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

@@ -2,11 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/favorite_detail_repository_impl.dart';
import 'package:im_app/domain/repositories/favorite_detail_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:im_app/domain/entities/favorite_detail.dart';
part 'favorite_detail_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
/// 收藏详情仓储 Provider
@@ -19,18 +16,13 @@ final favoriteDetailRepositoryProvider = Provider<FavoriteDetailRepository>((
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有收藏详情
@riverpod
Stream<List<FavoriteDetail>> allFavoriteDetails(Ref ref) {
final allFavoriteDetailsProvider = StreamProvider<List<FavoriteDetail>>((ref) {
return ref.watch(favoriteDetailRepositoryProvider).watchAll();
}
});
/// 监听指定 relatedId 的收藏详情
@riverpod
Stream<List<FavoriteDetail>> favoriteDetailsByRelatedId(
Ref ref,
String relatedId,
) {
final favoriteDetailsByRelatedIdProvider = StreamProvider.family<List<FavoriteDetail>, String>((ref, relatedId) {
return ref
.watch(favoriteDetailRepositoryProvider)
.watchByRelatedId(relatedId);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/favorite_repository_impl.dart';
import 'package:im_app/domain/entities/favorite.dart';
import 'package:im_app/domain/repositories/favorite_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'favorite_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,19 +14,16 @@ final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有收藏
@riverpod
Stream<List<Favorite>> allFavorites(Ref ref) {
final allFavoritesProvider = StreamProvider<List<Favorite>>((ref) {
return ref.watch(favoriteRepositoryProvider).watchAll();
}
});
/// 监听指定收藏
@riverpod
Stream<Favorite?> favoriteById(Ref ref, int id) {
final favoriteByIdProvider = StreamProvider.family<Favorite?, int>((ref, id) {
return ref.watch(favoriteRepositoryProvider).watchById(id);
}
});
/// 监听指定 parentId 的收藏
@riverpod
Stream<List<Favorite>> favoritesByParentId(Ref ref, String parentId) {
final favoritesByParentIdProvider = StreamProvider.family<List<Favorite>, String>((ref, parentId) {
return ref.watch(favoriteRepositoryProvider).watchByParentId(parentId);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/call_log_repository_impl.dart';
import 'package:im_app/domain/entities/call_log.dart';
import 'package:im_app/domain/repositories/call_log_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'call_log_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final callLogRepositoryProvider = Provider<CallLogRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有通话记录
@riverpod
Stream<List<CallLog>> allCallLogs(Ref ref) {
final allCallLogsProvider = StreamProvider<List<CallLog>>((ref) {
return ref.watch(callLogRepositoryProvider).watchAllCallLogs();
}
});
/// 监听指定通话记录
@riverpod
Stream<CallLog?> callLog(Ref ref, String id) {
final callLogProvider = StreamProvider.family<CallLog?, String>((ref, id) {
return ref.watch(callLogRepositoryProvider).watchCallLog(id);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/chat_bot_repository_impl.dart';
import 'package:im_app/domain/entities/chat_bot.dart';
import 'package:im_app/domain/repositories/chat_bot_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_bot_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatBotRepositoryProvider = Provider<ChatBotRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天机器人
@riverpod
Stream<List<ChatBot>> allChatBots(Ref ref) {
final allChatBotsProvider = StreamProvider<List<ChatBot>>((ref) {
return ref.watch(chatBotRepositoryProvider).watchAllChatBots();
}
});
/// 监听指定聊天机器人
@riverpod
Stream<ChatBot?> chatBot(Ref ref, int id) {
final chatBotProvider = StreamProvider.family<ChatBot?, int>((ref, id) {
return ref.watch(chatBotRepositoryProvider).watchChatBot(id);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/chat_category_repository_impl.dart';
import 'package:im_app/domain/entities/chat_category.dart';
import 'package:im_app/domain/repositories/chat_category_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_category_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatCategoryRepositoryProvider = Provider<ChatCategoryRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天分类
@riverpod
Stream<List<ChatCategory>> allChatCategories(Ref ref) {
final allChatCategoriesProvider = StreamProvider<List<ChatCategory>>((ref) {
return ref.watch(chatCategoryRepositoryProvider).watchAllChatCategories();
}
});
/// 监听指定聊天分类
@riverpod
Stream<ChatCategory?> chatCategory(Ref ref, int id) {
final chatCategoryProvider = StreamProvider.family<ChatCategory?, int>((ref, id) {
return ref.watch(chatCategoryRepositoryProvider).watchChatCategory(id);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/chat_repository_impl.dart';
import 'package:im_app/domain/entities/chat.dart';
import 'package:im_app/domain/repositories/chat_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatRepositoryProvider = Provider<ChatRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天
@riverpod
Stream<List<Chat>> allChats(Ref ref) {
final allChatsProvider = StreamProvider<List<Chat>>((ref) {
return ref.watch(chatRepositoryProvider).watchAllChats();
}
});
/// 监听指定聊天
@riverpod
Stream<Chat?> chat(Ref ref, int id) {
final chatProvider = StreamProvider.family<Chat?, int>((ref, id) {
return ref.watch(chatRepositoryProvider).watchChat(id);
}
});

View File

@@ -0,0 +1,66 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/app_providers.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/core/services/ws_message_service.dart';
import 'package:im_app/features/chat/di/chat_provider.dart';
import 'package:im_app/features/chat/di/message_provider.dart';
import 'package:im_app/features/chat/usecases/fetch_history_use_case.dart';
import 'package:im_app/features/chat/usecases/send_message_use_case.dart';
/// ## DI 装配Chat 服务层
///
/// 负责装配:
/// - [WsMessageService] — WS 帧接收 + HTTP 补拉 + DB 写入
/// - [SendMessageUseCase] — 发送文本消息
/// - [FetchHistoryUseCase] — 主动拉取历史
///
/// WS 消息服务在 Provider 创建时自动调用 `start()`
/// Provider dispose 时调用 `stop()`,生命周期与 Riverpod 容器绑定。
// ── WsMessageService ──────────────────────────────────────────────────────────
/// WS 消息服务 Provider
///
/// 全局单例,随应用启动而创建,登出时随 ProviderContainer 一同销毁。
/// 创建后立即调用 [WsMessageService.start],订阅 SocketManager.messageStream。
final wsMessageServiceProvider = Provider<WsMessageService>((ref) {
final service = WsMessageService(
socketManager: ref.read(socketManagerProvider),
apiClient: ref.read(networkSdkApiProvider),
messageRepo: ref.read(messageRepositoryProvider),
chatRepo: ref.read(chatRepositoryProvider),
);
service.start();
ref.onDispose(service.stop);
return service;
});
// ── SendMessageUseCase ────────────────────────────────────────────────────────
/// 发送消息用例 Provider
///
/// [currentUid] 从 [authNotifierProvider] 获取,用于填写发送方 uid。
/// uid 为 null未登录时用例仍可创建但发送时 sendId = 0。
final sendMessageUseCaseProvider = Provider<SendMessageUseCase>((ref) {
final uid = ref.read(authNotifierProvider).currentUid ?? 0;
return SendMessageUseCase(
apiClient: ref.read(networkSdkApiProvider),
messageRepo: ref.read(messageRepositoryProvider),
chatRepo: ref.read(chatRepositoryProvider),
currentUid: uid,
);
});
// ── FetchHistoryUseCase ───────────────────────────────────────────────────────
/// 拉取历史消息用例 Provider
final fetchHistoryUseCaseProvider = Provider<FetchHistoryUseCase>((ref) {
return FetchHistoryUseCase(
apiClient: ref.read(networkSdkApiProvider),
messageRepo: ref.read(messageRepositoryProvider),
);
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/group_repository_impl.dart';
import 'package:im_app/domain/entities/group.dart';
import 'package:im_app/domain/repositories/group_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'group_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final groupRepositoryProvider = Provider<GroupRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有群组
@riverpod
Stream<List<Group>> allGroups(Ref ref) {
final allGroupsProvider = StreamProvider<List<Group>>((ref) {
return ref.watch(groupRepositoryProvider).watchAll();
}
});
/// 监听指定群组
@riverpod
Stream<Group?> groupById(Ref ref, int id) {
final groupByIdProvider = StreamProvider.family<Group?, int>((ref, id) {
return ref.watch(groupRepositoryProvider).watchById(id);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/message_repository_impl.dart';
import 'package:im_app/domain/entities/message.dart';
import 'package:im_app/domain/repositories/message_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'message_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final messageRepositoryProvider = Provider<MessageRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听指定 chatId 的消息列表
@riverpod
Stream<List<Message>> messagesByChatId(Ref ref, int chatId) {
final messagesByChatIdProvider = StreamProvider.family<List<Message>, int>((ref, chatId) {
return ref.watch(messageRepositoryProvider).watchByChatId(chatId);
}
});
/// 监听指定消息
@riverpod
Stream<Message?> messageById(Ref ref, int id) {
final messageByIdProvider = StreamProvider.family<Message?, int>((ref, id) {
return ref.watch(messageRepositoryProvider).watchById(id);
}
});

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);

View File

@@ -0,0 +1,42 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/fetch_history_request.dart';
import 'package:im_app/domain/repositories/message_repository.dart';
/// 拉取消息历史用例
///
/// 调用 HTTP GET `/app/api/chat/history`,将消息写入本地 DB。
/// DB Stream → StreamProvider → UI 自动重建。
///
/// ## 分页
///
/// [chatIdx] 为锚点消息 index
/// - 0 → 拉最新 [limit] 条
/// - N → 拉 idx < N 的 [limit] 条(向上翻页)
///
/// 返回拉取到的消息数0 表示已到头或网络失败。
class FetchHistoryUseCase {
final NetworksSdkApi _apiClient;
final MessageRepository _messageRepo;
FetchHistoryUseCase({
required NetworksSdkApi apiClient,
required MessageRepository messageRepo,
}) : _apiClient = apiClient,
_messageRepo = messageRepo;
Future<int> execute({
required int chatId,
int chatIdx = 0,
int limit = 20,
}) async {
final response = await _apiClient.executeRequest(
FetchHistoryRequest(chatId: chatId, chatIdx: chatIdx, limit: limit),
);
if (response == null || response.messages.isEmpty) return 0;
final entities = response.messages.map((m) => m.toEntity()).toList();
await _messageRepo.insertOrReplaceAll(entities);
return entities.length;
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/send_message_request.dart';
import 'package:im_app/domain/entities/message.dart';
import 'package:im_app/domain/repositories/chat_repository.dart';
import 'package:im_app/domain/repositories/message_repository.dart';
/// 发送文本消息用例
///
/// ## 执行流程
/// 1. 乐观写入本地 DB临时消息id=0→ UI 立即刷新
/// 2. HTTP POST `/app/api/chat/send-message` → 获取服务端 messageId / chatIdx
/// 3. 更新 ChatRepository 的 lastMsg / lastTyp / lastTime
///
/// DB Stream → StreamProvider → UI 自动重建,无需额外通知。
class SendMessageUseCase {
final NetworksSdkApi _apiClient;
final MessageRepository _messageRepo;
final ChatRepository _chatRepo;
final int currentUid;
SendMessageUseCase({
required NetworksSdkApi apiClient,
required MessageRepository messageRepo,
required ChatRepository chatRepo,
required this.currentUid,
}) : _apiClient = apiClient,
_messageRepo = messageRepo,
_chatRepo = chatRepo;
Future<void> execute({
required int chatId,
required String content,
int typ = 1,
}) async {
final sendTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 1. 乐观本地写入
await _messageRepo.insertOrReplace(
Message(
id: 0,
chatId: chatId,
sendId: currentUid,
content: content,
typ: typ,
sendTime: sendTime,
),
);
// 2. HTTP 发送
SendMessageResponse? resp;
try {
resp = await _apiClient.executeRequest(
SendMessageRequest(
chatId: chatId,
content: content,
typ: typ,
sendTime: sendTime,
),
);
} catch (e) {
debugPrint('[SendMessageUseCase] HTTP error: $e');
}
// 3. 更新 Chat 摘要
try {
final chat = await _chatRepo.getChat(chatId);
if (chat != null) {
await _chatRepo.updateChat(
chat.copyWith(
lastMsg: content,
lastTyp: typ,
lastTime: sendTime,
msgIdx: resp?.chatIdx ?? chat.msgIdx,
),
);
}
} catch (e) {
debugPrint('[SendMessageUseCase] updateChat error: $e');
}
}
}

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}';
}
}

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/pending_friend_request_history_repository_impl.dart';
import 'package:im_app/domain/entities/pending_friend_request_history.dart';
import 'package:im_app/domain/repositories/pending_friend_request_history_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'pending_friend_request_history_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -19,20 +16,13 @@ final pendingFriendRequestHistoryRepositoryProvider =
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有好友请求历史
@riverpod
Stream<List<PendingFriendRequestHistory>> allPendingFriendRequestHistories(
Ref ref,
) {
final allPendingFriendRequestHistoriesProvider = StreamProvider<List<PendingFriendRequestHistory>>((ref) {
return ref.watch(pendingFriendRequestHistoryRepositoryProvider).watchAll();
}
});
/// 监听指定 uid 的好友请求历史
@riverpod
Stream<List<PendingFriendRequestHistory>> pendingFriendRequestHistoriesByUid(
Ref ref,
int uid,
) {
final pendingFriendRequestHistoriesByUidProvider = StreamProvider.family<List<PendingFriendRequestHistory>, int>((ref, uid) {
return ref
.watch(pendingFriendRequestHistoryRepositoryProvider)
.watchByUid(uid);
}
});

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/user_request_history_repository_impl.dart';
import 'package:im_app/domain/entities/user_request_history.dart';
import 'package:im_app/domain/repositories/user_request_history_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_request_history_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -17,22 +14,16 @@ final userRequestHistoryRepositoryProvider =
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有用户请求历史
@riverpod
Stream<List<UserRequestHistory>> allUserRequestHistories(Ref ref) {
final allUserRequestHistoriesProvider = StreamProvider<List<UserRequestHistory>>((ref) {
return ref.watch(userRequestHistoryRepositoryProvider).watchAll();
}
});
/// 监听指定状态的用户请求历史
@riverpod
Stream<List<UserRequestHistory>> userRequestHistoriesByStatus(
Ref ref,
int status,
) {
final userRequestHistoriesByStatusProvider = StreamProvider.family<List<UserRequestHistory>, int>((ref, status) {
return ref.watch(userRequestHistoryRepositoryProvider).watchByStatus(status);
}
});
/// 监听指定用户请求历史
@riverpod
Stream<UserRequestHistory?> userRequestHistoryById(Ref ref, int id) {
final userRequestHistoryByIdProvider = StreamProvider.family<UserRequestHistory?, int>((ref, id) {
return ref.watch(userRequestHistoryRepositoryProvider).watchById(id);
}
});

View File

@@ -1,7 +1,3 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'login_state.freezed.dart';
/// 登录流程的当前步骤
enum LoginStep {
/// 步骤 1输入手机号
@@ -11,45 +7,50 @@ enum LoginStep {
otp,
}
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString
/// 登录页面状态(手动 copyWith
///
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
/// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。
///
/// ## 状态流转
///
/// ```
/// 初始
/// → LoginState() step: phone, isLoading: false
/// 点击"获取验证码"
/// → state.copyWith(isLoading: true)
/// → 成功: state.copyWith(step: otp, contact: phone, isLoading: false)
/// → 失败: state.copyWith(error: '...', isLoading: false)
/// 点击"登录"
/// → state.copyWith(isLoading: true)
/// → 成功: authNotifierProvider.login() → 路由守卫重定向
/// → 失败: state.copyWith(error: '...', isLoading: false)
/// ```
@freezed
sealed class LoginState with _$LoginState {
const LoginState._();
class LoginState {
const LoginState({
this.step = LoginStep.phone,
this.countryCode = '+65',
this.contact = '',
this.isLoading = false,
this.error,
});
const factory LoginState({
/// 当前步骤(手机号输入 or 验证码输入)
@Default(LoginStep.phone) LoginStep step,
/// 当前步骤(手机号输入 or 验证码输入)
final LoginStep step;
/// 国家代码(默认 +65暂不支持切换
@Default('+65') String countryCode,
/// 国家代码(默认 +65暂不支持切换
final String countryCode;
/// 已提交的手机号(步骤 2 用于显示和构建请求)
@Default('') String contact,
/// 已提交的手机号(步骤 2 用于显示和构建请求)
final String contact;
/// 是否正在请求中
@Default(false) bool isLoading,
/// 是否正在请求中
final bool isLoading;
/// 错误信息null = 无错误)
/// 错误信息null = 无错误)
final String? error;
LoginState copyWith({
LoginStep? step,
String? countryCode,
String? contact,
bool? isLoading,
String? error,
}) = _LoginState;
bool clearError = false,
}) {
return LoginState(
step: step ?? this.step,
countryCode: countryCode ?? this.countryCode,
contact: contact ?? this.contact,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
/// 步骤 2 显示的脱敏手机号,如 "138****0000"
String get maskedContact {

View File

@@ -1,41 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:im_app/app/di/app_providers.dart';
import 'package:im_app/features/login/di/auth_providers.dart';
import 'package:im_app/features/login/presentation/login_state.dart';
part 'login_view_model.g.dart';
/// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider`
/// 登录 ViewModel(手动 NotifierProvider
///
/// 管理两步登录流程:手机号 → 验证码 → 完成登录。
///
/// ```dart
/// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider);
///
/// // View 层调用方法
/// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
/// ```
///
/// ## DI 链路
///
/// ```
/// loginViewModelProvider ← @riverpod 自动生成
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// loginViewModelProvider
/// → ref.read(loginUseCaseProvider)
/// → ref.read(authRepositoryProvider)
/// → ref.read(networkSdkApiProvider)
/// ```
@riverpod
class LoginViewModel extends _$LoginViewModel {
class LoginViewModel extends Notifier<LoginState> {
@override
LoginState build() => const LoginState();
/// 步骤 1发送手机验证码
///
/// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
Future<void> sendOtp(String countryCode, String contact) async {
if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null);
@@ -65,8 +51,6 @@ class LoginViewModel extends _$LoginViewModel {
}
/// 步骤 2+3校验验证码并完成登录
///
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向provider 随即被 dispose。
Future<void> verifyAndLogin(String code) async {
if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null);
@@ -80,8 +64,6 @@ class LoginViewModel extends _$LoginViewModel {
code: code,
);
// 成功后触发路由守卫重定向。
// 注意login() 触发导航后 provider 随即被 dispose之后不能再写 state。
if (!ref.mounted) return;
ref.read(authNotifierProvider).login(uid: user.uid);
} on FormatException catch (e) {
@@ -96,8 +78,11 @@ class LoginViewModel extends _$LoginViewModel {
}
}
/// 返回手机号输入步骤(用户想修改手机号)
/// 返回手机号输入步骤
void backToPhone() {
state = state.copyWith(step: LoginStep.phone, error: null);
}
}
final loginViewModelProvider =
NotifierProvider<LoginViewModel, LoginState>(LoginViewModel.new);

View File

@@ -1,25 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/discover_mini_app_repository_impl.dart';
import 'package:im_app/data/repositories/explore_mini_app_repository_impl.dart';
import 'package:im_app/data/repositories/favorite_mini_app_repository_impl.dart';
import 'package:im_app/data/repositories/recent_mini_app_repository_impl.dart';
import 'package:im_app/domain/repositories/mini_app_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'mini_app_provider.g.dart';
final discoverMiniAppRepositoryProvider = Provider<MiniAppRepository>(
(ref) => DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
);
@riverpod
MiniAppRepository discoverMiniAppRepository(Ref ref) =>
DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
final exploreMiniAppRepositoryProvider = Provider<MiniAppRepository>(
(ref) => ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
);
@riverpod
MiniAppRepository exploreMiniAppRepository(Ref ref) =>
ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
final favoriteMiniAppRepositoryProvider = Provider<MiniAppRepository>(
(ref) => FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
);
@riverpod
MiniAppRepository favoriteMiniAppRepository(Ref ref) =>
FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
@riverpod
MiniAppRepository recentMiniAppRepository(Ref ref) =>
RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
final recentMiniAppRepositoryProvider = Provider<MiniAppRepository>(
(ref) => RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
);

View File

@@ -1,29 +1,11 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/app_providers.dart';
import 'package:im_app/features/settings/di/settings_providers.dart';
part 'theme_view_model.g.dart';
/// 主题 ViewModel
///
/// View 层只感知此 ViewModel不直接依赖 app 级 Provider。
///
/// ## 数据流
///
/// ```
/// ThemeView
/// → ref.watch(themeViewModelProvider) ← 当前 ThemeMode
/// → ref.read(themeViewModelProvider.notifier).setMode(mode)
/// → ★ ThemeViewModel.setMode() ★ ← 你在这里
/// → SetThemeUseCase.execute()
/// → 幂等校验(相同模式直接返回)
/// → onApply → ThemeModeNotifier.setMode() ← 更新内存状态
/// → TODO: 持久化storage_sdk
/// ```
@riverpod
class ThemeViewModel extends _$ThemeViewModel {
/// 主题 ViewModel(手动 NotifierProvider
class ThemeViewModel extends Notifier<ThemeMode> {
@override
ThemeMode build() => ref.watch(themeModeProvider);
@@ -35,3 +17,6 @@ class ThemeViewModel extends _$ThemeViewModel {
);
}
}
final themeViewModelProvider =
NotifierProvider<ThemeViewModel, ThemeMode>(ThemeViewModel.new);

View File

@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/repositories/workspace_repository_impl.dart';
import 'package:im_app/domain/entities/workspace.dart';
import 'package:im_app/domain/repositories/workspace_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'workspace_provider.g.dart';
// ── Repository ────────────────────────────────────────────────────────────────
@@ -16,13 +13,11 @@ final workspaceRepositoryProvider = Provider<WorkspaceRepository>((ref) {
// ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有工作空间
@riverpod
Stream<List<Workspace>> allWorkspaces(Ref ref) {
final allWorkspacesProvider = StreamProvider<List<Workspace>>((ref) {
return ref.watch(workspaceRepositoryProvider).watchAll();
}
});
/// 监听指定工作空间
@riverpod
Stream<Workspace?> workspaceById(Ref ref, int id) {
final workspaceByIdProvider = StreamProvider.family<Workspace?, int>((ref, id) {
return ref.watch(workspaceRepositoryProvider).watchById(id);
}
});