Files
customer-im-client-dev/apps/im_app/lib/features/chat/view/chat_page.dart
pp-bot e8f58212e6
Some checks failed
CI / Lint (push) Has been cancelled
feat(chat): 正在输入指示器 — 点对点复刻 iOS + 性能改进
## 新增
- 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>
2026-04-09 14:59:30 +09:00

154 lines
5.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// 聊天列表页
///
/// 从本地 DB Stream 读取会话列表,实时反映 WS 推送的新消息摘要。
/// 点击任意会话进入 [ChatDetailPage]chatId + 名称作为路由参数)。
class ChatPage extends ConsumerWidget {
const ChatPage({super.key});
@override
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(
title: const Text('消息'),
actions: [
IconButton(
icon: const Icon(Icons.storage_outlined),
tooltip: '数据库测试',
onPressed: () => vm.goToDatabaseTest(context),
),
],
),
body: chatsAsync.when(
data: (chats) => chats.isEmpty
? const Center(child: Text('暂无会话'))
: ListView.separated(
itemCount: chats.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, indent: 72),
itemBuilder: (context, index) =>
_ChatTile(chat: chats[index], vm: vm),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败: $e')),
),
);
}
}
class _ChatTile extends ConsumerWidget {
const _ChatTile({required this.chat, required this.vm});
final Chat chat;
final ChatListViewModel vm;
@override
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,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
title: Row(
children: [
Expanded(
child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (timeStr.isNotEmpty)
Text(
timeStr,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
subtitle: Row(
children: [
Expanded(
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(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(10),
),
child: Text(
unread > 99 ? '99+' : '$unread',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onError,
),
),
),
],
),
onTap: () => vm.openChat(context, chat),
);
}
String _formatTime(int unixSec) {
final dt = DateTime.fromMillisecondsSinceEpoch(unixSec * 1000);
final now = DateTime.now();
if (dt.year == now.year &&
dt.month == now.month &&
dt.day == now.day) {
return '${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}';
}
return '${dt.month}/${dt.day}';
}
}