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,11 +1,9 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/user_provider.dart';
import 'package:im_app/domain/entities/user.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_db_test_view_model.g.dart';
class ChatDbTestState {
final bool testStarted;
@@ -41,8 +39,7 @@ class ChatDbTestState {
);
}
@riverpod
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
class ChatDbTestViewModel extends Notifier<ChatDbTestState> {
final _random = Random();
bool _isTesting = false;
static const _pageSize = 50;
@@ -81,7 +78,6 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
);
}
/// Called by ListView when reaching the end
void loadMore() {
if (!state.hasMore || state.testStarted) return;
_loadNextPage();
@@ -211,3 +207,8 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
}
}
}
final chatDbTestViewModelProvider =
NotifierProvider<ChatDbTestViewModel, ChatDbTestState>(
ChatDbTestViewModel.new,
);

View File

@@ -0,0 +1,68 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/di/chat_service_providers.dart';
/// 聊天详情页 ViewModel — 每个 chatId 独立实例
///
/// 状态包含:
/// - [isSending] — 发送按钮 loading 状态
/// - [error] — 最近一次发送失败的错误信息
///
/// 消息列表由 View 层直接 watch [messagesByChatIdProvider],不经过 ViewModel。
///
/// 首次进入时自动通过 [FetchHistoryUseCase] 拉取历史消息。
class ChatDetailState {
final bool isSending;
final String? error;
const ChatDetailState({this.isSending = false, this.error});
ChatDetailState copyWith({bool? isSending, String? error}) =>
ChatDetailState(
isSending: isSending ?? this.isSending,
error: error,
);
}
class ChatDetailViewModel
extends FamilyNotifier<ChatDetailState, int> {
@override
ChatDetailState build(int arg) {
// 进入聊天时自动拉取历史
Future.microtask(() => _fetchInitialHistory());
return const ChatDetailState();
}
// ── 操作 ───────────────────────────────────────────────────────────────────
Future<void> sendMessage(String content) async {
final trimmed = content.trim();
if (trimmed.isEmpty) return;
state = state.copyWith(isSending: true);
try {
await ref.read(sendMessageUseCaseProvider).execute(
chatId: arg,
content: trimmed,
);
state = const ChatDetailState();
} catch (e) {
state = state.copyWith(isSending: false, error: e.toString());
}
}
Future<void> _fetchInitialHistory() async {
try {
await ref
.read(fetchHistoryUseCaseProvider)
.execute(chatId: arg, chatIdx: 0, limit: 30);
} catch (_) {
// 静默失败UI 通过 DB Stream 反映现有数据
}
}
}
final chatDetailViewModelProvider =
NotifierProvider.family<ChatDetailViewModel, ChatDetailState, int>(
ChatDetailViewModel.new,
);

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:im_app/app/router/app_route_name.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_service_providers.dart';
/// 聊天列表页 ViewModel
///
/// 负责:
/// - 从 DB Stream 读取会话列表(通过 [allChatsProvider]
/// - 导航到会话详情页
/// - 初始化 WsMessageService通过 [wsMessageServiceProvider]
class ChatListViewModel extends Notifier<void> {
@override
void build() {
// 确保 WS 消息服务已启动
ref.read(wsMessageServiceProvider);
}
void openChat(BuildContext context, Chat chat) {
final chatId = chat.chatId ?? chat.id;
final title = chat.name ?? 'Chat $chatId';
context.push(
AppRouteName.chatDetail.path,
extra: (conversationId: chatId.toString(), title: title),
);
}
void goToDatabaseTest(BuildContext context) {
context.push(AppRouteName.chatDBTest.path);
}
void logout() {
ref.read(authNotifierProvider).logout();
}
}
final chatListViewModelProvider =
NotifierProvider<ChatListViewModel, void>(ChatListViewModel.new);

View File

@@ -1,38 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:im_app/app/di/app_providers.dart';
import 'package:im_app/app/router/app_route_name.dart';
part 'chat_view_model.g.dart';
/// 聊天页 ViewModel@riverpod 自动生成 `chatViewModelProvider`
///
/// 当前 chat 页面为 Demo无需从服务端加载数据状态为 void。
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
///
/// ## 数据流
///
/// ```
/// ChatPage
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
/// → ★ ChatViewModel ★ ← 你在这里
/// → 导航 / 业务逻辑
/// ```
@riverpod
class ChatViewModel extends _$ChatViewModel {
/// 聊天页 ViewModel(手动 NotifierProvider
class ChatViewModel extends Notifier<void> {
@override
void build() {}
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
/// 切换到联系人 Tab。
void goToContact(BuildContext context) {
context.go(AppRouteName.contact.path);
}
/// 带 extra 参数 push 聊天详情页extra 传 Dart Record
void pushChatDetailWithExtra(BuildContext context) {
context.push(
AppRouteName.chatDetail.path,
@@ -40,33 +21,26 @@ class ChatViewModel extends _$ChatViewModel {
);
}
/// 带路径参数 push 聊天详情页id 内嵌在 URL 中)。
void pushChatDetailById(BuildContext context) {
context.push(AppRouteName.chatDetailByIdPath('99'));
}
/// 无参 push演示 push 导航)。
void pushSettingsTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path);
}
/// 切换到设置 Tab。
void goToSettings(BuildContext context) {
context.go(AppRouteName.settings.path);
}
/// 测试数据库性能
void goToDatabaseTest(BuildContext context) {
context.push(AppRouteName.chatDBTest.path);
}
// ── 业务 ─────────────────────────────────────────────────────────────────
/// 退出登录
///
/// 调用 [AuthNotifier.logout] 清除登录状态go_router 守卫检测到后
/// 自动重定向到登录页,无需手动导航。
void logout() {
ref.read(authNotifierProvider).logout();
}
}
final chatViewModelProvider =
NotifierProvider<ChatViewModel, void>(ChatViewModel.new);