## 新增 - 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) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
/// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景)
|
/// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景)
|
||||||
/// context.push(
|
/// context.push(
|
||||||
/// AppRouteName.chatDetail.path,
|
/// AppRouteName.chatDetail.path,
|
||||||
/// extra: (conversationId: '42', title: '技术支持'),
|
/// extra: (conversationId: '42', title: '技术支持', chatType: 1),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
/// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景)
|
/// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景)
|
||||||
@@ -60,7 +60,7 @@ enum AppRouteName {
|
|||||||
settings('/settings'),
|
settings('/settings'),
|
||||||
|
|
||||||
// ── Chat 子路由 ──────────────────────────────────────────────────────────
|
// ── Chat 子路由 ──────────────────────────────────────────────────────────
|
||||||
// extra: ({String conversationId, String title})
|
// extra: ({String conversationId, String title, int chatType})
|
||||||
chatDetail('/chat/detail'),
|
chatDetail('/chat/detail'),
|
||||||
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
|
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
|
||||||
chatDetailById('/chat/:id'),
|
chatDetailById('/chat/:id'),
|
||||||
|
|||||||
@@ -134,11 +134,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
parentNavigatorKey: _rootKey,
|
parentNavigatorKey: _rootKey,
|
||||||
path: AppRouteName.chatDetail.path,
|
path: AppRouteName.chatDetail.path,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final extra =
|
final extra = state.extra
|
||||||
state.extra as ({String conversationId, String title});
|
as ({String conversationId, String title, int chatType});
|
||||||
return ChatDetailPage(
|
return ChatDetailPage(
|
||||||
conversationId: extra.conversationId,
|
conversationId: extra.conversationId,
|
||||||
title: extra.title,
|
title: extra.title,
|
||||||
|
chatType: extra.chatType,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
302
apps/im_app/lib/core/services/typing_indicator_manager.dart
Normal file
@@ -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<int, Map<int, _TypingEntry>> _typing = {};
|
||||||
|
|
||||||
|
/// 每个 chatId 一个 StreamController,按需创建
|
||||||
|
final Map<int, StreamController<String?>> _controllers = {};
|
||||||
|
|
||||||
|
/// 全局通知(用于聊天列表等需要监听所有 chat 的场景)
|
||||||
|
final StreamController<void> _globalNotifier =
|
||||||
|
StreamController<void>.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<String?> typingTextStream({
|
||||||
|
required int chatId,
|
||||||
|
required int myUserId,
|
||||||
|
bool isGroup = false,
|
||||||
|
}) {
|
||||||
|
final ctrl = _controllers.putIfAbsent(
|
||||||
|
chatId,
|
||||||
|
() => StreamController<String?>.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<void> 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 = <int>{};
|
||||||
|
|
||||||
|
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<T> extends StreamTransformerBase<T, T> {
|
||||||
|
_StartWithTransformer(this._initial);
|
||||||
|
final T _initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<T> bind(Stream<T> stream) async* {
|
||||||
|
yield _initial;
|
||||||
|
yield* stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
110
apps/im_app/lib/core/services/typing_input_sender.dart
Normal file
@@ -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<int, DateTime> _lastSentAt = {};
|
||||||
|
|
||||||
|
/// Per-chatId 停止计时器
|
||||||
|
final Map<int, Timer> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:networks_sdk/networks_sdk.dart';
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
import 'package:im_app/core/services/socket_manager.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/data/remote/fetch_history_request.dart';
|
||||||
import 'package:im_app/domain/repositories/chat_repository.dart';
|
import 'package:im_app/domain/repositories/chat_repository.dart';
|
||||||
import 'package:im_app/domain/repositories/message_repository.dart';
|
import 'package:im_app/domain/repositories/message_repository.dart';
|
||||||
@@ -31,6 +32,7 @@ class WsMessageService {
|
|||||||
final NetworksSdkApi _apiClient;
|
final NetworksSdkApi _apiClient;
|
||||||
final MessageRepository _messageRepo;
|
final MessageRepository _messageRepo;
|
||||||
final ChatRepository _chatRepo;
|
final ChatRepository _chatRepo;
|
||||||
|
final TypingIndicatorManager _typingManager;
|
||||||
|
|
||||||
StreamSubscription<Map<String, dynamic>>? _sub;
|
StreamSubscription<Map<String, dynamic>>? _sub;
|
||||||
|
|
||||||
@@ -39,10 +41,12 @@ class WsMessageService {
|
|||||||
required NetworksSdkApi apiClient,
|
required NetworksSdkApi apiClient,
|
||||||
required MessageRepository messageRepo,
|
required MessageRepository messageRepo,
|
||||||
required ChatRepository chatRepo,
|
required ChatRepository chatRepo,
|
||||||
|
required TypingIndicatorManager typingManager,
|
||||||
}) : _socketManager = socketManager,
|
}) : _socketManager = socketManager,
|
||||||
_apiClient = apiClient,
|
_apiClient = apiClient,
|
||||||
_messageRepo = messageRepo,
|
_messageRepo = messageRepo,
|
||||||
_chatRepo = chatRepo;
|
_chatRepo = chatRepo,
|
||||||
|
_typingManager = typingManager;
|
||||||
|
|
||||||
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,6 +69,26 @@ class WsMessageService {
|
|||||||
|
|
||||||
Future<void> _handleFrame(Map<String, dynamic> frame) async {
|
Future<void> _handleFrame(Map<String, dynamic> frame) async {
|
||||||
try {
|
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<String, dynamic>?
|
||||||
|
?? frame['chat_typing'] as Map<String, dynamic>?;
|
||||||
|
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<dynamic>?
|
||||||
|
?? frame['r'] as List<dynamic>?;
|
||||||
|
if (ctlData != null) {
|
||||||
|
_handleTypingFrame({'r': ctlData});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── chat.r — 新消息通知 ────────────────────────────────────────────
|
||||||
final chatPayload = frame['chat'] as Map<String, dynamic>?;
|
final chatPayload = frame['chat'] as Map<String, dynamic>?;
|
||||||
if (chatPayload == null) return;
|
if (chatPayload == null) return;
|
||||||
|
|
||||||
@@ -79,6 +103,12 @@ class WsMessageService {
|
|||||||
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
|
final msgIdx = (entry['msg_idx'] as num?)?.toInt();
|
||||||
if (chatId == null || msgIdx == null) continue;
|
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 _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx);
|
||||||
await _updateChatMeta(chatId: chatId, entry: entry);
|
await _updateChatMeta(chatId: chatId, entry: entry);
|
||||||
}
|
}
|
||||||
@@ -87,6 +117,40 @@ class WsMessageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理 chat_input/chat_typing 帧
|
||||||
|
void _handleTypingFrame(Map<String, dynamic> payload) {
|
||||||
|
final rList = payload['r'] as List<dynamic>?;
|
||||||
|
if (rList == null) return;
|
||||||
|
|
||||||
|
for (final item in rList) {
|
||||||
|
final entry = item as Map<String, dynamic>?;
|
||||||
|
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
|
/// 通过 HTTP 拉取消息并写入 DB
|
||||||
Future<void> _fetchAndSaveMessages({
|
Future<void> _fetchAndSaveMessages({
|
||||||
required int chatId,
|
required int chatId,
|
||||||
@@ -109,6 +173,9 @@ class WsMessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx
|
/// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx
|
||||||
|
///
|
||||||
|
/// 包含 msgIdx 守卫:如果帧中的 msgIdx 小于 DB 已有值,说明是乱序到达的旧帧,
|
||||||
|
/// 跳过更新防止 lastMsg 被旧数据覆盖。
|
||||||
Future<void> _updateChatMeta({
|
Future<void> _updateChatMeta({
|
||||||
required int chatId,
|
required int chatId,
|
||||||
required Map<String, dynamic> entry,
|
required Map<String, dynamic> entry,
|
||||||
@@ -121,6 +188,11 @@ class WsMessageService {
|
|||||||
final lastTyp = (entry['typ'] as num?)?.toInt();
|
final lastTyp = (entry['typ'] as num?)?.toInt();
|
||||||
final msgIdx = (entry['msg_idx'] 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(
|
await _chatRepo.updateChat(
|
||||||
existing.copyWith(
|
existing.copyWith(
|
||||||
lastMsg: lastMsg ?? existing.lastMsg,
|
lastMsg: lastMsg ?? existing.lastMsg,
|
||||||
|
|||||||
@@ -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/app_providers.dart';
|
||||||
import 'package:im_app/app/di/network_provider.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/core/services/ws_message_service.dart';
|
||||||
import 'package:im_app/features/chat/di/chat_provider.dart';
|
import 'package:im_app/features/chat/di/chat_provider.dart';
|
||||||
import 'package:im_app/features/chat/di/message_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()`,
|
/// WS 消息服务在 Provider 创建时自动调用 `start()`,
|
||||||
/// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。
|
/// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。
|
||||||
|
|
||||||
|
// ── TypingIndicatorManager ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 输入状态管理器 Provider — 全局单例
|
||||||
|
///
|
||||||
|
/// 维护 [chatId → [userId → TypingEntry]] 的内存 Map。
|
||||||
|
/// WS 帧由 [WsMessageService] 转发到此。
|
||||||
|
/// UI 通过 [typingTextProvider] 订阅指定 chat 的输入状态。
|
||||||
|
final typingIndicatorManagerProvider = Provider<TypingIndicatorManager>((ref) {
|
||||||
|
final manager = TypingIndicatorManager();
|
||||||
|
ref.onDispose(manager.dispose);
|
||||||
|
return manager;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 输入状态全局变化流 — 聊天列表监听此 Provider 触发重建
|
||||||
|
///
|
||||||
|
/// 每次任何 chat 的输入状态变化时 emit,ChatPage 通过 ref.watch 自动重建列表。
|
||||||
|
/// 值无意义(只用作触发器),轻量级且不产生 GC 压力。
|
||||||
|
final typingChangeProvider = StreamProvider<void>((ref) {
|
||||||
|
return ref.watch(typingIndicatorManagerProvider).globalChangeStream;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TypingInputSender ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 发送侧输入状态 Provider — 3s 节流 + 2s 防抖
|
||||||
|
///
|
||||||
|
/// 由 ChatDetailPage 的 TextField.onChanged 调用。
|
||||||
|
final typingInputSenderProvider = Provider<TypingInputSender>((ref) {
|
||||||
|
final sender = TypingInputSender(
|
||||||
|
socketManager: ref.read(socketManagerProvider),
|
||||||
|
);
|
||||||
|
ref.onDispose(sender.dispose);
|
||||||
|
return sender;
|
||||||
|
});
|
||||||
|
|
||||||
// ── WsMessageService ──────────────────────────────────────────────────────────
|
// ── WsMessageService ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// WS 消息服务 Provider
|
/// WS 消息服务 Provider
|
||||||
@@ -32,6 +68,7 @@ final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
|
|||||||
apiClient: ref.read(networkSdkApiProvider),
|
apiClient: ref.read(networkSdkApiProvider),
|
||||||
messageRepo: ref.read(messageRepositoryProvider),
|
messageRepo: ref.read(messageRepositoryProvider),
|
||||||
chatRepo: ref.read(chatRepositoryProvider),
|
chatRepo: ref.read(chatRepositoryProvider),
|
||||||
|
typingManager: ref.read(typingIndicatorManagerProvider),
|
||||||
);
|
);
|
||||||
|
|
||||||
service.start();
|
service.start();
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ class ChatListViewModel extends Notifier<void> {
|
|||||||
final title = chat.name ?? 'Chat $chatId';
|
final title = chat.name ?? 'Chat $chatId';
|
||||||
context.push(
|
context.push(
|
||||||
AppRouteName.chatDetail.path,
|
AppRouteName.chatDetail.path,
|
||||||
extra: (conversationId: chatId.toString(), title: title),
|
extra: (
|
||||||
|
conversationId: chatId.toString(),
|
||||||
|
title: title,
|
||||||
|
chatType: chat.typ ?? 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ChatViewModel extends Notifier<void> {
|
|||||||
void pushChatDetailWithExtra(BuildContext context) {
|
void pushChatDetailWithExtra(BuildContext context) {
|
||||||
context.push(
|
context.push(
|
||||||
AppRouteName.chatDetail.path,
|
AppRouteName.chatDetail.path,
|
||||||
extra: (conversationId: '42', title: 'extra 传参'),
|
extra: (conversationId: '42', title: 'extra 传参', chatType: 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:im_app/app/di/app_providers.dart';
|
import 'package:im_app/app/di/app_providers.dart';
|
||||||
import 'package:im_app/domain/entities/message.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/di/message_provider.dart';
|
||||||
import 'package:im_app/features/chat/presentation/chat_detail_view_model.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/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/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_grid_bubble.dart';
|
||||||
import 'package:im_app/features/chat/view/widgets/image_message_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/image_picker_sheet.dart';
|
||||||
import 'package:im_app/features/chat/view/widgets/red_envelope_bubble.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/sticker_message_bubble.dart';
|
||||||
import 'package:im_app/features/chat/view/widgets/video_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)
|
/// 聊天详情页(#28 / #35 / #37)
|
||||||
///
|
///
|
||||||
@@ -63,23 +64,68 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
final _inputCtrl = TextEditingController();
|
final _inputCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
|
|
||||||
|
/// 当前输入状态显示文本("正在输入…" / null)
|
||||||
|
String? _typingText;
|
||||||
|
StreamSubscription<String?>? _typingSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_chatId = int.tryParse(widget.conversationId) ?? 0;
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_typingSub?.cancel();
|
||||||
|
_inputCtrl.removeListener(_onTextChanged);
|
||||||
_inputCtrl.dispose();
|
_inputCtrl.dispose();
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
super.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() {
|
void _send() {
|
||||||
final text = _inputCtrl.text.trim();
|
final text = _inputCtrl.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
_inputCtrl.clear();
|
_inputCtrl.clear();
|
||||||
|
// 消息发出 → 立即停止输入状态(仅单聊)
|
||||||
|
if (widget.chatType != 2) {
|
||||||
|
ref.read(typingInputSenderProvider).notifyStopTyping(chatId: _chatId);
|
||||||
|
}
|
||||||
ref
|
ref
|
||||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||||
.sendMessage(text);
|
.sendMessage(text);
|
||||||
@@ -214,7 +260,24 @@ class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
|||||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/domain/entities/chat.dart';
|
||||||
import 'package:im_app/features/chat/di/chat_provider.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';
|
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) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final vm = ref.watch(chatListViewModelProvider.notifier);
|
final vm = ref.watch(chatListViewModelProvider.notifier);
|
||||||
final chatsAsync = ref.watch(allChatsProvider);
|
final chatsAsync = ref.watch(allChatsProvider);
|
||||||
|
// 监听输入状态变化 → 触发列表重建(对齐 iOS @Published typing 全局更新)
|
||||||
|
ref.watch(typingChangeProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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});
|
const _ChatTile({required this.chat, required this.vm});
|
||||||
|
|
||||||
final Chat chat;
|
final Chat chat;
|
||||||
final ChatListViewModel vm;
|
final ChatListViewModel vm;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final name = chat.name ?? 'Chat ${chat.chatId ?? chat.id}';
|
final name = chat.name ?? 'Chat ${chat.chatId ?? chat.id}';
|
||||||
final lastMsg = chat.lastMsg ?? '';
|
final lastMsg = chat.lastMsg ?? '';
|
||||||
final unread = chat.unreadNum ?? 0;
|
final unread = chat.unreadNum ?? 0;
|
||||||
final sendTime = chat.lastTime;
|
final sendTime = chat.lastTime;
|
||||||
final timeStr = sendTime != null ? _formatTime(sendTime) : '';
|
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(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
@@ -86,14 +99,24 @@ class _ChatTile extends StatelessWidget {
|
|||||||
subtitle: Row(
|
subtitle: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: typingText != null
|
||||||
lastMsg,
|
// 输入状态优先显示(绿色,对齐 iOS onlineGreen)
|
||||||
maxLines: 1,
|
? Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
typingText,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
maxLines: 1,
|
||||||
color: Theme.of(context).colorScheme.outline,
|
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)
|
if (unread > 0)
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
Reference in New Issue
Block a user