diff --git a/apps/im_app/lib/app/di/api_retry_provider.dart b/apps/im_app/lib/app/di/api_retry_provider.dart index 9fdc0cf..e4a976d 100644 --- a/apps/im_app/lib/app/di/api_retry_provider.dart +++ b/apps/im_app/lib/app/di/api_retry_provider.dart @@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/data/repositories/api_retry_repository_impl.dart'; import 'package:im_app/domain/entities/api_retry.dart'; import 'package:im_app/domain/repositories/api_retry_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'api_retry_provider.g.dart'; // ── Repository ──────────────────────────────────────────────────────────────── @@ -16,13 +13,11 @@ final apiRetryRepositoryProvider = Provider((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有重试任务 -@riverpod -Stream> allApiRetries(Ref ref) { +final allApiRetriesProvider = StreamProvider>((ref) { return ref.watch(apiRetryRepositoryProvider).watchAll(); -} +}); /// 监听未同步的重试任务 -@riverpod -Stream> pendingApiRetries(Ref ref) { +final pendingApiRetriesProvider = StreamProvider>((ref) { return ref.watch(apiRetryRepositoryProvider).watchPending(); -} +}); diff --git a/apps/im_app/lib/app/di/sound_provider.dart b/apps/im_app/lib/app/di/sound_provider.dart index 4647188..2a77acd 100644 --- a/apps/im_app/lib/app/di/sound_provider.dart +++ b/apps/im_app/lib/app/di/sound_provider.dart @@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/data/repositories/sound_repository_impl.dart'; import 'package:im_app/domain/entities/sound.dart'; import 'package:im_app/domain/repositories/sound_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'sound_provider.g.dart'; // ── Repository ──────────────────────────────────────────────────────────────── @@ -16,19 +13,16 @@ final soundRepositoryProvider = Provider((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有音效 -@riverpod -Stream> allSounds(Ref ref) { +final allSoundsProvider = StreamProvider>((ref) { return ref.watch(soundRepositoryProvider).watchAll(); -} +}); /// 监听指定类型音效 -@riverpod -Stream> soundsByType(Ref ref, int typ) { +final soundsByTypeProvider = StreamProvider.family, int>((ref, typ) { return ref.watch(soundRepositoryProvider).watchByType(typ); -} +}); /// 监听指定音效 -@riverpod -Stream soundById(Ref ref, int id) { +final soundByIdProvider = StreamProvider.family((ref, id) { return ref.watch(soundRepositoryProvider).watchById(id); -} +}); diff --git a/apps/im_app/lib/app/di/tag_provider.dart b/apps/im_app/lib/app/di/tag_provider.dart index f3bb04a..cc9e55e 100644 --- a/apps/im_app/lib/app/di/tag_provider.dart +++ b/apps/im_app/lib/app/di/tag_provider.dart @@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/data/repositories/tag_repository_impl.dart'; import 'package:im_app/domain/entities/tag.dart'; import 'package:im_app/domain/repositories/tag_repository.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'tag_provider.g.dart'; // ── Repository ──────────────────────────────────────────────────────────────── @@ -16,25 +13,21 @@ final tagRepositoryProvider = Provider((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有标签 -@riverpod -Stream> allTags(Ref ref) { +final allTagsProvider = StreamProvider>((ref) { return ref.watch(tagRepositoryProvider).watchAll(); -} +}); /// 监听指定 uid 的标签 -@riverpod -Stream> tagsByUid(Ref ref, int uid) { +final tagsByUidProvider = StreamProvider.family, int>((ref, uid) { return ref.watch(tagRepositoryProvider).watchByUid(uid); -} +}); /// 监听指定类型的标签 -@riverpod -Stream> tagsByType(Ref ref, int type) { +final tagsByTypeProvider = StreamProvider.family, int>((ref, type) { return ref.watch(tagRepositoryProvider).watchByType(type); -} +}); /// 监听指定标签 -@riverpod -Stream tagById(Ref ref, int id) { +final tagByIdProvider = StreamProvider.family((ref, id) { return ref.watch(tagRepositoryProvider).watchById(id); -} +}); diff --git a/apps/im_app/lib/app/di/user_provider.dart b/apps/im_app/lib/app/di/user_provider.dart index a50178e..cc24e6f 100644 --- a/apps/im_app/lib/app/di/user_provider.dart +++ b/apps/im_app/lib/app/di/user_provider.dart @@ -2,20 +2,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:im_app/features/chat/usecases/delete_users_use_case.dart'; import 'package:im_app/features/chat/usecases/insert_users_use_case.dart'; import 'package:im_app/features/chat/usecases/update_users_use_case.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/data/repositories/user_repository_impl.dart'; import 'package:im_app/domain/entities/user.dart'; import 'package:im_app/domain/repositories/user_repository.dart'; -part 'user_provider.g.dart'; - // ── Repository ──────────────────────────────────────────────────────────────── -@riverpod -UserRepository userRepository(Ref ref) { +final userRepositoryProvider = Provider((ref) { return UserRepositoryImpl(ref.watch(storageSdkProvider)); -} +}); // ── Use Cases ───────────────────────────────────────────────────────────────── @@ -42,12 +38,10 @@ final deleteUsersUseCaseProvider = Provider((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── -@riverpod -Stream> users(Ref ref, Set uids) { +final usersProvider = StreamProvider.family, Set>((ref, uids) { return ref.watch(userRepositoryProvider).watchUsers(uids.toList()); -} +}); -@riverpod -Stream> allUsers(Ref ref) { +final allUsersProvider = StreamProvider>((ref) { return ref.watch(userRepositoryProvider).watchAllUsers(); -} +}); diff --git a/apps/im_app/lib/app/notifiers/user_notifier.dart b/apps/im_app/lib/app/notifiers/user_notifier.dart index 103092e..144b45c 100644 --- a/apps/im_app/lib/app/notifiers/user_notifier.dart +++ b/apps/im_app/lib/app/notifiers/user_notifier.dart @@ -1,10 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:im_app/app/di/user_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:im_app/domain/entities/user.dart'; import 'package:im_app/domain/repositories/user_repository.dart'; -part 'user_notifier.g.dart'; - /// 单个用户状态管理 (family — 每个 uid 独立 notifier) /// /// ## 用法 @@ -26,22 +24,21 @@ part 'user_notifier.g.dart'; /// // 批量更新 /// ref.read(userNotifierProvider(123).notifier).updateUsers(updatedList); /// ``` -@riverpod -class UserNotifier extends _$UserNotifier { +class UserNotifier extends FamilyAsyncNotifier { User? _cached; UserRepository get _repo => ref.watch(userRepositoryProvider); @override - Future build(int uid) async { + Future build(int arg) async { ref.onDispose(() => _cached = null); - _repo.watchUser(uid).listen((user) { + _repo.watchUser(arg).listen((user) { _cached = user; state = AsyncData(user); }); - return _repo.getUser(uid); + return _repo.getUser(arg); } // ── 即时访问,无需 await ────────────────────────────────────────────────── @@ -56,23 +53,11 @@ class UserNotifier extends _$UserNotifier { } /// 更新单个用户所有字段,按 uid 匹配 - /// - /// 示例: - /// ```dart - /// await notifier.updateUser(user.copyWith(nickname: 'New Name')); - /// ``` Future updateUser(User user) async { await _repo.updateUser(user); } /// 批量更新用户,每条按 uid 匹配更新所有字段 - /// - /// 示例: - /// ```dart - /// await notifier.updateUsers( - /// users.map((u) => u.copyWith(nickname: 'new')).toList(), - /// ); - /// ``` Future updateUsers(List users) async { await _repo.updateUsersBatch(users); } @@ -80,8 +65,11 @@ class UserNotifier extends _$UserNotifier { // ── 删除 ───────────────────────────────────────────────────────────────── Future deleteUser() async { - await _repo.deleteUser(uid); + await _repo.deleteUser(arg); _cached = null; state = const AsyncData(null); } } + +final userNotifierProvider = + AsyncNotifierProvider.family(UserNotifier.new); diff --git a/apps/im_app/lib/core/services/ws_message_service.dart b/apps/im_app/lib/core/services/ws_message_service.dart new file mode 100644 index 0000000..85d5333 --- /dev/null +++ b/apps/im_app/lib/core/services/ws_message_service.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/core/services/socket_manager.dart'; +import 'package:im_app/data/remote/fetch_history_request.dart'; +import 'package:im_app/domain/repositories/chat_repository.dart'; +import 'package:im_app/domain/repositories/message_repository.dart'; + +/// WS 实时消息接收服务 +/// +/// 监听 [SocketManager.messageStream],解析 mode2 协议帧: +/// ```json +/// { +/// "chat": { "r": [{ "id": , "msg_idx": N, "typ": N, "last_msg": "..." }] }, +/// "message_realtime_pb": +/// } +/// ``` +/// +/// 每帧含 `chat.r` 时,通过 HTTP GET `/app/api/chat/history` 拉取完整消息内容, +/// 写入 [MessageRepository],再更新 [ChatRepository] 的 lastMsg。 +/// UI 层通过 `StreamProvider` 监听 DB 变化自动更新,无需额外信令。 +/// +/// ## 生命周期 +/// +/// 由 [WsMessageServiceProvider] 管理,登录后随 SocketManager 一同启动, +/// 登出时 Provider dispose 触发 [stop]。 +class WsMessageService { + final SocketManager _socketManager; + final NetworksSdkApi _apiClient; + final MessageRepository _messageRepo; + final ChatRepository _chatRepo; + + StreamSubscription>? _sub; + + WsMessageService({ + required SocketManager socketManager, + required NetworksSdkApi apiClient, + required MessageRepository messageRepo, + required ChatRepository chatRepo, + }) : _socketManager = socketManager, + _apiClient = apiClient, + _messageRepo = messageRepo, + _chatRepo = chatRepo; + + // ── 生命周期 ────────────────────────────────────────────────────────────── + + void start() { + _sub?.cancel(); + _sub = _socketManager.messageStream.listen( + _handleFrame, + onError: (e) => debugPrint('[WsMessageService] stream error: $e'), + ); + debugPrint('[WsMessageService] started'); + } + + void stop() { + _sub?.cancel(); + _sub = null; + debugPrint('[WsMessageService] stopped'); + } + + // ── 帧处理 ──────────────────────────────────────────────────────────────── + + Future _handleFrame(Map frame) async { + try { + final chatPayload = frame['chat'] as Map?; + if (chatPayload == null) return; + + final rList = chatPayload['r'] as List?; + if (rList == null || rList.isEmpty) return; + + for (final item in rList) { + final entry = item as Map?; + if (entry == null) continue; + + final chatId = (entry['id'] as num?)?.toInt(); + final msgIdx = (entry['msg_idx'] as num?)?.toInt(); + if (chatId == null || msgIdx == null) continue; + + await _fetchAndSaveMessages(chatId: chatId, anchorIdx: msgIdx); + await _updateChatMeta(chatId: chatId, entry: entry); + } + } catch (e, st) { + debugPrint('[WsMessageService] _handleFrame error: $e\n$st'); + } + } + + /// 通过 HTTP 拉取消息并写入 DB + Future _fetchAndSaveMessages({ + required int chatId, + required int anchorIdx, + }) async { + try { + final response = await _apiClient.executeRequest( + FetchHistoryRequest(chatId: chatId, chatIdx: anchorIdx, limit: 20), + ); + if (response == null || response.messages.isEmpty) return; + + final entities = response.messages.map((m) => m.toEntity()).toList(); + await _messageRepo.insertOrReplaceAll(entities); + debugPrint( + '[WsMessageService] saved ${entities.length} messages for chat $chatId', + ); + } catch (e) { + debugPrint('[WsMessageService] fetch error for chat $chatId: $e'); + } + } + + /// 更新聊天列表中对应 Chat 的 lastMsg / lastTyp / msgIdx + Future _updateChatMeta({ + required int chatId, + required Map entry, + }) async { + try { + final existing = await _chatRepo.getChat(chatId); + if (existing == null) return; + + final lastMsg = entry['last_msg'] as String?; + final lastTyp = (entry['typ'] as num?)?.toInt(); + final msgIdx = (entry['msg_idx'] as num?)?.toInt(); + + await _chatRepo.updateChat( + existing.copyWith( + lastMsg: lastMsg ?? existing.lastMsg, + lastTyp: lastTyp ?? existing.lastTyp, + msgIdx: msgIdx ?? existing.msgIdx, + ), + ); + } catch (e) { + debugPrint('[WsMessageService] updateChatMeta error: $e'); + } + } +} diff --git a/apps/im_app/lib/data/remote/fetch_history_request.dart b/apps/im_app/lib/data/remote/fetch_history_request.dart new file mode 100644 index 0000000..640789d --- /dev/null +++ b/apps/im_app/lib/data/remote/fetch_history_request.dart @@ -0,0 +1,125 @@ +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/core/foundation/api_paths.dart'; +import 'package:im_app/domain/entities/message.dart'; + +// ── 消息历史响应 ─────────────────────────────────────────────────────────────── + +/// 单条消息 DTO(历史接口返回列表中的每一项) +class MessageItem { + final int messageId; + final int chatId; + final int chatIdx; + final int sendId; + final String? content; + final int typ; + final int sendTime; + final int? expireTime; + final String? atUsers; + + const MessageItem({ + required this.messageId, + required this.chatId, + required this.chatIdx, + required this.sendId, + this.content, + required this.typ, + required this.sendTime, + this.expireTime, + this.atUsers, + }); + + factory MessageItem.fromJson(Map json) => MessageItem( + messageId: (json['message_id'] ?? json['messageId'] ?? json['id'] ?? 0) + as int, + chatId: (json['chat_id'] ?? json['chatId'] ?? 0) as int, + chatIdx: (json['chat_idx'] ?? json['chatIdx'] ?? 0) as int, + sendId: (json['send_id'] ?? json['sendId'] ?? 0) as int, + content: json['content'] as String?, + typ: (json['typ'] ?? 1) as int, + sendTime: (json['send_time'] ?? json['sendTime'] ?? 0) as int, + expireTime: json['expire_time'] as int?, + atUsers: json['at_users'] as String?, + ); + + Message toEntity() => Message( + id: 0, + messageId: messageId, + chatId: chatId, + chatIdx: chatIdx, + sendId: sendId, + content: content, + typ: typ, + sendTime: sendTime, + expireTime: expireTime, + atUsers: atUsers, + ); +} + +/// 历史消息接口响应 +class FetchHistoryResponse { + final List messages; + + const FetchHistoryResponse({required this.messages}); + + factory FetchHistoryResponse.fromJson(Map json) { + final raw = + json['list'] as List? ?? + json['messages'] as List? ?? + const []; + return FetchHistoryResponse( + messages: raw + .whereType>() + .map(MessageItem.fromJson) + .toList(), + ); + } +} + +// ── 获取历史消息请求 ─────────────────────────────────────────────────────────── + +/// GET /app/api/chat/history — 按锚点分页拉取消息 +/// +/// [chatIdx] 锚点消息 index(从 0 开始拉最新)。 +/// [limit] 每次拉取条数,默认 20。 +class FetchHistoryRequest extends ApiRequestable { + final int chatId; + final int chatIdx; + final int limit; + + const FetchHistoryRequest({ + required this.chatId, + this.chatIdx = 0, + this.limit = 20, + }); + + @override + String get path => ApiPaths.chatHistory; + + @override + HttpMethod get method => HttpMethod.get; + + @override + Map get parameters => { + 'chat_id': chatId.toString(), + 'chat_idx': chatIdx.toString(), + 'limit': limit.toString(), + }; + + @override + FetchHistoryResponse? decodeResponse(dynamic response) { + final data = (response as dynamic).data; + if (data is Map) { + return FetchHistoryResponse.fromJson(data); + } + if (data is List) { + return FetchHistoryResponse( + messages: data + .whereType>() + .map(MessageItem.fromJson) + .toList(), + ); + } + return const FetchHistoryResponse(messages: []); + } +} diff --git a/apps/im_app/lib/data/remote/get_profile_request.dart b/apps/im_app/lib/data/remote/get_profile_request.dart index 3dd3c10..3356fa8 100644 --- a/apps/im_app/lib/data/remote/get_profile_request.dart +++ b/apps/im_app/lib/data/remote/get_profile_request.dart @@ -76,6 +76,26 @@ class ProfileResponse { required this.hint, }); + factory ProfileResponse.fromJson(Map json) => ProfileResponse( + uid: (json['uid'] as num?)?.toInt() ?? 0, + uuid: json['uuid'] as String? ?? '', + lastOnline: (json['last_online'] as num?)?.toInt() ?? 0, + profilePic: json['profile_pic'] as String? ?? '', + profilePicGaussian: json['profile_pic_gaussian'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + contact: json['contact'] as String? ?? '', + countryCode: json['country_code'] as String? ?? '', + email: json['email'] as String? ?? '', + recoveryEmail: json['recovery_email'] as String? ?? '', + username: json['username'] as String? ?? '', + bio: json['bio'] as String? ?? '', + relationship: (json['relationship'] as num?)?.toInt() ?? 0, + userAlias: json['user_alias'] as String?, + channelId: (json['channel_id'] as num?)?.toInt() ?? 0, + channelGroupId: (json['channel_group_id'] as num?)?.toInt() ?? 0, + hint: json['hint'] as String? ?? '', + ); + User toEntity() => User( uid: uid, uuid: uuid, diff --git a/apps/im_app/lib/data/remote/login_request.dart b/apps/im_app/lib/data/remote/login_request.dart index 37e02d2..e5a1c85 100644 --- a/apps/im_app/lib/data/remote/login_request.dart +++ b/apps/im_app/lib/data/remote/login_request.dart @@ -76,6 +76,26 @@ class LoginProfile { required this.hint, }); + factory LoginProfile.fromJson(Map json) => LoginProfile( + uid: (json['uid'] as num?)?.toInt() ?? 0, + uuid: json['uuid'] as String? ?? '', + lastOnline: (json['last_online'] as num?)?.toInt() ?? 0, + profilePic: json['profile_pic'] as String? ?? '', + profilePicGaussian: json['profile_pic_gaussian'] as String? ?? '', + nickname: json['nickname'] as String? ?? '', + contact: json['contact'] as String? ?? '', + countryCode: json['country_code'] as String? ?? '', + email: json['email'] as String? ?? '', + recoveryEmail: json['recovery_email'] as String? ?? '', + username: json['username'] as String? ?? '', + bio: json['bio'] as String? ?? '', + relationship: (json['relationship'] as num?)?.toInt() ?? 0, + userAlias: json['user_alias'] as String?, + channelId: (json['channel_id'] as num?)?.toInt() ?? 0, + channelGroupId: (json['channel_group_id'] as num?)?.toInt() ?? 0, + hint: json['hint'] as String? ?? '', + ); + User toEntity() => User( uid: uid, uuid: uuid, @@ -126,6 +146,17 @@ class LoginResponse { this.isVerified, }); + factory LoginResponse.fromJson(Map json) => LoginResponse( + accountId: json['account_id'] as String? ?? '', + profile: LoginProfile.fromJson(json['profile'] as Map), + accessToken: json['access_token'] as String? ?? '', + refreshToken: json['refresh_token'] as String? ?? '', + deviceId: json['device_id'] as String? ?? '', + nonce: json['nonce'] as String? ?? '', + loginData: json['login_data'] as String? ?? '', + isVerified: json['is_verified'] as bool?, + ); + User toEntity() => profile.toEntity(); } diff --git a/apps/im_app/lib/data/remote/send_message_request.dart b/apps/im_app/lib/data/remote/send_message_request.dart new file mode 100644 index 0000000..46de798 --- /dev/null +++ b/apps/im_app/lib/data/remote/send_message_request.dart @@ -0,0 +1,88 @@ +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/core/foundation/api_paths.dart'; +import 'package:im_app/domain/entities/message.dart'; + +// ── 发送消息响应 ─────────────────────────────────────────────────────────────── + +/// 发送消息接口响应 +class SendMessageResponse { + final int messageId; + final int chatIdx; + final int sendTime; + + const SendMessageResponse({ + required this.messageId, + required this.chatIdx, + required this.sendTime, + }); + + factory SendMessageResponse.fromJson(Map json) => + SendMessageResponse( + messageId: + (json['message_id'] ?? json['messageId'] ?? json['id'] ?? 0) + as int, + chatIdx: + (json['chat_idx'] ?? json['chatIdx'] ?? 0) as int, + sendTime: + (json['send_time'] ?? json['sendTime'] ?? 0) as int, + ); + + Message toEntity({ + required int chatId, + required int sendId, + required String content, + required int typ, + }) => + Message( + id: 0, + messageId: messageId, + chatId: chatId, + chatIdx: chatIdx, + sendId: sendId, + content: content, + typ: typ, + sendTime: sendTime, + ); +} + +// ── 发送消息请求 ─────────────────────────────────────────────────────────────── + +/// POST /app/api/chat/send-message — 发送文本消息 +/// +/// [typ] 消息类型:1 = 文本,详见服务端 MessageType 枚举。 +/// [sendTime] Unix 时间戳(秒),由客户端生成,用于服务端去重。 +class SendMessageRequest extends ApiRequestable { + final int chatId; + final String content; + final int typ; + final int sendTime; + + const SendMessageRequest({ + required this.chatId, + required this.content, + required this.typ, + required this.sendTime, + }); + + @override + String get path => ApiPaths.chatSendMessage; + + @override + HttpMethod get method => HttpMethod.post; + + @override + Map get parameters => { + 'chat_id': chatId, + 'content': content, + 'typ': typ, + 'send_time': sendTime, + }; + + @override + SendMessageResponse? decodeResponse(dynamic response) { + final data = (response as dynamic).data; + if (data is! Map) return null; + return SendMessageResponse.fromJson(data); + } +} diff --git a/apps/im_app/lib/data/remote/send_otp_request.dart b/apps/im_app/lib/data/remote/send_otp_request.dart index 563a129..68f428a 100644 --- a/apps/im_app/lib/data/remote/send_otp_request.dart +++ b/apps/im_app/lib/data/remote/send_otp_request.dart @@ -33,6 +33,14 @@ class SendOtpResponse { this.web, this.extras, }); + + factory SendOtpResponse.fromJson(Map json) => SendOtpResponse( + expiryTime: (json['expiry_time'] as num?)?.toInt(), + android: json['android'] as String?, + ios: json['ios'] as String?, + web: json['web'] as String?, + extras: json['extras'] as Map?, + ); } /// # /app/api/auth/vcode/get — 发送手机验证码 diff --git a/apps/im_app/lib/data/remote/upload_file_request.dart b/apps/im_app/lib/data/remote/upload_file_request.dart index f2d0849..ee3330a 100644 --- a/apps/im_app/lib/data/remote/upload_file_request.dart +++ b/apps/im_app/lib/data/remote/upload_file_request.dart @@ -40,6 +40,11 @@ class UploadResult { final String fileId; const UploadResult({required this.url, required this.fileId}); + + factory UploadResult.fromJson(Map json) => UploadResult( + url: json['url'] as String? ?? '', + fileId: json['file_id'] as String? ?? '', + ); } // ═════════════════════════════════════════════ diff --git a/apps/im_app/lib/data/remote/verify_otp_request.dart b/apps/im_app/lib/data/remote/verify_otp_request.dart index 7415ea9..2c4d6bc 100644 --- a/apps/im_app/lib/data/remote/verify_otp_request.dart +++ b/apps/im_app/lib/data/remote/verify_otp_request.dart @@ -14,6 +14,9 @@ class VerifyOtpResponse { final String token; const VerifyOtpResponse({required this.token}); + + factory VerifyOtpResponse.fromJson(Map json) => + VerifyOtpResponse(token: json['token'] as String? ?? ''); } /// # /app/api/auth/vcode/check — 校验手机验证码 diff --git a/apps/im_app/lib/features/app_tab/di/favorite_detail_provider.dart b/apps/im_app/lib/features/app_tab/di/favorite_detail_provider.dart index 81c0f63..9dba357 100644 --- a/apps/im_app/lib/features/app_tab/di/favorite_detail_provider.dart +++ b/apps/im_app/lib/features/app_tab/di/favorite_detail_provider.dart @@ -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(( // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有收藏详情 -@riverpod -Stream> allFavoriteDetails(Ref ref) { +final allFavoriteDetailsProvider = StreamProvider>((ref) { return ref.watch(favoriteDetailRepositoryProvider).watchAll(); -} +}); /// 监听指定 relatedId 的收藏详情 -@riverpod -Stream> favoriteDetailsByRelatedId( - Ref ref, - String relatedId, -) { +final favoriteDetailsByRelatedIdProvider = StreamProvider.family, String>((ref, relatedId) { return ref .watch(favoriteDetailRepositoryProvider) .watchByRelatedId(relatedId); -} +}); diff --git a/apps/im_app/lib/features/app_tab/di/favorite_provider.dart b/apps/im_app/lib/features/app_tab/di/favorite_provider.dart index d949272..11bf6cc 100644 --- a/apps/im_app/lib/features/app_tab/di/favorite_provider.dart +++ b/apps/im_app/lib/features/app_tab/di/favorite_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有收藏 -@riverpod -Stream> allFavorites(Ref ref) { +final allFavoritesProvider = StreamProvider>((ref) { return ref.watch(favoriteRepositoryProvider).watchAll(); -} +}); /// 监听指定收藏 -@riverpod -Stream favoriteById(Ref ref, int id) { +final favoriteByIdProvider = StreamProvider.family((ref, id) { return ref.watch(favoriteRepositoryProvider).watchById(id); -} +}); /// 监听指定 parentId 的收藏 -@riverpod -Stream> favoritesByParentId(Ref ref, String parentId) { +final favoritesByParentIdProvider = StreamProvider.family, String>((ref, parentId) { return ref.watch(favoriteRepositoryProvider).watchByParentId(parentId); -} +}); diff --git a/apps/im_app/lib/features/chat/call/di/call_log_provider.dart b/apps/im_app/lib/features/chat/call/di/call_log_provider.dart index 10a94bc..11ce618 100644 --- a/apps/im_app/lib/features/chat/call/di/call_log_provider.dart +++ b/apps/im_app/lib/features/chat/call/di/call_log_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有通话记录 -@riverpod -Stream> allCallLogs(Ref ref) { +final allCallLogsProvider = StreamProvider>((ref) { return ref.watch(callLogRepositoryProvider).watchAllCallLogs(); -} +}); /// 监听指定通话记录 -@riverpod -Stream callLog(Ref ref, String id) { +final callLogProvider = StreamProvider.family((ref, id) { return ref.watch(callLogRepositoryProvider).watchCallLog(id); -} +}); diff --git a/apps/im_app/lib/features/chat/di/chat_bot_provider.dart b/apps/im_app/lib/features/chat/di/chat_bot_provider.dart index 744d7b2..8fd7801 100644 --- a/apps/im_app/lib/features/chat/di/chat_bot_provider.dart +++ b/apps/im_app/lib/features/chat/di/chat_bot_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有聊天机器人 -@riverpod -Stream> allChatBots(Ref ref) { +final allChatBotsProvider = StreamProvider>((ref) { return ref.watch(chatBotRepositoryProvider).watchAllChatBots(); -} +}); /// 监听指定聊天机器人 -@riverpod -Stream chatBot(Ref ref, int id) { +final chatBotProvider = StreamProvider.family((ref, id) { return ref.watch(chatBotRepositoryProvider).watchChatBot(id); -} +}); diff --git a/apps/im_app/lib/features/chat/di/chat_category_provider.dart b/apps/im_app/lib/features/chat/di/chat_category_provider.dart index 3903c47..b74813a 100644 --- a/apps/im_app/lib/features/chat/di/chat_category_provider.dart +++ b/apps/im_app/lib/features/chat/di/chat_category_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有聊天分类 -@riverpod -Stream> allChatCategories(Ref ref) { +final allChatCategoriesProvider = StreamProvider>((ref) { return ref.watch(chatCategoryRepositoryProvider).watchAllChatCategories(); -} +}); /// 监听指定聊天分类 -@riverpod -Stream chatCategory(Ref ref, int id) { +final chatCategoryProvider = StreamProvider.family((ref, id) { return ref.watch(chatCategoryRepositoryProvider).watchChatCategory(id); -} +}); diff --git a/apps/im_app/lib/features/chat/di/chat_provider.dart b/apps/im_app/lib/features/chat/di/chat_provider.dart index 10288ba..b9c7a3d 100644 --- a/apps/im_app/lib/features/chat/di/chat_provider.dart +++ b/apps/im_app/lib/features/chat/di/chat_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有聊天 -@riverpod -Stream> allChats(Ref ref) { +final allChatsProvider = StreamProvider>((ref) { return ref.watch(chatRepositoryProvider).watchAllChats(); -} +}); /// 监听指定聊天 -@riverpod -Stream chat(Ref ref, int id) { +final chatProvider = StreamProvider.family((ref, id) { return ref.watch(chatRepositoryProvider).watchChat(id); -} +}); diff --git a/apps/im_app/lib/features/chat/di/chat_service_providers.dart b/apps/im_app/lib/features/chat/di/chat_service_providers.dart new file mode 100644 index 0000000..fc3bcef --- /dev/null +++ b/apps/im_app/lib/features/chat/di/chat_service_providers.dart @@ -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((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((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((ref) { + return FetchHistoryUseCase( + apiClient: ref.read(networkSdkApiProvider), + messageRepo: ref.read(messageRepositoryProvider), + ); +}); diff --git a/apps/im_app/lib/features/chat/di/group_provider.dart b/apps/im_app/lib/features/chat/di/group_provider.dart index f9b39a0..fea66ad 100644 --- a/apps/im_app/lib/features/chat/di/group_provider.dart +++ b/apps/im_app/lib/features/chat/di/group_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有群组 -@riverpod -Stream> allGroups(Ref ref) { +final allGroupsProvider = StreamProvider>((ref) { return ref.watch(groupRepositoryProvider).watchAll(); -} +}); /// 监听指定群组 -@riverpod -Stream groupById(Ref ref, int id) { +final groupByIdProvider = StreamProvider.family((ref, id) { return ref.watch(groupRepositoryProvider).watchById(id); -} +}); diff --git a/apps/im_app/lib/features/chat/di/message_provider.dart b/apps/im_app/lib/features/chat/di/message_provider.dart index 3faa9bd..c08f860 100644 --- a/apps/im_app/lib/features/chat/di/message_provider.dart +++ b/apps/im_app/lib/features/chat/di/message_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听指定 chatId 的消息列表 -@riverpod -Stream> messagesByChatId(Ref ref, int chatId) { +final messagesByChatIdProvider = StreamProvider.family, int>((ref, chatId) { return ref.watch(messageRepositoryProvider).watchByChatId(chatId); -} +}); /// 监听指定消息 -@riverpod -Stream messageById(Ref ref, int id) { +final messageByIdProvider = StreamProvider.family((ref, id) { return ref.watch(messageRepositoryProvider).watchById(id); -} +}); diff --git a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart index 3679f9f..acff516 100644 --- a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart @@ -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 { 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.new, +); diff --git a/apps/im_app/lib/features/chat/presentation/chat_detail_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_detail_view_model.dart new file mode 100644 index 0000000..7721db5 --- /dev/null +++ b/apps/im_app/lib/features/chat/presentation/chat_detail_view_model.dart @@ -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 { + @override + ChatDetailState build(int arg) { + // 进入聊天时自动拉取历史 + Future.microtask(() => _fetchInitialHistory()); + return const ChatDetailState(); + } + + // ── 操作 ─────────────────────────────────────────────────────────────────── + + Future 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 _fetchInitialHistory() async { + try { + await ref + .read(fetchHistoryUseCaseProvider) + .execute(chatId: arg, chatIdx: 0, limit: 30); + } catch (_) { + // 静默失败,UI 通过 DB Stream 反映现有数据 + } + } +} + +final chatDetailViewModelProvider = + NotifierProvider.family( + ChatDetailViewModel.new, +); diff --git a/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart new file mode 100644 index 0000000..a78c014 --- /dev/null +++ b/apps/im_app/lib/features/chat/presentation/chat_list_view_model.dart @@ -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 { + @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.new); diff --git a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart index 88f5b83..931c089 100644 --- a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart @@ -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 { @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.new); diff --git a/apps/im_app/lib/features/chat/usecases/fetch_history_use_case.dart b/apps/im_app/lib/features/chat/usecases/fetch_history_use_case.dart new file mode 100644 index 0000000..7e6391c --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/fetch_history_use_case.dart @@ -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 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; + } +} diff --git a/apps/im_app/lib/features/chat/usecases/send_message_use_case.dart b/apps/im_app/lib/features/chat/usecases/send_message_use_case.dart new file mode 100644 index 0000000..83907c8 --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/send_message_use_case.dart @@ -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 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'); + } + } +} diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart index 4270356..75a6cb6 100644 --- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart @@ -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 createState() => _ChatDetailPageState(); +} + +class _ChatDetailPageState extends ConsumerState { + 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), + ), ], ), ), diff --git a/apps/im_app/lib/features/chat/view/chat_page.dart b/apps/im_app/lib/features/chat/view/chat_page.dart index a3bedbc..360525b 100644 --- a/apps/im_app/lib/features/chat/view/chat_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_page.dart @@ -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}'; + } +} diff --git a/apps/im_app/lib/features/contact/di/pending_friend_request_history_provider.dart b/apps/im_app/lib/features/contact/di/pending_friend_request_history_provider.dart index 9648e59..82688be 100644 --- a/apps/im_app/lib/features/contact/di/pending_friend_request_history_provider.dart +++ b/apps/im_app/lib/features/contact/di/pending_friend_request_history_provider.dart @@ -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> allPendingFriendRequestHistories( - Ref ref, -) { +final allPendingFriendRequestHistoriesProvider = StreamProvider>((ref) { return ref.watch(pendingFriendRequestHistoryRepositoryProvider).watchAll(); -} +}); /// 监听指定 uid 的好友请求历史 -@riverpod -Stream> pendingFriendRequestHistoriesByUid( - Ref ref, - int uid, -) { +final pendingFriendRequestHistoriesByUidProvider = StreamProvider.family, int>((ref, uid) { return ref .watch(pendingFriendRequestHistoryRepositoryProvider) .watchByUid(uid); -} +}); diff --git a/apps/im_app/lib/features/contact/di/user_request_history_provider.dart b/apps/im_app/lib/features/contact/di/user_request_history_provider.dart index 7acc693..58e5ec1 100644 --- a/apps/im_app/lib/features/contact/di/user_request_history_provider.dart +++ b/apps/im_app/lib/features/contact/di/user_request_history_provider.dart @@ -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> allUserRequestHistories(Ref ref) { +final allUserRequestHistoriesProvider = StreamProvider>((ref) { return ref.watch(userRequestHistoryRepositoryProvider).watchAll(); -} +}); /// 监听指定状态的用户请求历史 -@riverpod -Stream> userRequestHistoriesByStatus( - Ref ref, - int status, -) { +final userRequestHistoriesByStatusProvider = StreamProvider.family, int>((ref, status) { return ref.watch(userRequestHistoryRepositoryProvider).watchByStatus(status); -} +}); /// 监听指定用户请求历史 -@riverpod -Stream userRequestHistoryById(Ref ref, int id) { +final userRequestHistoryByIdProvider = StreamProvider.family((ref, id) { return ref.watch(userRequestHistoryRepositoryProvider).watchById(id); -} +}); diff --git a/apps/im_app/lib/features/login/presentation/login_state.dart b/apps/im_app/lib/features/login/presentation/login_state.dart index 6aef117..fa6ba30 100644 --- a/apps/im_app/lib/features/login/presentation/login_state.dart +++ b/apps/im_app/lib/features/login/presentation/login_state.dart @@ -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 { diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart index e96abed..b26245c 100644 --- a/apps/im_app/lib/features/login/presentation/login_view_model.dart +++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart @@ -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 { @override LoginState build() => const LoginState(); /// 步骤 1:发送手机验证码 - /// - /// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。 Future 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 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.new); diff --git a/apps/im_app/lib/features/mini_app/mini_app_provider.dart b/apps/im_app/lib/features/mini_app/mini_app_provider.dart index b736468..42f017f 100644 --- a/apps/im_app/lib/features/mini_app/mini_app_provider.dart +++ b/apps/im_app/lib/features/mini_app/mini_app_provider.dart @@ -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( + (ref) => DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider)), +); -@riverpod -MiniAppRepository discoverMiniAppRepository(Ref ref) => - DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider)); +final exploreMiniAppRepositoryProvider = Provider( + (ref) => ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)), +); -@riverpod -MiniAppRepository exploreMiniAppRepository(Ref ref) => - ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)); +final favoriteMiniAppRepositoryProvider = Provider( + (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( + (ref) => RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider)), +); diff --git a/apps/im_app/lib/features/settings/presentation/theme_view_model.dart b/apps/im_app/lib/features/settings/presentation/theme_view_model.dart index de1cfa8..3e6a185 100644 --- a/apps/im_app/lib/features/settings/presentation/theme_view_model.dart +++ b/apps/im_app/lib/features/settings/presentation/theme_view_model.dart @@ -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 { @override ThemeMode build() => ref.watch(themeModeProvider); @@ -35,3 +17,6 @@ class ThemeViewModel extends _$ThemeViewModel { ); } } + +final themeViewModelProvider = + NotifierProvider(ThemeViewModel.new); diff --git a/apps/im_app/lib/features/workspace/di/workspace_provider.dart b/apps/im_app/lib/features/workspace/di/workspace_provider.dart index 8254354..80088f2 100644 --- a/apps/im_app/lib/features/workspace/di/workspace_provider.dart +++ b/apps/im_app/lib/features/workspace/di/workspace_provider.dart @@ -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((ref) { // ── Streams ─────────────────────────────────────────────────────────────────── /// 监听所有工作空间 -@riverpod -Stream> allWorkspaces(Ref ref) { +final allWorkspacesProvider = StreamProvider>((ref) { return ref.watch(workspaceRepositoryProvider).watchAll(); -} +}); /// 监听指定工作空间 -@riverpod -Stream workspaceById(Ref ref, int id) { +final workspaceByIdProvider = StreamProvider.family((ref, id) { return ref.watch(workspaceRepositoryProvider).watchById(id); -} +});