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

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