feat(chat): 正在输入指示器 — 点对点复刻 iOS + 性能改进
Some checks failed
CI / Lint (push) Has been cancelled

## 新增
- 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:
pp-bot
2026-04-09 14:59:30 +09:00
parent b8f1f82ee5
commit e8f58212e6
10 changed files with 634 additions and 22 deletions

View File

@@ -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<TypingIndicatorManager>((ref) {
final manager = TypingIndicatorManager();
ref.onDispose(manager.dispose);
return manager;
});
/// 输入状态全局变化流 — 聊天列表监听此 Provider 触发重建
///
/// 每次任何 chat 的输入状态变化时 emitChatPage 通过 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 ──────────────────────────────────────────────────────────
/// WS 消息服务 Provider
@@ -32,6 +68,7 @@ final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
apiClient: ref.read(networkSdkApiProvider),
messageRepo: ref.read(messageRepositoryProvider),
chatRepo: ref.read(chatRepositoryProvider),
typingManager: ref.read(typingIndicatorManagerProvider),
);
service.start();

View File

@@ -25,7 +25,11 @@ class ChatListViewModel extends Notifier<void> {
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,
),
);
}

View File

@@ -17,7 +17,7 @@ class ChatViewModel extends Notifier<void> {
void pushChatDetailWithExtra(BuildContext context) {
context.push(
AppRouteName.chatDetail.path,
extra: (conversationId: '42', title: 'extra 传参'),
extra: (conversationId: '42', title: 'extra 传参', chatType: 1),
);
}

View File

@@ -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<ChatDetailPage> {
final _inputCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
/// 当前输入状态显示文本("正在输入…" / null
String? _typingText;
StreamSubscription<String?>? _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<ChatDetailPage> {
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: [
// ── 消息列表 ────────────────────────────────────────────────────────

View File

@@ -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(