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

@@ -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/data/repositories/api_retry_repository_impl.dart';
import 'package:im_app/domain/entities/api_retry.dart'; import 'package:im_app/domain/entities/api_retry.dart';
import 'package:im_app/domain/repositories/api_retry_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -16,13 +13,11 @@ final apiRetryRepositoryProvider = Provider<ApiRetryRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有重试任务 /// 监听所有重试任务
@riverpod final allApiRetriesProvider = StreamProvider<List<ApiRetry>>((ref) {
Stream<List<ApiRetry>> allApiRetries(Ref ref) {
return ref.watch(apiRetryRepositoryProvider).watchAll(); return ref.watch(apiRetryRepositoryProvider).watchAll();
} });
/// 监听未同步的重试任务 /// 监听未同步的重试任务
@riverpod final pendingApiRetriesProvider = StreamProvider<List<ApiRetry>>((ref) {
Stream<List<ApiRetry>> pendingApiRetries(Ref ref) {
return ref.watch(apiRetryRepositoryProvider).watchPending(); return ref.watch(apiRetryRepositoryProvider).watchPending();
} });

View File

@@ -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/data/repositories/sound_repository_impl.dart';
import 'package:im_app/domain/entities/sound.dart'; import 'package:im_app/domain/entities/sound.dart';
import 'package:im_app/domain/repositories/sound_repository.dart'; import 'package:im_app/domain/repositories/sound_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sound_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -16,19 +13,16 @@ final soundRepositoryProvider = Provider<SoundRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有音效 /// 监听所有音效
@riverpod final allSoundsProvider = StreamProvider<List<Sound>>((ref) {
Stream<List<Sound>> allSounds(Ref ref) {
return ref.watch(soundRepositoryProvider).watchAll(); return ref.watch(soundRepositoryProvider).watchAll();
} });
/// 监听指定类型音效 /// 监听指定类型音效
@riverpod final soundsByTypeProvider = StreamProvider.family<List<Sound>, int>((ref, typ) {
Stream<List<Sound>> soundsByType(Ref ref, int typ) {
return ref.watch(soundRepositoryProvider).watchByType(typ); return ref.watch(soundRepositoryProvider).watchByType(typ);
} });
/// 监听指定音效 /// 监听指定音效
@riverpod final soundByIdProvider = StreamProvider.family<Sound?, int>((ref, id) {
Stream<Sound?> soundById(Ref ref, int id) {
return ref.watch(soundRepositoryProvider).watchById(id); return ref.watch(soundRepositoryProvider).watchById(id);
} });

View File

@@ -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/data/repositories/tag_repository_impl.dart';
import 'package:im_app/domain/entities/tag.dart'; import 'package:im_app/domain/entities/tag.dart';
import 'package:im_app/domain/repositories/tag_repository.dart'; import 'package:im_app/domain/repositories/tag_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tag_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -16,25 +13,21 @@ final tagRepositoryProvider = Provider<TagRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有标签 /// 监听所有标签
@riverpod final allTagsProvider = StreamProvider<List<Tag>>((ref) {
Stream<List<Tag>> allTags(Ref ref) {
return ref.watch(tagRepositoryProvider).watchAll(); return ref.watch(tagRepositoryProvider).watchAll();
} });
/// 监听指定 uid 的标签 /// 监听指定 uid 的标签
@riverpod final tagsByUidProvider = StreamProvider.family<List<Tag>, int>((ref, uid) {
Stream<List<Tag>> tagsByUid(Ref ref, int uid) {
return ref.watch(tagRepositoryProvider).watchByUid(uid); return ref.watch(tagRepositoryProvider).watchByUid(uid);
} });
/// 监听指定类型的标签 /// 监听指定类型的标签
@riverpod final tagsByTypeProvider = StreamProvider.family<List<Tag>, int>((ref, type) {
Stream<List<Tag>> tagsByType(Ref ref, int type) {
return ref.watch(tagRepositoryProvider).watchByType(type); return ref.watch(tagRepositoryProvider).watchByType(type);
} });
/// 监听指定标签 /// 监听指定标签
@riverpod final tagByIdProvider = StreamProvider.family<Tag?, int>((ref, id) {
Stream<Tag?> tagById(Ref ref, int id) {
return ref.watch(tagRepositoryProvider).watchById(id); return ref.watch(tagRepositoryProvider).watchById(id);
} });

View File

@@ -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/delete_users_use_case.dart';
import 'package:im_app/features/chat/usecases/insert_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: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/app/di/db_provider.dart';
import 'package:im_app/data/repositories/user_repository_impl.dart'; import 'package:im_app/data/repositories/user_repository_impl.dart';
import 'package:im_app/domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
import 'package:im_app/domain/repositories/user_repository.dart'; import 'package:im_app/domain/repositories/user_repository.dart';
part 'user_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@riverpod final userRepositoryProvider = Provider<UserRepository>((ref) {
UserRepository userRepository(Ref ref) {
return UserRepositoryImpl(ref.watch(storageSdkProvider)); return UserRepositoryImpl(ref.watch(storageSdkProvider));
} });
// ── Use Cases ───────────────────────────────────────────────────────────────── // ── Use Cases ─────────────────────────────────────────────────────────────────
@@ -42,12 +38,10 @@ final deleteUsersUseCaseProvider = Provider<DeleteUsersUseCase>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
@riverpod final usersProvider = StreamProvider.family<List<User>, Set<int>>((ref, uids) {
Stream<List<User>> users(Ref ref, Set<int> uids) {
return ref.watch(userRepositoryProvider).watchUsers(uids.toList()); return ref.watch(userRepositoryProvider).watchUsers(uids.toList());
} });
@riverpod final allUsersProvider = StreamProvider<List<User>>((ref) {
Stream<List<User>> allUsers(Ref ref) {
return ref.watch(userRepositoryProvider).watchAllUsers(); return ref.watch(userRepositoryProvider).watchAllUsers();
} });

View File

@@ -1,10 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/user_provider.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/entities/user.dart';
import 'package:im_app/domain/repositories/user_repository.dart'; import 'package:im_app/domain/repositories/user_repository.dart';
part 'user_notifier.g.dart';
/// 单个用户状态管理 (family — 每个 uid 独立 notifier) /// 单个用户状态管理 (family — 每个 uid 独立 notifier)
/// ///
/// ## 用法 /// ## 用法
@@ -26,22 +24,21 @@ part 'user_notifier.g.dart';
/// // 批量更新 /// // 批量更新
/// ref.read(userNotifierProvider(123).notifier).updateUsers(updatedList); /// ref.read(userNotifierProvider(123).notifier).updateUsers(updatedList);
/// ``` /// ```
@riverpod class UserNotifier extends FamilyAsyncNotifier<User?, int> {
class UserNotifier extends _$UserNotifier {
User? _cached; User? _cached;
UserRepository get _repo => ref.watch(userRepositoryProvider); UserRepository get _repo => ref.watch(userRepositoryProvider);
@override @override
Future<User?> build(int uid) async { Future<User?> build(int arg) async {
ref.onDispose(() => _cached = null); ref.onDispose(() => _cached = null);
_repo.watchUser(uid).listen((user) { _repo.watchUser(arg).listen((user) {
_cached = user; _cached = user;
state = AsyncData(user); state = AsyncData(user);
}); });
return _repo.getUser(uid); return _repo.getUser(arg);
} }
// ── 即时访问,无需 await ────────────────────────────────────────────────── // ── 即时访问,无需 await ──────────────────────────────────────────────────
@@ -56,23 +53,11 @@ class UserNotifier extends _$UserNotifier {
} }
/// 更新单个用户所有字段,按 uid 匹配 /// 更新单个用户所有字段,按 uid 匹配
///
/// 示例:
/// ```dart
/// await notifier.updateUser(user.copyWith(nickname: 'New Name'));
/// ```
Future<void> updateUser(User user) async { Future<void> updateUser(User user) async {
await _repo.updateUser(user); await _repo.updateUser(user);
} }
/// 批量更新用户,每条按 uid 匹配更新所有字段 /// 批量更新用户,每条按 uid 匹配更新所有字段
///
/// 示例:
/// ```dart
/// await notifier.updateUsers(
/// users.map((u) => u.copyWith(nickname: 'new')).toList(),
/// );
/// ```
Future<void> updateUsers(List<User> users) async { Future<void> updateUsers(List<User> users) async {
await _repo.updateUsersBatch(users); await _repo.updateUsersBatch(users);
} }
@@ -80,8 +65,11 @@ class UserNotifier extends _$UserNotifier {
// ── 删除 ───────────────────────────────────────────────────────────────── // ── 删除 ─────────────────────────────────────────────────────────────────
Future<void> deleteUser() async { Future<void> deleteUser() async {
await _repo.deleteUser(uid); await _repo.deleteUser(arg);
_cached = null; _cached = null;
state = const AsyncData(null); state = const AsyncData(null);
} }
} }
final userNotifierProvider =
AsyncNotifierProvider.family<UserNotifier, User?, int>(UserNotifier.new);

View File

@@ -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": <chatId>, "msg_idx": N, "typ": N, "last_msg": "..." }] },
/// "message_realtime_pb": <bytes>
/// }
/// ```
///
/// 每帧含 `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<Map<String, dynamic>>? _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<void> _handleFrame(Map<String, dynamic> frame) async {
try {
final chatPayload = frame['chat'] as Map<String, dynamic>?;
if (chatPayload == null) return;
final rList = chatPayload['r'] as List<dynamic>?;
if (rList == null || rList.isEmpty) return;
for (final item in rList) {
final entry = item as Map<String, dynamic>?;
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<void> _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<void> _updateChatMeta({
required int chatId,
required Map<String, dynamic> 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');
}
}
}

View File

@@ -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<String, dynamic> 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<MessageItem> messages;
const FetchHistoryResponse({required this.messages});
factory FetchHistoryResponse.fromJson(Map<String, dynamic> json) {
final raw =
json['list'] as List<dynamic>? ??
json['messages'] as List<dynamic>? ??
const [];
return FetchHistoryResponse(
messages: raw
.whereType<Map<String, dynamic>>()
.map(MessageItem.fromJson)
.toList(),
);
}
}
// ── 获取历史消息请求 ───────────────────────────────────────────────────────────
/// GET /app/api/chat/history — 按锚点分页拉取消息
///
/// [chatIdx] 锚点消息 index从 0 开始拉最新)。
/// [limit] 每次拉取条数,默认 20。
class FetchHistoryRequest extends ApiRequestable<FetchHistoryResponse> {
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<String, dynamic> 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<String, dynamic>) {
return FetchHistoryResponse.fromJson(data);
}
if (data is List<dynamic>) {
return FetchHistoryResponse(
messages: data
.whereType<Map<String, dynamic>>()
.map(MessageItem.fromJson)
.toList(),
);
}
return const FetchHistoryResponse(messages: []);
}
}

View File

@@ -76,6 +76,26 @@ class ProfileResponse {
required this.hint, required this.hint,
}); });
factory ProfileResponse.fromJson(Map<String, dynamic> 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( User toEntity() => User(
uid: uid, uid: uid,
uuid: uuid, uuid: uuid,

View File

@@ -76,6 +76,26 @@ class LoginProfile {
required this.hint, required this.hint,
}); });
factory LoginProfile.fromJson(Map<String, dynamic> 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( User toEntity() => User(
uid: uid, uid: uid,
uuid: uuid, uuid: uuid,
@@ -126,6 +146,17 @@ class LoginResponse {
this.isVerified, this.isVerified,
}); });
factory LoginResponse.fromJson(Map<String, dynamic> json) => LoginResponse(
accountId: json['account_id'] as String? ?? '',
profile: LoginProfile.fromJson(json['profile'] as Map<String, dynamic>),
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(); User toEntity() => profile.toEntity();
} }

View File

@@ -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<String, dynamic> 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<SendMessageResponse> {
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<String, dynamic> 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<String, dynamic>) return null;
return SendMessageResponse.fromJson(data);
}
}

View File

@@ -33,6 +33,14 @@ class SendOtpResponse {
this.web, this.web,
this.extras, this.extras,
}); });
factory SendOtpResponse.fromJson(Map<String, dynamic> 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<String, dynamic>?,
);
} }
/// # /app/api/auth/vcode/get — 发送手机验证码 /// # /app/api/auth/vcode/get — 发送手机验证码

View File

@@ -40,6 +40,11 @@ class UploadResult {
final String fileId; final String fileId;
const UploadResult({required this.url, required this.fileId}); const UploadResult({required this.url, required this.fileId});
factory UploadResult.fromJson(Map<String, dynamic> json) => UploadResult(
url: json['url'] as String? ?? '',
fileId: json['file_id'] as String? ?? '',
);
} }
// ═════════════════════════════════════════════ // ═════════════════════════════════════════════

View File

@@ -14,6 +14,9 @@ class VerifyOtpResponse {
final String token; final String token;
const VerifyOtpResponse({required this.token}); const VerifyOtpResponse({required this.token});
factory VerifyOtpResponse.fromJson(Map<String, dynamic> json) =>
VerifyOtpResponse(token: json['token'] as String? ?? '');
} }
/// # /app/api/auth/vcode/check — 校验手机验证码 /// # /app/api/auth/vcode/check — 校验手机验证码

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/app/di/db_provider.dart';
import 'package:im_app/data/repositories/favorite_detail_repository_impl.dart'; import 'package:im_app/data/repositories/favorite_detail_repository_impl.dart';
import 'package:im_app/domain/repositories/favorite_detail_repository.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'; import 'package:im_app/domain/entities/favorite_detail.dart';
part 'favorite_detail_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
/// 收藏详情仓储 Provider /// 收藏详情仓储 Provider
@@ -19,18 +16,13 @@ final favoriteDetailRepositoryProvider = Provider<FavoriteDetailRepository>((
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有收藏详情 /// 监听所有收藏详情
@riverpod final allFavoriteDetailsProvider = StreamProvider<List<FavoriteDetail>>((ref) {
Stream<List<FavoriteDetail>> allFavoriteDetails(Ref ref) {
return ref.watch(favoriteDetailRepositoryProvider).watchAll(); return ref.watch(favoriteDetailRepositoryProvider).watchAll();
} });
/// 监听指定 relatedId 的收藏详情 /// 监听指定 relatedId 的收藏详情
@riverpod final favoriteDetailsByRelatedIdProvider = StreamProvider.family<List<FavoriteDetail>, String>((ref, relatedId) {
Stream<List<FavoriteDetail>> favoriteDetailsByRelatedId(
Ref ref,
String relatedId,
) {
return ref return ref
.watch(favoriteDetailRepositoryProvider) .watch(favoriteDetailRepositoryProvider)
.watchByRelatedId(relatedId); .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/data/repositories/favorite_repository_impl.dart';
import 'package:im_app/domain/entities/favorite.dart'; import 'package:im_app/domain/entities/favorite.dart';
import 'package:im_app/domain/repositories/favorite_repository.dart'; import 'package:im_app/domain/repositories/favorite_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'favorite_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,19 +14,16 @@ final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有收藏 /// 监听所有收藏
@riverpod final allFavoritesProvider = StreamProvider<List<Favorite>>((ref) {
Stream<List<Favorite>> allFavorites(Ref ref) {
return ref.watch(favoriteRepositoryProvider).watchAll(); return ref.watch(favoriteRepositoryProvider).watchAll();
} });
/// 监听指定收藏 /// 监听指定收藏
@riverpod final favoriteByIdProvider = StreamProvider.family<Favorite?, int>((ref, id) {
Stream<Favorite?> favoriteById(Ref ref, int id) {
return ref.watch(favoriteRepositoryProvider).watchById(id); return ref.watch(favoriteRepositoryProvider).watchById(id);
} });
/// 监听指定 parentId 的收藏 /// 监听指定 parentId 的收藏
@riverpod final favoritesByParentIdProvider = StreamProvider.family<List<Favorite>, String>((ref, parentId) {
Stream<List<Favorite>> favoritesByParentId(Ref ref, String parentId) {
return ref.watch(favoriteRepositoryProvider).watchByParentId(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/data/repositories/call_log_repository_impl.dart';
import 'package:im_app/domain/entities/call_log.dart'; import 'package:im_app/domain/entities/call_log.dart';
import 'package:im_app/domain/repositories/call_log_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final callLogRepositoryProvider = Provider<CallLogRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有通话记录 /// 监听所有通话记录
@riverpod final allCallLogsProvider = StreamProvider<List<CallLog>>((ref) {
Stream<List<CallLog>> allCallLogs(Ref ref) {
return ref.watch(callLogRepositoryProvider).watchAllCallLogs(); return ref.watch(callLogRepositoryProvider).watchAllCallLogs();
} });
/// 监听指定通话记录 /// 监听指定通话记录
@riverpod final callLogProvider = StreamProvider.family<CallLog?, String>((ref, id) {
Stream<CallLog?> callLog(Ref ref, String id) {
return ref.watch(callLogRepositoryProvider).watchCallLog(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/data/repositories/chat_bot_repository_impl.dart';
import 'package:im_app/domain/entities/chat_bot.dart'; import 'package:im_app/domain/entities/chat_bot.dart';
import 'package:im_app/domain/repositories/chat_bot_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatBotRepositoryProvider = Provider<ChatBotRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天机器人 /// 监听所有聊天机器人
@riverpod final allChatBotsProvider = StreamProvider<List<ChatBot>>((ref) {
Stream<List<ChatBot>> allChatBots(Ref ref) {
return ref.watch(chatBotRepositoryProvider).watchAllChatBots(); return ref.watch(chatBotRepositoryProvider).watchAllChatBots();
} });
/// 监听指定聊天机器人 /// 监听指定聊天机器人
@riverpod final chatBotProvider = StreamProvider.family<ChatBot?, int>((ref, id) {
Stream<ChatBot?> chatBot(Ref ref, int id) {
return ref.watch(chatBotRepositoryProvider).watchChatBot(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/data/repositories/chat_category_repository_impl.dart';
import 'package:im_app/domain/entities/chat_category.dart'; import 'package:im_app/domain/entities/chat_category.dart';
import 'package:im_app/domain/repositories/chat_category_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatCategoryRepositoryProvider = Provider<ChatCategoryRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天分类 /// 监听所有聊天分类
@riverpod final allChatCategoriesProvider = StreamProvider<List<ChatCategory>>((ref) {
Stream<List<ChatCategory>> allChatCategories(Ref ref) {
return ref.watch(chatCategoryRepositoryProvider).watchAllChatCategories(); return ref.watch(chatCategoryRepositoryProvider).watchAllChatCategories();
} });
/// 监听指定聊天分类 /// 监听指定聊天分类
@riverpod final chatCategoryProvider = StreamProvider.family<ChatCategory?, int>((ref, id) {
Stream<ChatCategory?> chatCategory(Ref ref, int id) {
return ref.watch(chatCategoryRepositoryProvider).watchChatCategory(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/data/repositories/chat_repository_impl.dart';
import 'package:im_app/domain/entities/chat.dart'; import 'package:im_app/domain/entities/chat.dart';
import 'package:im_app/domain/repositories/chat_repository.dart'; import 'package:im_app/domain/repositories/chat_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final chatRepositoryProvider = Provider<ChatRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有聊天 /// 监听所有聊天
@riverpod final allChatsProvider = StreamProvider<List<Chat>>((ref) {
Stream<List<Chat>> allChats(Ref ref) {
return ref.watch(chatRepositoryProvider).watchAllChats(); return ref.watch(chatRepositoryProvider).watchAllChats();
} });
/// 监听指定聊天 /// 监听指定聊天
@riverpod final chatProvider = StreamProvider.family<Chat?, int>((ref, id) {
Stream<Chat?> chat(Ref ref, int id) {
return ref.watch(chatRepositoryProvider).watchChat(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/data/repositories/group_repository_impl.dart';
import 'package:im_app/domain/entities/group.dart'; import 'package:im_app/domain/entities/group.dart';
import 'package:im_app/domain/repositories/group_repository.dart'; import 'package:im_app/domain/repositories/group_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'group_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final groupRepositoryProvider = Provider<GroupRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有群组 /// 监听所有群组
@riverpod final allGroupsProvider = StreamProvider<List<Group>>((ref) {
Stream<List<Group>> allGroups(Ref ref) {
return ref.watch(groupRepositoryProvider).watchAll(); return ref.watch(groupRepositoryProvider).watchAll();
} });
/// 监听指定群组 /// 监听指定群组
@riverpod final groupByIdProvider = StreamProvider.family<Group?, int>((ref, id) {
Stream<Group?> groupById(Ref ref, int id) {
return ref.watch(groupRepositoryProvider).watchById(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/data/repositories/message_repository_impl.dart';
import 'package:im_app/domain/entities/message.dart'; import 'package:im_app/domain/entities/message.dart';
import 'package:im_app/domain/repositories/message_repository.dart'; import 'package:im_app/domain/repositories/message_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'message_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,13 +14,11 @@ final messageRepositoryProvider = Provider<MessageRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听指定 chatId 的消息列表 /// 监听指定 chatId 的消息列表
@riverpod final messagesByChatIdProvider = StreamProvider.family<List<Message>, int>((ref, chatId) {
Stream<List<Message>> messagesByChatId(Ref ref, int chatId) {
return ref.watch(messageRepositoryProvider).watchByChatId(chatId); return ref.watch(messageRepositoryProvider).watchByChatId(chatId);
} });
/// 监听指定消息 /// 监听指定消息
@riverpod final messageByIdProvider = StreamProvider.family<Message?, int>((ref, id) {
Stream<Message?> messageById(Ref ref, int id) {
return ref.watch(messageRepositoryProvider).watchById(id); return ref.watch(messageRepositoryProvider).watchById(id);
} });

View File

@@ -1,11 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; 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/app/di/user_provider.dart';
import 'package:im_app/domain/entities/user.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 { class ChatDbTestState {
final bool testStarted; final bool testStarted;
@@ -41,8 +39,7 @@ class ChatDbTestState {
); );
} }
@riverpod class ChatDbTestViewModel extends Notifier<ChatDbTestState> {
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
final _random = Random(); final _random = Random();
bool _isTesting = false; bool _isTesting = false;
static const _pageSize = 50; static const _pageSize = 50;
@@ -81,7 +78,6 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
); );
} }
/// Called by ListView when reaching the end
void loadMore() { void loadMore() {
if (!state.hasMore || state.testStarted) return; if (!state.hasMore || state.testStarted) return;
_loadNextPage(); _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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/di/app_providers.dart';
import 'package:im_app/app/router/app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
part 'chat_view_model.g.dart'; /// 聊天页 ViewModel(手动 NotifierProvider
class ChatViewModel extends Notifier<void> {
/// 聊天页 ViewModel@riverpod 自动生成 `chatViewModelProvider`
///
/// 当前 chat 页面为 Demo无需从服务端加载数据状态为 void。
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
///
/// ## 数据流
///
/// ```
/// ChatPage
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
/// → ★ ChatViewModel ★ ← 你在这里
/// → 导航 / 业务逻辑
/// ```
@riverpod
class ChatViewModel extends _$ChatViewModel {
@override @override
void build() {} void build() {}
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
/// 切换到联系人 Tab。
void goToContact(BuildContext context) { void goToContact(BuildContext context) {
context.go(AppRouteName.contact.path); context.go(AppRouteName.contact.path);
} }
/// 带 extra 参数 push 聊天详情页extra 传 Dart Record
void pushChatDetailWithExtra(BuildContext context) { void pushChatDetailWithExtra(BuildContext context) {
context.push( context.push(
AppRouteName.chatDetail.path, AppRouteName.chatDetail.path,
@@ -40,33 +21,26 @@ class ChatViewModel extends _$ChatViewModel {
); );
} }
/// 带路径参数 push 聊天详情页id 内嵌在 URL 中)。
void pushChatDetailById(BuildContext context) { void pushChatDetailById(BuildContext context) {
context.push(AppRouteName.chatDetailByIdPath('99')); context.push(AppRouteName.chatDetailByIdPath('99'));
} }
/// 无参 push演示 push 导航)。
void pushSettingsTheme(BuildContext context) { void pushSettingsTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path); context.push(AppRouteName.settingsTheme.path);
} }
/// 切换到设置 Tab。
void goToSettings(BuildContext context) { void goToSettings(BuildContext context) {
context.go(AppRouteName.settings.path); context.go(AppRouteName.settings.path);
} }
/// 测试数据库性能
void goToDatabaseTest(BuildContext context) { void goToDatabaseTest(BuildContext context) {
context.push(AppRouteName.chatDBTest.path); context.push(AppRouteName.chatDBTest.path);
} }
// ── 业务 ─────────────────────────────────────────────────────────────────
/// 退出登录
///
/// 调用 [AuthNotifier.logout] 清除登录状态go_router 守卫检测到后
/// 自动重定向到登录页,无需手动导航。
void logout() { void logout() {
ref.read(authNotifierProvider).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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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` 接收上一页传入的数据, /// 接收 [conversationId]chatId 字符串)和 [title](会话名称)。
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入, /// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream实时渲染气泡列表。
/// 本页不感知 GoRouter 任何实现细节 /// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息
/// class ChatDetailPage extends ConsumerStatefulWidget {
/// ## 正式开发
///
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
class ChatDetailPage extends ConsumerWidget {
const ChatDetailPage({ const ChatDetailPage({
super.key, super.key,
required this.conversationId, required this.conversationId,
@@ -24,18 +22,236 @@ class ChatDetailPage extends ConsumerWidget {
final String title; final String title;
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
final s = context.styles; }
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( return Scaffold(
appBar: AppBar(title: Text(title)), appBar: AppBar(title: Text(widget.title)),
body: Center( body: Column(
child: Column( children: [
mainAxisSize: MainAxisSize.min, // ── 消息列表 ────────────────────────────────────────────────────────
spacing: 8, 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: [ children: [
Text('会话 ID', style: s.labelMuted), Expanded(
Text(conversationId, style: s.headlineSmall), 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/ui/components/app_button.dart'; import 'package:im_app/domain/entities/chat.dart';
import 'package:im_app/features/chat/presentation/chat_view_model.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 的常见导航场景: /// 从本地 DB Stream 读取会话列表,实时反映 WS 推送的新消息摘要。
/// - 「切换 Tab」 — go替换历史不可返回 /// 点击任意会话进入 [ChatDetailPage]chatId + 名称作为路由参数)。
/// - 「有参 pushextra」 — push + extraDart Record可返回
/// - 「有参 push路径参数」— push + URL 内嵌 id可返回
/// - 「无参 push」 — push可返回
/// - 「退出登录」 — 守卫自动重定向到 /login
///
/// 所有操作通过 [ChatViewModel] 处理View 不直接调用路由。
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
class ChatPage extends ConsumerWidget { class ChatPage extends ConsumerWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.watch(chatListViewModelProvider.notifier);
final chatsAsync = ref.watch(allChatsProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('聊天')), appBar: AppBar(
body: Center( title: const Text('消息'),
child: Column( actions: [
mainAxisSize: MainAxisSize.min, IconButton(
spacing: 16, icon: const Icon(Icons.storage_outlined),
children: [ tooltip: '数据库测试',
// 切换 Tab用 go替换整个历史栈不可返回 onPressed: () => vm.goToDatabaseTest(context),
AppButton.inverse( ),
label: '切换 Tabgo', ],
onPressed: () => ),
ref.read(chatViewModelProvider.notifier).goToContact(context), body: chatsAsync.when(
), data: (chats) => chats.isEmpty
// 带参数 pushextra 传 Dart Record适合已有对象的场景 ? const Center(child: Text('暂无会话'))
AppButton.inverse( : ListView.separated(
label: '有参 pushextra', itemCount: chats.length,
onPressed: () => ref separatorBuilder: (_, __) =>
.read(chatViewModelProvider.notifier) const Divider(height: 1, indent: 72),
.pushChatDetailWithExtra(context), itemBuilder: (context, index) =>
), _ChatTile(chat: chats[index], vm: vm),
// 带参数 pushid 内嵌在路径中,适合需要深链接 / 分享的场景 ),
AppButton.inverse( loading: () => const Center(child: CircularProgressIndicator()),
label: '有参 push路径参数', error: (e, _) => Center(child: Text('加载失败: $e')),
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(),
),
],
),
), ),
); );
} }
} }
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/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/entities/pending_friend_request_history.dart';
import 'package:im_app/domain/repositories/pending_friend_request_history_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -19,20 +16,13 @@ final pendingFriendRequestHistoryRepositoryProvider =
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有好友请求历史 /// 监听所有好友请求历史
@riverpod final allPendingFriendRequestHistoriesProvider = StreamProvider<List<PendingFriendRequestHistory>>((ref) {
Stream<List<PendingFriendRequestHistory>> allPendingFriendRequestHistories(
Ref ref,
) {
return ref.watch(pendingFriendRequestHistoryRepositoryProvider).watchAll(); return ref.watch(pendingFriendRequestHistoryRepositoryProvider).watchAll();
} });
/// 监听指定 uid 的好友请求历史 /// 监听指定 uid 的好友请求历史
@riverpod final pendingFriendRequestHistoriesByUidProvider = StreamProvider.family<List<PendingFriendRequestHistory>, int>((ref, uid) {
Stream<List<PendingFriendRequestHistory>> pendingFriendRequestHistoriesByUid(
Ref ref,
int uid,
) {
return ref return ref
.watch(pendingFriendRequestHistoryRepositoryProvider) .watch(pendingFriendRequestHistoryRepositoryProvider)
.watchByUid(uid); .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/data/repositories/user_request_history_repository_impl.dart';
import 'package:im_app/domain/entities/user_request_history.dart'; import 'package:im_app/domain/entities/user_request_history.dart';
import 'package:im_app/domain/repositories/user_request_history_repository.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 ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -17,22 +14,16 @@ final userRequestHistoryRepositoryProvider =
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有用户请求历史 /// 监听所有用户请求历史
@riverpod final allUserRequestHistoriesProvider = StreamProvider<List<UserRequestHistory>>((ref) {
Stream<List<UserRequestHistory>> allUserRequestHistories(Ref ref) {
return ref.watch(userRequestHistoryRepositoryProvider).watchAll(); return ref.watch(userRequestHistoryRepositoryProvider).watchAll();
} });
/// 监听指定状态的用户请求历史 /// 监听指定状态的用户请求历史
@riverpod final userRequestHistoriesByStatusProvider = StreamProvider.family<List<UserRequestHistory>, int>((ref, status) {
Stream<List<UserRequestHistory>> userRequestHistoriesByStatus(
Ref ref,
int status,
) {
return ref.watch(userRequestHistoryRepositoryProvider).watchByStatus(status); return ref.watch(userRequestHistoryRepositoryProvider).watchByStatus(status);
} });
/// 监听指定用户请求历史 /// 监听指定用户请求历史
@riverpod final userRequestHistoryByIdProvider = StreamProvider.family<UserRequestHistory?, int>((ref, id) {
Stream<UserRequestHistory?> userRequestHistoryById(Ref ref, int id) {
return ref.watch(userRequestHistoryRepositoryProvider).watchById(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 { enum LoginStep {
/// 步骤 1输入手机号 /// 步骤 1输入手机号
@@ -11,45 +7,50 @@ enum LoginStep {
otp, otp,
} }
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString /// 登录页面状态(手动 copyWith
/// ///
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态, /// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
/// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。 /// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。
/// class LoginState {
/// ## 状态流转 const LoginState({
/// this.step = LoginStep.phone,
/// ``` this.countryCode = '+65',
/// 初始 this.contact = '',
/// → LoginState() step: phone, isLoading: false this.isLoading = false,
/// 点击"获取验证码" this.error,
/// → 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._();
const factory LoginState({ /// 当前步骤(手机号输入 or 验证码输入)
/// 当前步骤(手机号输入 or 验证码输入) final LoginStep step;
@Default(LoginStep.phone) LoginStep step,
/// 国家代码(默认 +65暂不支持切换 /// 国家代码(默认 +65暂不支持切换
@Default('+65') String countryCode, final String countryCode;
/// 已提交的手机号(步骤 2 用于显示和构建请求) /// 已提交的手机号(步骤 2 用于显示和构建请求)
@Default('') String contact, 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, 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" /// 步骤 2 显示的脱敏手机号,如 "138****0000"
String get maskedContact { 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: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/app/di/app_providers.dart';
import 'package:im_app/features/login/di/auth_providers.dart'; import 'package:im_app/features/login/di/auth_providers.dart';
import 'package:im_app/features/login/presentation/login_state.dart'; import 'package:im_app/features/login/presentation/login_state.dart';
part 'login_view_model.g.dart'; /// 登录 ViewModel(手动 NotifierProvider
/// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider`
/// ///
/// 管理两步登录流程:手机号 → 验证码 → 完成登录。 /// 管理两步登录流程:手机号 → 验证码 → 完成登录。
/// ///
/// ```dart
/// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider);
///
/// // View 层调用方法
/// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
/// ```
///
/// ## DI 链路 /// ## DI 链路
/// ///
/// ``` /// ```
/// loginViewModelProvider ← @riverpod 自动生成 /// loginViewModelProvider
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配 /// → ref.read(loginUseCaseProvider)
/// → ref.read(authRepositoryProvider) ← di/ 手动装配 /// → ref.read(authRepositoryProvider)
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配 /// → ref.read(networkSdkApiProvider)
/// ``` /// ```
@riverpod class LoginViewModel extends Notifier<LoginState> {
class LoginViewModel extends _$LoginViewModel {
@override @override
LoginState build() => const LoginState(); LoginState build() => const LoginState();
/// 步骤 1发送手机验证码 /// 步骤 1发送手机验证码
///
/// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
Future<void> sendOtp(String countryCode, String contact) async { Future<void> sendOtp(String countryCode, String contact) async {
if (state.isLoading) return; if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -65,8 +51,6 @@ class LoginViewModel extends _$LoginViewModel {
} }
/// 步骤 2+3校验验证码并完成登录 /// 步骤 2+3校验验证码并完成登录
///
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向provider 随即被 dispose。
Future<void> verifyAndLogin(String code) async { Future<void> verifyAndLogin(String code) async {
if (state.isLoading) return; if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -80,8 +64,6 @@ class LoginViewModel extends _$LoginViewModel {
code: code, code: code,
); );
// 成功后触发路由守卫重定向。
// 注意login() 触发导航后 provider 随即被 dispose之后不能再写 state。
if (!ref.mounted) return; if (!ref.mounted) return;
ref.read(authNotifierProvider).login(uid: user.uid); ref.read(authNotifierProvider).login(uid: user.uid);
} on FormatException catch (e) { } on FormatException catch (e) {
@@ -96,8 +78,11 @@ class LoginViewModel extends _$LoginViewModel {
} }
} }
/// 返回手机号输入步骤(用户想修改手机号) /// 返回手机号输入步骤
void backToPhone() { void backToPhone() {
state = state.copyWith(step: LoginStep.phone, error: null); 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/app/di/db_provider.dart';
import 'package:im_app/data/repositories/discover_mini_app_repository_impl.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/explore_mini_app_repository_impl.dart';
import 'package:im_app/data/repositories/favorite_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/data/repositories/recent_mini_app_repository_impl.dart';
import 'package:im_app/domain/repositories/mini_app_repository.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 final exploreMiniAppRepositoryProvider = Provider<MiniAppRepository>(
MiniAppRepository discoverMiniAppRepository(Ref ref) => (ref) => ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider)); );
@riverpod final favoriteMiniAppRepositoryProvider = Provider<MiniAppRepository>(
MiniAppRepository exploreMiniAppRepository(Ref ref) => (ref) => FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)); );
@riverpod final recentMiniAppRepositoryProvider = Provider<MiniAppRepository>(
MiniAppRepository favoriteMiniAppRepository(Ref ref) => (ref) => RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider)); );
@riverpod
MiniAppRepository recentMiniAppRepository(Ref ref) =>
RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider));

View File

@@ -1,29 +1,11 @@
import 'package:flutter/material.dart'; 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/app/di/app_providers.dart';
import 'package:im_app/features/settings/di/settings_providers.dart'; import 'package:im_app/features/settings/di/settings_providers.dart';
part 'theme_view_model.g.dart'; /// 主题 ViewModel(手动 NotifierProvider
class ThemeViewModel extends Notifier<ThemeMode> {
/// 主题 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 {
@override @override
ThemeMode build() => ref.watch(themeModeProvider); 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/data/repositories/workspace_repository_impl.dart';
import 'package:im_app/domain/entities/workspace.dart'; import 'package:im_app/domain/entities/workspace.dart';
import 'package:im_app/domain/repositories/workspace_repository.dart'; import 'package:im_app/domain/repositories/workspace_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'workspace_provider.g.dart';
// ── Repository ──────────────────────────────────────────────────────────────── // ── Repository ────────────────────────────────────────────────────────────────
@@ -16,13 +13,11 @@ final workspaceRepositoryProvider = Provider<WorkspaceRepository>((ref) {
// ── Streams ─────────────────────────────────────────────────────────────────── // ── Streams ───────────────────────────────────────────────────────────────────
/// 监听所有工作空间 /// 监听所有工作空间
@riverpod final allWorkspacesProvider = StreamProvider<List<Workspace>>((ref) {
Stream<List<Workspace>> allWorkspaces(Ref ref) {
return ref.watch(workspaceRepositoryProvider).watchAll(); return ref.watch(workspaceRepositoryProvider).watchAll();
} });
/// 监听指定工作空间 /// 监听指定工作空间
@riverpod final workspaceByIdProvider = StreamProvider.family<Workspace?, int>((ref, id) {
Stream<Workspace?> workspaceById(Ref ref, int id) {
return ref.watch(workspaceRepositoryProvider).watchById(id); return ref.watch(workspaceRepositoryProvider).watchById(id);
} });