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:
pp-bot
2026-03-23 23:16:44 +09:00
parent d9539d391c
commit e715a0673b
37 changed files with 1226 additions and 405 deletions

View File

@@ -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),
),
],
),
),

View File

@@ -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替换历史不可返回
/// - 「有参 pushextra」 — push + extraDart 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: '切换 Tabgo',
onPressed: () =>
ref.read(chatViewModelProvider.notifier).goToContact(context),
),
// 带参数 pushextra 传 Dart Record适合已有对象的场景
AppButton.inverse(
label: '有参 pushextra',
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.pushChatDetailWithExtra(context),
),
// 带参数 pushid 内嵌在路径中,适合需要深链接 / 分享的场景
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替换历史切换到对应 TabTabBar 可见,不可返回
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}';
}
}