## 新增 - 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:
@@ -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: [
|
||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user