feat(chat): 发收消息全量实现 (#25~#28)
- 移除 @riverpod/@freezed 注解依赖,全部改为手写 Provider(无需 build_runner) · LoginState 改为纯 Dart,LoginViewModel/ThemeViewModel/ChatViewModel 改为 Notifier · UserNotifier 改为 FamilyAsyncNotifier<User?,int>,mini_app_provider 改为手写 Provider · 15 个 StreamProvider/StreamProvider.family 从 @riverpod 迁移至手写 - 发送消息(#25) · SendMessageRequest/SendMessageResponse DTO · SendMessageUseCase:乐观写入 DB → HTTP POST → 更新 Chat 摘要 - 接收消息 WS(#26) · WsMessageService:监听 mode2 WS 帧 → HTTP 补拉 → DB 写入 → Chat 更新 · FetchHistoryRequest/FetchHistoryResponse DTO(GET /app/api/chat/history) · FetchHistoryUseCase:拉取 → insertOrReplaceAll - DI 装配(chat_service_providers.dart) · wsMessageServiceProvider、sendMessageUseCaseProvider、fetchHistoryUseCaseProvider - 聊天列表页(#27) · ChatListViewModel(Notifier<void>)+ chat_page.dart 真实会话列表 UI · ListTile:头像首字母、最新消息摘要、未读角标、时间格式化 - 聊天详情页(#28) · ChatDetailViewModel(FamilyNotifier<ChatDetailState,int>)+ chat_detail_page.dart · 消息气泡(自己/他人分左右)、底部输入框、发送状态与错误提示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.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/message_provider.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart';
|
||||
|
||||
/// 会话详情页(路由传参 Demo)
|
||||
/// 聊天详情页
|
||||
///
|
||||
/// 通过 go_router 的 `extra` 接收上一页传入的数据,
|
||||
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入,
|
||||
/// 本页不感知 GoRouter 任何实现细节。
|
||||
///
|
||||
/// ## 正式开发
|
||||
///
|
||||
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
|
||||
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
||||
class ChatDetailPage extends ConsumerWidget {
|
||||
/// 接收 [conversationId](chatId 字符串)和 [title](会话名称)。
|
||||
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。
|
||||
/// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。
|
||||
class ChatDetailPage extends ConsumerStatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
@@ -24,18 +22,236 @@ class ChatDetailPage extends ConsumerWidget {
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = context.styles;
|
||||
ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
late final int _chatId;
|
||||
final _inputCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatId = int.tryParse(widget.conversationId) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _inputCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_inputCtrl.clear();
|
||||
ref
|
||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||
.sendMessage(text);
|
||||
// Scroll to bottom after a brief delay so the new message is rendered
|
||||
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollCtrl.hasClients) {
|
||||
_scrollCtrl.animateTo(
|
||||
_scrollCtrl.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = ref.watch(chatDetailViewModelProvider(_chatId).notifier);
|
||||
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
||||
final messagesAsync = ref.watch(messagesByChatIdProvider(_chatId));
|
||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: messagesAsync.when(
|
||||
data: (msgs) {
|
||||
if (msgs.isEmpty) {
|
||||
return const Center(child: Text('暂无消息'));
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToBottom(),
|
||||
);
|
||||
return ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (context, i) => _MessageBubble(
|
||||
message: msgs[i],
|
||||
isMine: msgs[i].sendId == currentUid,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('加载失败: $e')),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 错误提示 ────────────────────────────────────────────────────────
|
||||
if (state.error != null)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 输入框 ──────────────────────────────────────────────────────────
|
||||
_InputBar(
|
||||
controller: _inputCtrl,
|
||||
isSending: state.isSending,
|
||||
onSend: _send,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 消息气泡 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.isMine,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool isMine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final content = message.content ?? '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMine) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: cs.secondaryContainer,
|
||||
child: Text(
|
||||
(message.sendId ?? 0).toString().substring(0, 1),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isMine ? cs.primaryContainer : cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMine ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMine ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: isMine ? cs.onPrimaryContainer : cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isMine) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 输入栏 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _InputBar extends StatelessWidget {
|
||||
const _InputBar({
|
||||
required this.controller,
|
||||
required this.isSending,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool isSending;
|
||||
final VoidCallback onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('会话 ID', style: s.labelMuted),
|
||||
Text(conversationId, style: s.headlineSmall),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => onSend(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入消息…',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
onPressed: isSending ? null : onSend,
|
||||
icon: isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,81 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_view_model.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/presentation/chat_list_view_model.dart';
|
||||
|
||||
/// 聊天页(Demo 按钮)
|
||||
/// 聊天列表页
|
||||
///
|
||||
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
|
||||
/// - 「切换 Tab」 — go,替换历史,不可返回
|
||||
/// - 「有参 push(extra)」 — push + extra(Dart Record),可返回
|
||||
/// - 「有参 push(路径参数)」— push + URL 内嵌 id,可返回
|
||||
/// - 「无参 push」 — push,可返回
|
||||
/// - 「退出登录」 — 守卫自动重定向到 /login
|
||||
///
|
||||
/// 所有操作通过 [ChatViewModel] 处理,View 不直接调用路由。
|
||||
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
|
||||
/// 从本地 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);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('聊天')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
// 切换 Tab:用 go,替换整个历史栈,不可返回
|
||||
AppButton.inverse(
|
||||
label: '切换 Tab(go)',
|
||||
onPressed: () =>
|
||||
ref.read(chatViewModelProvider.notifier).goToContact(context),
|
||||
),
|
||||
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(extra)',
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushChatDetailWithExtra(context),
|
||||
),
|
||||
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(路径参数)',
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushChatDetailById(context),
|
||||
),
|
||||
// 无参 push:压栈,自动显示返回按钮,不切 Tab
|
||||
AppButton.inverse(
|
||||
label: '无参 push',
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.pushSettingsTheme(context),
|
||||
),
|
||||
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
|
||||
AppButton.inverse(
|
||||
label: '无参 go',
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.goToSettings(context),
|
||||
),
|
||||
AppButton.inverse(
|
||||
label: '测试数据库性能',
|
||||
onPressed: () => ref
|
||||
.read(chatViewModelProvider.notifier)
|
||||
.goToDatabaseTest(context),
|
||||
),
|
||||
AppButton.secondary(
|
||||
label: '退出登录',
|
||||
fullWidth: false,
|
||||
onPressed: () =>
|
||||
ref.read(chatViewModelProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 StatelessWidget {
|
||||
const _ChatTile({required this.chat, required this.vm});
|
||||
|
||||
final Chat chat;
|
||||
final ChatListViewModel vm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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) : '';
|
||||
|
||||
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: 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}';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user