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>
154 lines
5.4 KiB
Dart
154 lines
5.4 KiB
Dart
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}';
|
||
}
|
||
}
|