From e8f58212e6ba6371b382709a1004c1b38caa7910 Mon Sep 17 00:00:00 2001 From: pp-bot Date: Thu, 9 Apr 2026 14:59:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=AD=A3=E5=9C=A8=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=8C=87=E7=A4=BA=E5=99=A8=20=E2=80=94=20=E7=82=B9?= =?UTF-8?q?=E5=AF=B9=E7=82=B9=E5=A4=8D=E5=88=BB=20iOS=20+=20=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增 - TypingIndicatorManager: 内存态管理器,精准Timer替代iOS 1s轮询 - TypingInputSender: per-chatId 节流(3s)/防抖(2s),修复iOS跨chat竞态 - WS chat_input/chat_typing 帧处理(mode2 + ctl 双路径) ## UI - ChatDetailPage AppBar 绿色副标题显示"正在输入…" - ChatPage 列表 snippet 绿色输入状态优先于 lastMsg - 群聊不发送 typing 事件(对齐 iOS gate) ## 改进 (vs iOS) - Timer 仅在有 entry 时启动,空时 null(零空转) - per-chatId 隔离节流/防抖(iOS 全局共享有竞态 bug) - msgIdx 守卫防止乱序帧覆盖 lastMsg Co-Authored-By: Claude Opus 4.6 (1M context) --- .../im_app/lib/app/router/app_route_name.dart | 4 +- apps/im_app/lib/app/router/app_router.dart | 5 +- .../services/typing_indicator_manager.dart | 302 ++++++++++++++++++ .../core/services/typing_input_sender.dart | 110 +++++++ .../lib/core/services/ws_message_service.dart | 74 ++++- .../chat/di/chat_service_providers.dart | 37 +++ .../presentation/chat_list_view_model.dart | 6 +- .../chat/presentation/chat_view_model.dart | 2 +- .../features/chat/view/chat_detail_page.dart | 75 ++++- .../lib/features/chat/view/chat_page.dart | 41 ++- 10 files changed, 634 insertions(+), 22 deletions(-) create mode 100644 apps/im_app/lib/core/services/typing_indicator_manager.dart create mode 100644 apps/im_app/lib/core/services/typing_input_sender.dart diff --git a/apps/im_app/lib/app/router/app_route_name.dart b/apps/im_app/lib/app/router/app_route_name.dart index f4a97ba..7d5792b 100644 --- a/apps/im_app/lib/app/router/app_route_name.dart +++ b/apps/im_app/lib/app/router/app_route_name.dart @@ -17,7 +17,7 @@ /// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景) /// context.push( /// AppRouteName.chatDetail.path, -/// extra: (conversationId: '42', title: '技术支持'), +/// extra: (conversationId: '42', title: '技术支持', chatType: 1), /// ); /// /// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景) @@ -60,7 +60,7 @@ enum AppRouteName { settings('/settings'), // ── Chat 子路由 ────────────────────────────────────────────────────────── - // extra: ({String conversationId, String title}) + // extra: ({String conversationId, String title, int chatType}) chatDetail('/chat/detail'), // 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path chatDetailById('/chat/:id'), diff --git a/apps/im_app/lib/app/router/app_router.dart b/apps/im_app/lib/app/router/app_router.dart index b56cce3..82befcc 100644 --- a/apps/im_app/lib/app/router/app_router.dart +++ b/apps/im_app/lib/app/router/app_router.dart @@ -134,11 +134,12 @@ final routerProvider = Provider((ref) { parentNavigatorKey: _rootKey, path: AppRouteName.chatDetail.path, builder: (context, state) { - final extra = - state.extra as ({String conversationId, String title}); + final extra = state.extra + as ({String conversationId, String title, int chatType}); return ChatDetailPage( conversationId: extra.conversationId, title: extra.title, + chatType: extra.chatType, ); }, ), diff --git a/apps/im_app/lib/core/services/typing_indicator_manager.dart b/apps/im_app/lib/core/services/typing_indicator_manager.dart new file mode 100644 index 0000000..babf9bf --- /dev/null +++ b/apps/im_app/lib/core/services/typing_indicator_manager.dart @@ -0,0 +1,302 @@ +import 'dart:async'; + +/// 输入状态枚举 — 对齐 Flutter ChatInputState / iOS ChatInputState +/// +/// state=1 → typing, state=2 → noTyping (clear), 3-7 → media sending +enum ChatInputState { + typing(1), + noTyping(2), + sendImage(3), + sendVideo(4), + sendDocument(5), + sendAlbum(6), + sendVoice(7); + + const ChatInputState(this.value); + final int value; + + static ChatInputState? fromValue(int v) { + for (final s in values) { + if (s.value == v) return s; + } + return null; + } + + /// 过期秒数:文字输入 5s,媒体发送 30s(对齐 iOS/Flutter) + Duration get expiry { + switch (this) { + case sendImage: + case sendVideo: + case sendDocument: + case sendAlbum: + case sendVoice: + return const Duration(seconds: 30); + default: + return const Duration(seconds: 5); + } + } + + /// 中文展示文本 + String get displayText { + switch (this) { + case typing: + return '正在输入…'; + case sendImage: + return '正在发送图片…'; + case sendVideo: + return '正在发送视频…'; + case sendDocument: + return '正在发送文件…'; + case sendAlbum: + return '正在发送相册…'; + case sendVoice: + return '正在发送语音…'; + case noTyping: + return ''; + } + } +} + +/// 单条输入条目 +class _TypingEntry { + _TypingEntry({ + required this.userId, + required this.username, + required this.state, + required this.expiresAt, + }); + + final int userId; + final String username; + final ChatInputState state; + final DateTime expiresAt; +} + +/// 正在输入状态管理器 — 对齐 iOS TypingIndicatorManager + 性能优化 +/// +/// ## 对齐 iOS 行为 +/// - WS 收到 chat_input/chat_typing 时调用 [handleTypingEvent] +/// - 收到真实消息时调用 [clearTyping] 立即清除(不等 5s 过期) +/// - 文字输入 5s 过期,媒体发送 30s 过期 +/// - 单聊显示 "正在输入…",群聊显示 "Alice 正在输入…" / "N 人正在输入…" +/// +/// ## 性能改进(vs iOS) +/// - **无常驻 Timer**:仅在有 entry 时启动清理 timer,空时自动取消 +/// - **精准调度**:timer 对齐最近过期时间,不是每秒轮询 +/// - **按 chatId 通知**:[typingTextStream] 返回指定 chat 的 Stream, +/// 只有该 chat 的状态变化才触发,避免全局重建 +class TypingIndicatorManager { + /// [chatId → [userId → _TypingEntry]] + final Map> _typing = {}; + + /// 每个 chatId 一个 StreamController,按需创建 + final Map> _controllers = {}; + + /// 全局通知(用于聊天列表等需要监听所有 chat 的场景) + final StreamController _globalNotifier = + StreamController.broadcast(); + + Timer? _purgeTimer; + + // ── 接收侧 ────────────────────────────────────────────────────────────── + + /// 处理 WS 收到的 chat_input/chat_typing 事件 + /// + /// [senderId], [chatId], [stateValue] 从 WS 帧解析。 + /// [username] 用于群聊显示名。 + void handleTypingEvent({ + required int chatId, + required int senderId, + required String username, + required int stateValue, + }) { + final state = ChatInputState.fromValue(stateValue); + if (state == null) return; + + if (state == ChatInputState.noTyping) { + _typing[chatId]?.remove(senderId); + if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId); + } else { + _typing.putIfAbsent(chatId, () => {}); + _typing[chatId]![senderId] = _TypingEntry( + userId: senderId, + username: username, + state: state, + expiresAt: DateTime.now().add(state.expiry), + ); + } + + _notify(chatId); + _schedulePurge(); + } + + /// 收到真实消息时立即清除该发送者的输入状态 + /// (对齐 iOS: TypingIndicatorManager.clearTyping + .didReceiveChatMessage) + void clearTyping({required int chatId, required int senderId}) { + if (_typing[chatId]?.remove(senderId) != null) { + if (_typing[chatId]?.isEmpty == true) _typing.remove(chatId); + _notify(chatId); + } + } + + // ── 查询侧 ────────────────────────────────────────────────────────────── + + /// 单聊:返回第一个活跃输入者的状态文本,排除自己 + /// 对齐 iOS displayText(for:excludingUserId:) + String? displayText({required int chatId, required int myUserId}) { + final entries = _typing[chatId]?.values; + if (entries == null || entries.isEmpty) return null; + for (final e in entries) { + if (e.userId != myUserId && e.state != ChatInputState.noTyping) { + return e.state.displayText; + } + } + return null; + } + + /// 群聊:多人输入显示规则 + /// - 1 人 → "Alice 正在输入…" + /// - 2-3 人 → "Alice、Bob 正在输入…" + /// - 4+ 人 → "N 人正在输入…" + /// 对齐 iOS groupDisplayText(for:groupId:excludingUserId:) + String? groupDisplayText({required int chatId, required int myUserId}) { + final entries = _typing[chatId]?.values; + if (entries == null || entries.isEmpty) return null; + final active = entries + .where((e) => e.userId != myUserId && e.state != ChatInputState.noTyping) + .toList(); + if (active.isEmpty) return null; + + switch (active.length) { + case 1: + final name = active[0].username.isNotEmpty + ? active[0].username + : '用户${active[0].userId}'; + return '$name ${active[0].state.displayText}'; + case 2: + case 3: + final names = active + .map((e) => e.username.isNotEmpty ? e.username : '用户${e.userId}') + .join('、'); + return '$names 正在输入…'; + default: + return '${active.length} 人正在输入…'; + } + } + + /// 返回指定 chatId 的输入状态 Stream + /// + /// 每次该 chatId 的输入状态变化时发出新的显示文本(null = 无人输入)。 + /// 懒创建 StreamController,dispose 时自动清理。 + Stream typingTextStream({ + required int chatId, + required int myUserId, + bool isGroup = false, + }) { + final ctrl = _controllers.putIfAbsent( + chatId, + () => StreamController.broadcast(), + ); + // 立即发出当前状态 + final current = isGroup + ? groupDisplayText(chatId: chatId, myUserId: myUserId) + : displayText(chatId: chatId, myUserId: myUserId); + return ctrl.stream.transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) => sink.add(data), + ), + ).transform(_StartWithTransformer(current)); + } + + /// 全局变化通知(聊天列表用) + Stream get globalChangeStream => _globalNotifier.stream; + + // ── 内部 ──────────────────────────────────────────────────────────────── + + void _notify(int chatId) { + // 通知该 chatId 的订阅者 + final ctrl = _controllers[chatId]; + if (ctrl != null && !ctrl.isClosed) { + // 不知道订阅者的 myUserId / isGroup,所以传 null 让 UI 层自己查 + ctrl.add(null); // sentinel — UI 层重新查询 displayText + } + // 通知全局订阅者 + if (!_globalNotifier.isClosed) { + _globalNotifier.add(null); + } + } + + /// 智能清理调度 — 对齐最近过期时间,非 1s 轮询 + /// + /// iOS 问题:1s Timer 永远运行,即使 typing 为空。 + /// 改进:找到最近过期的 entry,只在那个时间点触发一次清理。 + void _schedulePurge() { + _purgeTimer?.cancel(); + + if (_typing.isEmpty) { + _purgeTimer = null; + return; + } + + // 找最近过期时间 + DateTime? earliest; + for (final chatEntries in _typing.values) { + for (final entry in chatEntries.values) { + if (earliest == null || entry.expiresAt.isBefore(earliest)) { + earliest = entry.expiresAt; + } + } + } + if (earliest == null) return; + + final delay = earliest.difference(DateTime.now()); + // 至少 100ms 防止 busy-loop + final safeDuration = + delay.isNegative ? const Duration(milliseconds: 100) : delay; + + _purgeTimer = Timer(safeDuration, _purgeExpired); + } + + void _purgeExpired() { + final now = DateTime.now(); + final changedChats = {}; + + for (final chatId in _typing.keys.toList()) { + final entries = _typing[chatId]!; + final before = entries.length; + entries.removeWhere((_, entry) => entry.expiresAt.isBefore(now)); + if (entries.isEmpty) _typing.remove(chatId); + if ((entries.length) != before) changedChats.add(chatId); + } + + for (final chatId in changedChats) { + _notify(chatId); + } + + // 如果还有 entry,继续调度下一次 + _schedulePurge(); + } + + /// 释放资源 + void dispose() { + _purgeTimer?.cancel(); + for (final ctrl in _controllers.values) { + ctrl.close(); + } + _controllers.clear(); + _globalNotifier.close(); + _typing.clear(); + } +} + +/// 在 Stream 前插入一个初始值 +class _StartWithTransformer extends StreamTransformerBase { + _StartWithTransformer(this._initial); + final T _initial; + + @override + Stream bind(Stream stream) async* { + yield _initial; + yield* stream; + } +} diff --git a/apps/im_app/lib/core/services/typing_input_sender.dart b/apps/im_app/lib/core/services/typing_input_sender.dart new file mode 100644 index 0000000..13a9c2f --- /dev/null +++ b/apps/im_app/lib/core/services/typing_input_sender.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:im_app/core/services/socket_manager.dart'; + +/// 发送侧"正在输入" WS 事件管理器 +/// +/// 对齐 iOS `TypingInputSender.swift`: +/// - 节流 3s:用户输入时最多每 3s 发一次 {typ:1} +/// - 防抖 2s:用户停止输入 2s 后自动发 {typ:0} +/// - 消息发送 / 文本清空时立即发 {typ:0} +/// +/// ## 改进(vs iOS TypingInputSender.swift) +/// +/// iOS 版用全局单一 `lastTypingSentAt` / `stopTask`,当用户在 3s 内切换聊天时 +/// 会导致:chat A 的 stop timer 被 chat B 覆盖,chat A 输入状态无法正常清除。 +/// Flutter 版改为 **per-chatId** 节流/防抖,彻底消除跨 chat 竞态。 +/// +/// WS 发送格式(对齐 iOS TypingInputSender.sendAction): +/// ```json +/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 1} // 开始输入 +/// {"action": "ACTION_SENDINPUT_MSG", "chat_id": N, "typ": 0} // 停止输入 +/// ``` +class TypingInputSender { + TypingInputSender({required SocketManager socketManager}) + : _socketManager = socketManager; + + final SocketManager _socketManager; + + /// 节流间隔:最多每 3s 发一次 typ=1(对齐 iOS inputThrottleInterval = 3.0) + static const _throttleInterval = Duration(seconds: 3); + + /// 停止输入防抖:最后一次击键 2s 后发 typ=0(对齐 iOS inputStopDelay = 2.0) + static const _stopDelay = Duration(seconds: 2); + + /// Per-chatId 节流时间戳(防止跨 chat 竞态) + final Map _lastSentAt = {}; + + /// Per-chatId 停止计时器 + final Map _stopTimers = {}; + + /// 用户输入文本变化时调用 + /// + /// [text] 为当前输入框全文。 + /// - 非空:节流发 typ=1 + 重置 2s 停止计时 + /// - 空(清空输入框):立即发 typ=0 + /// + /// 对齐 iOS ChatViewModel.onComposerTextChanged + void onTextChanged({required int chatId, required String text}) { + final isTyping = text.trim().isNotEmpty; + + if (isTyping) { + // 重置该 chat 的 2s 停止计时 + _stopTimers[chatId]?.cancel(); + _stopTimers[chatId] = Timer(_stopDelay, () { + _sendAction(chatId: chatId, typ: 0); + _lastSentAt.remove(chatId); + _stopTimers.remove(chatId); + }); + + // 节流:该 chat 距上次发送不足 3s 则跳过 + final now = DateTime.now(); + final lastSent = _lastSentAt[chatId]; + if (lastSent != null && now.difference(lastSent) < _throttleInterval) { + return; + } + _lastSentAt[chatId] = now; + _sendAction(chatId: chatId, typ: 1); + } else { + // 文本清空 → 立即停止 + _stopTimers[chatId]?.cancel(); + _stopTimers.remove(chatId); + if (_lastSentAt.containsKey(chatId)) { + _lastSentAt.remove(chatId); + _sendAction(chatId: chatId, typ: 0); + } + } + } + + /// 消息发送成功后调用 — 立即发 typ=0 + /// + /// 对齐 iOS TypingInputSender.notifyStopTyping + void notifyStopTyping({required int chatId}) { + _stopTimers[chatId]?.cancel(); + _stopTimers.remove(chatId); + _lastSentAt.remove(chatId); + _sendAction(chatId: chatId, typ: 0); + } + + void _sendAction({required int chatId, required int typ}) { + final payload = { + 'action': 'ACTION_SENDINPUT_MSG', + 'chat_id': chatId, + 'typ': typ, + }; + final text = jsonEncode(payload); + _socketManager.sendString(text); + debugPrint('[TypingInputSender] chat=$chatId typ=$typ'); + } + + void dispose() { + for (final timer in _stopTimers.values) { + timer.cancel(); + } + _stopTimers.clear(); + _lastSentAt.clear(); + } +} diff --git a/apps/im_app/lib/core/services/ws_message_service.dart b/apps/im_app/lib/core/services/ws_message_service.dart index 85d5333..eece87b 100644 --- a/apps/im_app/lib/core/services/ws_message_service.dart +++ b/apps/im_app/lib/core/services/ws_message_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:networks_sdk/networks_sdk.dart'; import 'package:im_app/core/services/socket_manager.dart'; +import 'package:im_app/core/services/typing_indicator_manager.dart'; import 'package:im_app/data/remote/fetch_history_request.dart'; import 'package:im_app/domain/repositories/chat_repository.dart'; import 'package:im_app/domain/repositories/message_repository.dart'; @@ -31,6 +32,7 @@ class WsMessageService { final NetworksSdkApi _apiClient; final MessageRepository _messageRepo; final ChatRepository _chatRepo; + final TypingIndicatorManager _typingManager; StreamSubscription>? _sub; @@ -39,10 +41,12 @@ class WsMessageService { required NetworksSdkApi apiClient, required MessageRepository messageRepo, required ChatRepository chatRepo, + required TypingIndicatorManager typingManager, }) : _socketManager = socketManager, _apiClient = apiClient, _messageRepo = messageRepo, - _chatRepo = chatRepo; + _chatRepo = chatRepo, + _typingManager = typingManager; // ── 生命周期 ────────────────────────────────────────────────────────────── @@ -65,6 +69,26 @@ class WsMessageService { Future _handleFrame(Map frame) async { try { + // ── chat_input / chat_typing — 正在输入状态 ───────────────────────── + // mode2: {"chat_input": {"r": [{"send_id": N, "chat_id": N, "username": "Alice", "state": N}]}} + // ctl: {"ctl": "chat_input", "r": [...]} + // 对齐 iOS MessageReceiver.handleChatInput + final typingPayload = frame['chat_input'] as Map? + ?? frame['chat_typing'] as Map?; + if (typingPayload != null) { + _handleTypingFrame(typingPayload); + } + // ctl 路径 + final ctl = frame['ctl'] as String?; + if (ctl == 'chat_input' || ctl == 'chat_typing') { + final ctlData = frame['data'] as List? + ?? frame['r'] as List?; + if (ctlData != null) { + _handleTypingFrame({'r': ctlData}); + } + } + + // ── chat.r — 新消息通知 ──────────────────────────────────────────── final chatPayload = frame['chat'] as Map?; if (chatPayload == null) return; @@ -79,6 +103,12 @@ class WsMessageService { final msgIdx = (entry['msg_idx'] as num?)?.toInt(); if (chatId == null || msgIdx == null) continue; + // 收到真实消息 → 清除发送者的输入状态(对齐 iOS .didReceiveChatMessage) + final sendId = (entry['send_id'] as num?)?.toInt(); + if (sendId != null) { + _typingManager.clearTyping(chatId: chatId, senderId: sendId); + } + await _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx); await _updateChatMeta(chatId: chatId, entry: entry); } @@ -87,6 +117,40 @@ class WsMessageService { } } + /// 处理 chat_input/chat_typing 帧 + void _handleTypingFrame(Map payload) { + final rList = payload['r'] as List?; + if (rList == null) return; + + for (final item in rList) { + final entry = item as Map?; + if (entry == null) continue; + + // 兼容 Int / String 类型(对齐 iOS handleChatInput 的 flatMap(Int.init)) + final senderId = _parseInt(entry['send_id']); + final chatId = _parseInt(entry['chat_id']); + final stateValue = _parseInt(entry['state']) ?? _parseInt(entry['typ']); + final username = entry['username'] as String? ?? ''; + + if (senderId == null || chatId == null || stateValue == null) continue; + + _typingManager.handleTypingEvent( + chatId: chatId, + senderId: senderId, + username: username, + stateValue: stateValue, + ); + } + } + + /// 安全解析 int — 兼容 int / String / num(对齐 iOS Int ?? String→Int) + static int? _parseInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + /// 通过 HTTP 拉取消息并写入 DB Future _fetchAndSaveMessages({ required int chatId, @@ -109,6 +173,9 @@ class WsMessageService { } /// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx + /// + /// 包含 msgIdx 守卫:如果帧中的 msgIdx 小于 DB 已有值,说明是乱序到达的旧帧, + /// 跳过更新防止 lastMsg 被旧数据覆盖。 Future _updateChatMeta({ required int chatId, required Map entry, @@ -121,6 +188,11 @@ class WsMessageService { final lastTyp = (entry['typ'] as num?)?.toInt(); final msgIdx = (entry['msg_idx'] as num?)?.toInt(); + // 防止乱序帧覆盖新数据(Codex review #4) + if (msgIdx != null && existing.msgIdx != null && msgIdx < existing.msgIdx!) { + return; + } + await _chatRepo.updateChat( existing.copyWith( lastMsg: lastMsg ?? existing.lastMsg, diff --git a/apps/im_app/lib/features/chat/di/chat_service_providers.dart b/apps/im_app/lib/features/chat/di/chat_service_providers.dart index b749fa4..64cb2be 100644 --- a/apps/im_app/lib/features/chat/di/chat_service_providers.dart +++ b/apps/im_app/lib/features/chat/di/chat_service_providers.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:im_app/app/di/app_providers.dart'; import 'package:im_app/app/di/network_provider.dart'; +import 'package:im_app/core/services/typing_indicator_manager.dart'; +import 'package:im_app/core/services/typing_input_sender.dart'; import 'package:im_app/core/services/ws_message_service.dart'; import 'package:im_app/features/chat/di/chat_provider.dart'; import 'package:im_app/features/chat/di/message_provider.dart'; @@ -20,6 +22,40 @@ import 'package:im_app/features/chat/usecases/send_video_usecase.dart'; /// WS 消息服务在 Provider 创建时自动调用 `start()`, /// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。 +// ── TypingIndicatorManager ──────────────────────────────────────────────────── + +/// 输入状态管理器 Provider — 全局单例 +/// +/// 维护 [chatId → [userId → TypingEntry]] 的内存 Map。 +/// WS 帧由 [WsMessageService] 转发到此。 +/// UI 通过 [typingTextProvider] 订阅指定 chat 的输入状态。 +final typingIndicatorManagerProvider = Provider((ref) { + final manager = TypingIndicatorManager(); + ref.onDispose(manager.dispose); + return manager; +}); + +/// 输入状态全局变化流 — 聊天列表监听此 Provider 触发重建 +/// +/// 每次任何 chat 的输入状态变化时 emit,ChatPage 通过 ref.watch 自动重建列表。 +/// 值无意义(只用作触发器),轻量级且不产生 GC 压力。 +final typingChangeProvider = StreamProvider((ref) { + return ref.watch(typingIndicatorManagerProvider).globalChangeStream; +}); + +// ── TypingInputSender ──────────────────────────────────────────────────────── + +/// 发送侧输入状态 Provider — 3s 节流 + 2s 防抖 +/// +/// 由 ChatDetailPage 的 TextField.onChanged 调用。 +final typingInputSenderProvider = Provider((ref) { + final sender = TypingInputSender( + socketManager: ref.read(socketManagerProvider), + ); + ref.onDispose(sender.dispose); + return sender; +}); + // ── WsMessageService ────────────────────────────────────────────────────────── /// WS 消息服务 Provider @@ -32,6 +68,7 @@ final wsMessageServiceProvider = Provider((ref) { apiClient: ref.read(networkSdkApiProvider), messageRepo: ref.read(messageRepositoryProvider), chatRepo: ref.read(chatRepositoryProvider), + typingManager: ref.read(typingIndicatorManagerProvider), ); service.start(); diff --git a/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart index a78c014..8a3fded 100644 --- a/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart @@ -25,7 +25,11 @@ class ChatListViewModel extends Notifier { final title = chat.name ?? 'Chat $chatId'; context.push( AppRouteName.chatDetail.path, - extra: (conversationId: chatId.toString(), title: title), + extra: ( + conversationId: chatId.toString(), + title: title, + chatType: chat.typ ?? 1, + ), ); } diff --git a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart index 931c089..d5ee8e7 100644 --- a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart @@ -17,7 +17,7 @@ class ChatViewModel extends Notifier { void pushChatDetailWithExtra(BuildContext context) { context.push( AppRouteName.chatDetail.path, - extra: (conversationId: '42', title: 'extra 传参'), + extra: (conversationId: '42', title: 'extra 传参', chatType: 1), ); } diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart index 4c0249e..9d7dde3 100644 --- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart @@ -1,23 +1,24 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:im_app/app/di/app_providers.dart'; import 'package:im_app/domain/entities/message.dart'; +import 'package:im_app/features/chat/di/chat_service_providers.dart'; import 'package:im_app/features/chat/di/message_provider.dart'; import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart'; +import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart'; import 'package:im_app/features/chat/view/widgets/audio_message_bubble.dart'; +import 'package:im_app/features/chat/view/widgets/emoji_panel.dart'; import 'package:im_app/features/chat/view/widgets/file_message_bubble.dart'; import 'package:im_app/features/chat/view/widgets/image_grid_bubble.dart'; import 'package:im_app/features/chat/view/widgets/image_message_bubble.dart'; -import 'package:image_picker/image_picker.dart'; - -import 'package:im_app/features/chat/di/chat_service_providers.dart'; -import 'package:im_app/features/chat/view/widgets/attachment_panel_sheet.dart'; -import 'package:im_app/features/chat/view/widgets/emoji_panel.dart'; import 'package:im_app/features/chat/view/widgets/image_picker_sheet.dart'; import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.dart'; import 'package:im_app/features/chat/view/widgets/sticker_message_bubble.dart'; import 'package:im_app/features/chat/view/widgets/video_message_bubble.dart'; +import 'package:image_picker/image_picker.dart'; /// 聊天详情页(#28 / #35 / #37) /// @@ -63,23 +64,68 @@ class _ChatDetailPageState extends ConsumerState { final _inputCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); + /// 当前输入状态显示文本("正在输入…" / null) + String? _typingText; + StreamSubscription? _typingSub; + @override void initState() { super.initState(); _chatId = int.tryParse(widget.conversationId) ?? 0; + _inputCtrl.addListener(_onTextChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 订阅输入状态 Stream(首次 + 依赖变化时重建) + if (_typingSub == null) { + final uid = ref.read(authNotifierProvider).currentUid ?? 0; + final mgr = ref.read(typingIndicatorManagerProvider); + final isGroup = widget.chatType == 2; + _typingSub = mgr + .typingTextStream(chatId: _chatId, myUserId: uid, isGroup: isGroup) + .listen((text) { + // Stream sentinel: text == null → 重新查询 displayText + final uid = ref.read(authNotifierProvider).currentUid ?? 0; + final actual = isGroup + ? mgr.groupDisplayText(chatId: _chatId, myUserId: uid) + : mgr.displayText(chatId: _chatId, myUserId: uid); + if (actual != _typingText) { + setState(() => _typingText = actual); + } + }); + } } @override void dispose() { + _typingSub?.cancel(); + _inputCtrl.removeListener(_onTextChanged); _inputCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } + /// TextField 输入变化 → 发送输入状态(仅单聊) + /// + /// 对齐 iOS ChatView.swift:1052 — 单聊"对方正在输入",群聊不发送 + void _onTextChanged() { + if (widget.chatType == 2) return; // 群聊不发送 typing 事件 + ref.read(typingInputSenderProvider).onTextChanged( + chatId: _chatId, + text: _inputCtrl.text, + ); + } + void _send() { final text = _inputCtrl.text.trim(); if (text.isEmpty) return; _inputCtrl.clear(); + // 消息发出 → 立即停止输入状态(仅单聊) + if (widget.chatType != 2) { + ref.read(typingInputSenderProvider).notifyStopTyping(chatId: _chatId); + } ref .read(chatDetailViewModelProvider(_chatId).notifier) .sendMessage(text); @@ -214,7 +260,24 @@ class _ChatDetailPageState extends ConsumerState { final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0; return Scaffold( - appBar: AppBar(title: Text(widget.title)), + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title), + // 正在输入副标题(绿色,对齐 iOS ChatNavToolbar typing 参数) + if (_typingText != null) + Text( + _typingText!, + style: TextStyle( + fontSize: 12, + color: Colors.green.shade600, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), body: Column( children: [ // ── 消息列表 ──────────────────────────────────────────────────────── diff --git a/apps/im_app/lib/features/chat/view/chat_page.dart b/apps/im_app/lib/features/chat/view/chat_page.dart index 360525b..262ab47 100644 --- a/apps/im_app/lib/features/chat/view/chat_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:im_app/app/di/app_providers.dart'; import 'package:im_app/domain/entities/chat.dart'; import 'package:im_app/features/chat/di/chat_provider.dart'; +import 'package:im_app/features/chat/di/chat_service_providers.dart'; import 'package:im_app/features/chat/presentation/chat_list_view_model.dart'; /// 聊天列表页 @@ -16,6 +18,8 @@ class ChatPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final vm = ref.watch(chatListViewModelProvider.notifier); final chatsAsync = ref.watch(allChatsProvider); + // 监听输入状态变化 → 触发列表重建(对齐 iOS @Published typing 全局更新) + ref.watch(typingChangeProvider); return Scaffold( appBar: AppBar( @@ -45,20 +49,29 @@ class ChatPage extends ConsumerWidget { } } -class _ChatTile extends StatelessWidget { +class _ChatTile extends ConsumerWidget { const _ChatTile({required this.chat, required this.vm}); final Chat chat; final ChatListViewModel vm; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final name = chat.name ?? 'Chat ${chat.chatId ?? chat.id}'; final lastMsg = chat.lastMsg ?? ''; final unread = chat.unreadNum ?? 0; final sendTime = chat.lastTime; final timeStr = sendTime != null ? _formatTime(sendTime) : ''; + // 正在输入状态(对齐 iOS ConversationCell.typingText) + final chatId = chat.chatId ?? chat.id; + final myUid = ref.watch(authNotifierProvider).currentUid ?? 0; + final typingMgr = ref.watch(typingIndicatorManagerProvider); + final isGroup = (chat.typ ?? 1) == 2; + final typingText = isGroup + ? typingMgr.groupDisplayText(chatId: chatId, myUserId: myUid) + : typingMgr.displayText(chatId: chatId, myUserId: myUid); + return ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, @@ -86,14 +99,24 @@ class _ChatTile extends StatelessWidget { subtitle: Row( children: [ Expanded( - child: Text( - lastMsg, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, + child: typingText != null + // 输入状态优先显示(绿色,对齐 iOS onlineGreen) + ? Text( + typingText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green.shade600, + ), + ) + : Text( + lastMsg, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), ), - ), ), if (unread > 0) Container(