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:
@@ -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<ApiRetryRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有重试任务
|
||||
@riverpod
|
||||
Stream<List<ApiRetry>> allApiRetries(Ref ref) {
|
||||
final allApiRetriesProvider = StreamProvider<List<ApiRetry>>((ref) {
|
||||
return ref.watch(apiRetryRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听未同步的重试任务
|
||||
@riverpod
|
||||
Stream<List<ApiRetry>> pendingApiRetries(Ref ref) {
|
||||
final pendingApiRetriesProvider = StreamProvider<List<ApiRetry>>((ref) {
|
||||
return ref.watch(apiRetryRepositoryProvider).watchPending();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<SoundRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有音效
|
||||
@riverpod
|
||||
Stream<List<Sound>> allSounds(Ref ref) {
|
||||
final allSoundsProvider = StreamProvider<List<Sound>>((ref) {
|
||||
return ref.watch(soundRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定类型音效
|
||||
@riverpod
|
||||
Stream<List<Sound>> soundsByType(Ref ref, int typ) {
|
||||
final soundsByTypeProvider = StreamProvider.family<List<Sound>, int>((ref, typ) {
|
||||
return ref.watch(soundRepositoryProvider).watchByType(typ);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定音效
|
||||
@riverpod
|
||||
Stream<Sound?> soundById(Ref ref, int id) {
|
||||
final soundByIdProvider = StreamProvider.family<Sound?, int>((ref, id) {
|
||||
return ref.watch(soundRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<TagRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有标签
|
||||
@riverpod
|
||||
Stream<List<Tag>> allTags(Ref ref) {
|
||||
final allTagsProvider = StreamProvider<List<Tag>>((ref) {
|
||||
return ref.watch(tagRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定 uid 的标签
|
||||
@riverpod
|
||||
Stream<List<Tag>> tagsByUid(Ref ref, int uid) {
|
||||
final tagsByUidProvider = StreamProvider.family<List<Tag>, int>((ref, uid) {
|
||||
return ref.watch(tagRepositoryProvider).watchByUid(uid);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定类型的标签
|
||||
@riverpod
|
||||
Stream<List<Tag>> tagsByType(Ref ref, int type) {
|
||||
final tagsByTypeProvider = StreamProvider.family<List<Tag>, int>((ref, type) {
|
||||
return ref.watch(tagRepositoryProvider).watchByType(type);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定标签
|
||||
@riverpod
|
||||
Stream<Tag?> tagById(Ref ref, int id) {
|
||||
final tagByIdProvider = StreamProvider.family<Tag?, int>((ref, id) {
|
||||
return ref.watch(tagRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<UserRepository>((ref) {
|
||||
return UserRepositoryImpl(ref.watch(storageSdkProvider));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Use Cases ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,12 +38,10 @@ final deleteUsersUseCaseProvider = Provider<DeleteUsersUseCase>((ref) {
|
||||
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@riverpod
|
||||
Stream<List<User>> users(Ref ref, Set<int> uids) {
|
||||
final usersProvider = StreamProvider.family<List<User>, Set<int>>((ref, uids) {
|
||||
return ref.watch(userRepositoryProvider).watchUsers(uids.toList());
|
||||
}
|
||||
});
|
||||
|
||||
@riverpod
|
||||
Stream<List<User>> allUsers(Ref ref) {
|
||||
final allUsersProvider = StreamProvider<List<User>>((ref) {
|
||||
return ref.watch(userRepositoryProvider).watchAllUsers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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?, int> {
|
||||
User? _cached;
|
||||
|
||||
UserRepository get _repo => ref.watch(userRepositoryProvider);
|
||||
|
||||
@override
|
||||
Future<User?> build(int uid) async {
|
||||
Future<User?> 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<void> updateUser(User user) async {
|
||||
await _repo.updateUser(user);
|
||||
}
|
||||
|
||||
/// 批量更新用户,每条按 uid 匹配更新所有字段
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// await notifier.updateUsers(
|
||||
/// users.map((u) => u.copyWith(nickname: 'new')).toList(),
|
||||
/// );
|
||||
/// ```
|
||||
Future<void> updateUsers(List<User> users) async {
|
||||
await _repo.updateUsersBatch(users);
|
||||
}
|
||||
@@ -80,8 +65,11 @@ class UserNotifier extends _$UserNotifier {
|
||||
// ── 删除 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> deleteUser() async {
|
||||
await _repo.deleteUser(uid);
|
||||
await _repo.deleteUser(arg);
|
||||
_cached = null;
|
||||
state = const AsyncData(null);
|
||||
}
|
||||
}
|
||||
|
||||
final userNotifierProvider =
|
||||
AsyncNotifierProvider.family<UserNotifier, User?, int>(UserNotifier.new);
|
||||
|
||||
135
apps/im_app/lib/core/services/ws_message_service.dart
Normal file
135
apps/im_app/lib/core/services/ws_message_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
125
apps/im_app/lib/data/remote/fetch_history_request.dart
Normal file
125
apps/im_app/lib/data/remote/fetch_history_request.dart
Normal 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: []);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,26 @@ class ProfileResponse {
|
||||
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(
|
||||
uid: uid,
|
||||
uuid: uuid,
|
||||
|
||||
@@ -76,6 +76,26 @@ class LoginProfile {
|
||||
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(
|
||||
uid: uid,
|
||||
uuid: uuid,
|
||||
@@ -126,6 +146,17 @@ class LoginResponse {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
88
apps/im_app/lib/data/remote/send_message_request.dart
Normal file
88
apps/im_app/lib/data/remote/send_message_request.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ class SendOtpResponse {
|
||||
this.web,
|
||||
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 — 发送手机验证码
|
||||
|
||||
@@ -40,6 +40,11 @@ class UploadResult {
|
||||
final String 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? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════
|
||||
|
||||
@@ -14,6 +14,9 @@ class VerifyOtpResponse {
|
||||
final String token;
|
||||
|
||||
const VerifyOtpResponse({required this.token});
|
||||
|
||||
factory VerifyOtpResponse.fromJson(Map<String, dynamic> json) =>
|
||||
VerifyOtpResponse(token: json['token'] as String? ?? '');
|
||||
}
|
||||
|
||||
/// # /app/api/auth/vcode/check — 校验手机验证码
|
||||
|
||||
@@ -2,11 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/favorite_detail_repository_impl.dart';
|
||||
import 'package:im_app/domain/repositories/favorite_detail_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:im_app/domain/entities/favorite_detail.dart';
|
||||
|
||||
part 'favorite_detail_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 收藏详情仓储 Provider
|
||||
@@ -19,18 +16,13 @@ final favoriteDetailRepositoryProvider = Provider<FavoriteDetailRepository>((
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有收藏详情
|
||||
@riverpod
|
||||
Stream<List<FavoriteDetail>> allFavoriteDetails(Ref ref) {
|
||||
final allFavoriteDetailsProvider = StreamProvider<List<FavoriteDetail>>((ref) {
|
||||
return ref.watch(favoriteDetailRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定 relatedId 的收藏详情
|
||||
@riverpod
|
||||
Stream<List<FavoriteDetail>> favoriteDetailsByRelatedId(
|
||||
Ref ref,
|
||||
String relatedId,
|
||||
) {
|
||||
final favoriteDetailsByRelatedIdProvider = StreamProvider.family<List<FavoriteDetail>, String>((ref, relatedId) {
|
||||
return ref
|
||||
.watch(favoriteDetailRepositoryProvider)
|
||||
.watchByRelatedId(relatedId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/favorite_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/favorite.dart';
|
||||
import 'package:im_app/domain/repositories/favorite_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'favorite_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,19 +14,16 @@ final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有收藏
|
||||
@riverpod
|
||||
Stream<List<Favorite>> allFavorites(Ref ref) {
|
||||
final allFavoritesProvider = StreamProvider<List<Favorite>>((ref) {
|
||||
return ref.watch(favoriteRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定收藏
|
||||
@riverpod
|
||||
Stream<Favorite?> favoriteById(Ref ref, int id) {
|
||||
final favoriteByIdProvider = StreamProvider.family<Favorite?, int>((ref, id) {
|
||||
return ref.watch(favoriteRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定 parentId 的收藏
|
||||
@riverpod
|
||||
Stream<List<Favorite>> favoritesByParentId(Ref ref, String parentId) {
|
||||
final favoritesByParentIdProvider = StreamProvider.family<List<Favorite>, String>((ref, parentId) {
|
||||
return ref.watch(favoriteRepositoryProvider).watchByParentId(parentId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/call_log_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/call_log.dart';
|
||||
import 'package:im_app/domain/repositories/call_log_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'call_log_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final callLogRepositoryProvider = Provider<CallLogRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有通话记录
|
||||
@riverpod
|
||||
Stream<List<CallLog>> allCallLogs(Ref ref) {
|
||||
final allCallLogsProvider = StreamProvider<List<CallLog>>((ref) {
|
||||
return ref.watch(callLogRepositoryProvider).watchAllCallLogs();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定通话记录
|
||||
@riverpod
|
||||
Stream<CallLog?> callLog(Ref ref, String id) {
|
||||
final callLogProvider = StreamProvider.family<CallLog?, String>((ref, id) {
|
||||
return ref.watch(callLogRepositoryProvider).watchCallLog(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/chat_bot_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/chat_bot.dart';
|
||||
import 'package:im_app/domain/repositories/chat_bot_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_bot_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final chatBotRepositoryProvider = Provider<ChatBotRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有聊天机器人
|
||||
@riverpod
|
||||
Stream<List<ChatBot>> allChatBots(Ref ref) {
|
||||
final allChatBotsProvider = StreamProvider<List<ChatBot>>((ref) {
|
||||
return ref.watch(chatBotRepositoryProvider).watchAllChatBots();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定聊天机器人
|
||||
@riverpod
|
||||
Stream<ChatBot?> chatBot(Ref ref, int id) {
|
||||
final chatBotProvider = StreamProvider.family<ChatBot?, int>((ref, id) {
|
||||
return ref.watch(chatBotRepositoryProvider).watchChatBot(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/chat_category_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/chat_category.dart';
|
||||
import 'package:im_app/domain/repositories/chat_category_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_category_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final chatCategoryRepositoryProvider = Provider<ChatCategoryRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有聊天分类
|
||||
@riverpod
|
||||
Stream<List<ChatCategory>> allChatCategories(Ref ref) {
|
||||
final allChatCategoriesProvider = StreamProvider<List<ChatCategory>>((ref) {
|
||||
return ref.watch(chatCategoryRepositoryProvider).watchAllChatCategories();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定聊天分类
|
||||
@riverpod
|
||||
Stream<ChatCategory?> chatCategory(Ref ref, int id) {
|
||||
final chatCategoryProvider = StreamProvider.family<ChatCategory?, int>((ref, id) {
|
||||
return ref.watch(chatCategoryRepositoryProvider).watchChatCategory(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/chat_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/chat.dart';
|
||||
import 'package:im_app/domain/repositories/chat_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有聊天
|
||||
@riverpod
|
||||
Stream<List<Chat>> allChats(Ref ref) {
|
||||
final allChatsProvider = StreamProvider<List<Chat>>((ref) {
|
||||
return ref.watch(chatRepositoryProvider).watchAllChats();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定聊天
|
||||
@riverpod
|
||||
Stream<Chat?> chat(Ref ref, int id) {
|
||||
final chatProvider = StreamProvider.family<Chat?, int>((ref, id) {
|
||||
return ref.watch(chatRepositoryProvider).watchChat(id);
|
||||
}
|
||||
});
|
||||
|
||||
66
apps/im_app/lib/features/chat/di/chat_service_providers.dart
Normal file
66
apps/im_app/lib/features/chat/di/chat_service_providers.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/group_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/group.dart';
|
||||
import 'package:im_app/domain/repositories/group_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'group_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final groupRepositoryProvider = Provider<GroupRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有群组
|
||||
@riverpod
|
||||
Stream<List<Group>> allGroups(Ref ref) {
|
||||
final allGroupsProvider = StreamProvider<List<Group>>((ref) {
|
||||
return ref.watch(groupRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定群组
|
||||
@riverpod
|
||||
Stream<Group?> groupById(Ref ref, int id) {
|
||||
final groupByIdProvider = StreamProvider.family<Group?, int>((ref, id) {
|
||||
return ref.watch(groupRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/message_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/domain/repositories/message_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'message_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,13 +14,11 @@ final messageRepositoryProvider = Provider<MessageRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听指定 chatId 的消息列表
|
||||
@riverpod
|
||||
Stream<List<Message>> messagesByChatId(Ref ref, int chatId) {
|
||||
final messagesByChatIdProvider = StreamProvider.family<List<Message>, int>((ref, chatId) {
|
||||
return ref.watch(messageRepositoryProvider).watchByChatId(chatId);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定消息
|
||||
@riverpod
|
||||
Stream<Message?> messageById(Ref ref, int id) {
|
||||
final messageByIdProvider = StreamProvider.family<Message?, int>((ref, id) {
|
||||
return ref.watch(messageRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/app/di/user_provider.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_db_test_view_model.g.dart';
|
||||
|
||||
class ChatDbTestState {
|
||||
final bool testStarted;
|
||||
@@ -41,8 +39,7 @@ class ChatDbTestState {
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
||||
class ChatDbTestViewModel extends Notifier<ChatDbTestState> {
|
||||
final _random = Random();
|
||||
bool _isTesting = false;
|
||||
static const _pageSize = 50;
|
||||
@@ -81,7 +78,6 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
/// Called by ListView when reaching the end
|
||||
void loadMore() {
|
||||
if (!state.hasMore || state.testStarted) return;
|
||||
_loadNextPage();
|
||||
@@ -211,3 +207,8 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final chatDbTestViewModelProvider =
|
||||
NotifierProvider<ChatDbTestViewModel, ChatDbTestState>(
|
||||
ChatDbTestViewModel.new,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
@@ -1,38 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
part 'chat_view_model.g.dart';
|
||||
|
||||
/// 聊天页 ViewModel(@riverpod 自动生成 `chatViewModelProvider`)
|
||||
///
|
||||
/// 当前 chat 页面为 Demo,无需从服务端加载数据,状态为 void。
|
||||
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
|
||||
///
|
||||
/// ## 数据流
|
||||
///
|
||||
/// ```
|
||||
/// ChatPage
|
||||
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
|
||||
/// → ★ ChatViewModel ★ ← 你在这里
|
||||
/// → 导航 / 业务逻辑
|
||||
/// ```
|
||||
@riverpod
|
||||
class ChatViewModel extends _$ChatViewModel {
|
||||
/// 聊天页 ViewModel(手动 NotifierProvider)
|
||||
class ChatViewModel extends Notifier<void> {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
// ── 导航(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
|
||||
|
||||
/// 切换到联系人 Tab。
|
||||
void goToContact(BuildContext context) {
|
||||
context.go(AppRouteName.contact.path);
|
||||
}
|
||||
|
||||
/// 带 extra 参数 push 聊天详情页(extra 传 Dart Record)。
|
||||
void pushChatDetailWithExtra(BuildContext context) {
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
@@ -40,33 +21,26 @@ class ChatViewModel extends _$ChatViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
/// 带路径参数 push 聊天详情页(id 内嵌在 URL 中)。
|
||||
void pushChatDetailById(BuildContext context) {
|
||||
context.push(AppRouteName.chatDetailByIdPath('99'));
|
||||
}
|
||||
|
||||
/// 无参 push(演示 push 导航)。
|
||||
void pushSettingsTheme(BuildContext context) {
|
||||
context.push(AppRouteName.settingsTheme.path);
|
||||
}
|
||||
|
||||
/// 切换到设置 Tab。
|
||||
void goToSettings(BuildContext context) {
|
||||
context.go(AppRouteName.settings.path);
|
||||
}
|
||||
|
||||
/// 测试数据库性能
|
||||
void goToDatabaseTest(BuildContext context) {
|
||||
context.push(AppRouteName.chatDBTest.path);
|
||||
}
|
||||
|
||||
// ── 业务 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 退出登录
|
||||
///
|
||||
/// 调用 [AuthNotifier.logout] 清除登录状态,go_router 守卫检测到后
|
||||
/// 自动重定向到登录页,无需手动导航。
|
||||
void logout() {
|
||||
ref.read(authNotifierProvider).logout();
|
||||
}
|
||||
}
|
||||
|
||||
final chatViewModelProvider =
|
||||
NotifierProvider<ChatViewModel, void>(ChatViewModel.new);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/domain/entities/message.dart';
|
||||
import 'package:im_app/features/chat/di/message_provider.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_detail_view_model.dart';
|
||||
|
||||
/// 会话详情页(路由传参 Demo)
|
||||
/// 聊天详情页
|
||||
///
|
||||
/// 通过 go_router 的 `extra` 接收上一页传入的数据,
|
||||
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入,
|
||||
/// 本页不感知 GoRouter 任何实现细节。
|
||||
///
|
||||
/// ## 正式开发
|
||||
///
|
||||
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
|
||||
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
||||
class ChatDetailPage extends ConsumerWidget {
|
||||
/// 接收 [conversationId](chatId 字符串)和 [title](会话名称)。
|
||||
/// 通过 [ChatDetailViewModel] 监听 DB 消息 Stream,实时渲染气泡列表。
|
||||
/// 底部输入框调用 [ChatDetailViewModel.sendMessage] 发送文本消息。
|
||||
class ChatDetailPage extends ConsumerStatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
@@ -24,18 +22,236 @@ class ChatDetailPage extends ConsumerWidget {
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = context.styles;
|
||||
ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
|
||||
late final int _chatId;
|
||||
final _inputCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatId = int.tryParse(widget.conversationId) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _inputCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_inputCtrl.clear();
|
||||
ref
|
||||
.read(chatDetailViewModelProvider(_chatId).notifier)
|
||||
.sendMessage(text);
|
||||
// Scroll to bottom after a brief delay so the new message is rendered
|
||||
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollCtrl.hasClients) {
|
||||
_scrollCtrl.animateTo(
|
||||
_scrollCtrl.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = ref.watch(chatDetailViewModelProvider(_chatId).notifier);
|
||||
final state = ref.watch(chatDetailViewModelProvider(_chatId));
|
||||
final messagesAsync = ref.watch(messagesByChatIdProvider(_chatId));
|
||||
final currentUid = ref.watch(authNotifierProvider).currentUid ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
body: Column(
|
||||
children: [
|
||||
Text('会话 ID', style: s.labelMuted),
|
||||
Text(conversationId, style: s.headlineSmall),
|
||||
// ── 消息列表 ────────────────────────────────────────────────────────
|
||||
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: [
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/pending_friend_request_history_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/pending_friend_request_history.dart';
|
||||
import 'package:im_app/domain/repositories/pending_friend_request_history_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'pending_friend_request_history_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,20 +16,13 @@ final pendingFriendRequestHistoryRepositoryProvider =
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有好友请求历史
|
||||
@riverpod
|
||||
Stream<List<PendingFriendRequestHistory>> allPendingFriendRequestHistories(
|
||||
Ref ref,
|
||||
) {
|
||||
final allPendingFriendRequestHistoriesProvider = StreamProvider<List<PendingFriendRequestHistory>>((ref) {
|
||||
return ref.watch(pendingFriendRequestHistoryRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定 uid 的好友请求历史
|
||||
@riverpod
|
||||
Stream<List<PendingFriendRequestHistory>> pendingFriendRequestHistoriesByUid(
|
||||
Ref ref,
|
||||
int uid,
|
||||
) {
|
||||
final pendingFriendRequestHistoriesByUidProvider = StreamProvider.family<List<PendingFriendRequestHistory>, int>((ref, uid) {
|
||||
return ref
|
||||
.watch(pendingFriendRequestHistoryRepositoryProvider)
|
||||
.watchByUid(uid);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/user_request_history_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/user_request_history.dart';
|
||||
import 'package:im_app/domain/repositories/user_request_history_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'user_request_history_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,22 +14,16 @@ final userRequestHistoryRepositoryProvider =
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有用户请求历史
|
||||
@riverpod
|
||||
Stream<List<UserRequestHistory>> allUserRequestHistories(Ref ref) {
|
||||
final allUserRequestHistoriesProvider = StreamProvider<List<UserRequestHistory>>((ref) {
|
||||
return ref.watch(userRequestHistoryRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定状态的用户请求历史
|
||||
@riverpod
|
||||
Stream<List<UserRequestHistory>> userRequestHistoriesByStatus(
|
||||
Ref ref,
|
||||
int status,
|
||||
) {
|
||||
final userRequestHistoriesByStatusProvider = StreamProvider.family<List<UserRequestHistory>, int>((ref, status) {
|
||||
return ref.watch(userRequestHistoryRepositoryProvider).watchByStatus(status);
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定用户请求历史
|
||||
@riverpod
|
||||
Stream<UserRequestHistory?> userRequestHistoryById(Ref ref, int id) {
|
||||
final userRequestHistoryByIdProvider = StreamProvider.family<UserRequestHistory?, int>((ref, id) {
|
||||
return ref.watch(userRequestHistoryRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
final LoginStep step;
|
||||
|
||||
/// 国家代码(默认 +65,暂不支持切换)
|
||||
@Default('+65') String countryCode,
|
||||
final String countryCode;
|
||||
|
||||
/// 已提交的手机号(步骤 2 用于显示和构建请求)
|
||||
@Default('') String contact,
|
||||
final String contact;
|
||||
|
||||
/// 是否正在请求中
|
||||
@Default(false) bool isLoading,
|
||||
final bool isLoading;
|
||||
|
||||
/// 错误信息(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 {
|
||||
|
||||
@@ -1,41 +1,27 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
part 'login_view_model.g.dart';
|
||||
|
||||
/// 登录 ViewModel(@riverpod 自动生成 `loginViewModelProvider`)
|
||||
/// 登录 ViewModel(手动 NotifierProvider)
|
||||
///
|
||||
/// 管理两步登录流程:手机号 → 验证码 → 完成登录。
|
||||
///
|
||||
/// ```dart
|
||||
/// // View 层读取状态
|
||||
/// final state = ref.watch(loginViewModelProvider);
|
||||
///
|
||||
/// // View 层调用方法
|
||||
/// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
|
||||
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
|
||||
/// ```
|
||||
///
|
||||
/// ## DI 链路
|
||||
///
|
||||
/// ```
|
||||
/// loginViewModelProvider ← @riverpod 自动生成
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// loginViewModelProvider
|
||||
/// → ref.read(loginUseCaseProvider)
|
||||
/// → ref.read(authRepositoryProvider)
|
||||
/// → ref.read(networkSdkApiProvider)
|
||||
/// ```
|
||||
@riverpod
|
||||
class LoginViewModel extends _$LoginViewModel {
|
||||
class LoginViewModel extends Notifier<LoginState> {
|
||||
@override
|
||||
LoginState build() => const LoginState();
|
||||
|
||||
/// 步骤 1:发送手机验证码
|
||||
///
|
||||
/// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
|
||||
Future<void> sendOtp(String countryCode, String contact) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
@@ -65,8 +51,6 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
}
|
||||
|
||||
/// 步骤 2+3:校验验证码并完成登录
|
||||
///
|
||||
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向,provider 随即被 dispose。
|
||||
Future<void> verifyAndLogin(String code) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
@@ -80,8 +64,6 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
code: code,
|
||||
);
|
||||
|
||||
// 成功后触发路由守卫重定向。
|
||||
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state。
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login(uid: user.uid);
|
||||
} on FormatException catch (e) {
|
||||
@@ -96,8 +78,11 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回手机号输入步骤(用户想修改手机号)
|
||||
/// 返回手机号输入步骤
|
||||
void backToPhone() {
|
||||
state = state.copyWith(step: LoginStep.phone, error: null);
|
||||
}
|
||||
}
|
||||
|
||||
final loginViewModelProvider =
|
||||
NotifierProvider<LoginViewModel, LoginState>(LoginViewModel.new);
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/discover_mini_app_repository_impl.dart';
|
||||
import 'package:im_app/data/repositories/explore_mini_app_repository_impl.dart';
|
||||
import 'package:im_app/data/repositories/favorite_mini_app_repository_impl.dart';
|
||||
import 'package:im_app/data/repositories/recent_mini_app_repository_impl.dart';
|
||||
import 'package:im_app/domain/repositories/mini_app_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'mini_app_provider.g.dart';
|
||||
final discoverMiniAppRepositoryProvider = Provider<MiniAppRepository>(
|
||||
(ref) => DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
|
||||
);
|
||||
|
||||
@riverpod
|
||||
MiniAppRepository discoverMiniAppRepository(Ref ref) =>
|
||||
DiscoverMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
|
||||
final exploreMiniAppRepositoryProvider = Provider<MiniAppRepository>(
|
||||
(ref) => ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
|
||||
);
|
||||
|
||||
@riverpod
|
||||
MiniAppRepository exploreMiniAppRepository(Ref ref) =>
|
||||
ExploreMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
|
||||
final favoriteMiniAppRepositoryProvider = Provider<MiniAppRepository>(
|
||||
(ref) => FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
|
||||
);
|
||||
|
||||
@riverpod
|
||||
MiniAppRepository favoriteMiniAppRepository(Ref ref) =>
|
||||
FavoriteMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
|
||||
|
||||
@riverpod
|
||||
MiniAppRepository recentMiniAppRepository(Ref ref) =>
|
||||
RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider));
|
||||
final recentMiniAppRepositoryProvider = Provider<MiniAppRepository>(
|
||||
(ref) => RecentMiniAppRepositoryImpl(ref.watch(storageSdkProvider)),
|
||||
);
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
part 'theme_view_model.g.dart';
|
||||
|
||||
/// 主题 ViewModel
|
||||
///
|
||||
/// View 层只感知此 ViewModel,不直接依赖 app 级 Provider。
|
||||
///
|
||||
/// ## 数据流
|
||||
///
|
||||
/// ```
|
||||
/// ThemeView
|
||||
/// → ref.watch(themeViewModelProvider) ← 当前 ThemeMode
|
||||
/// → ref.read(themeViewModelProvider.notifier).setMode(mode)
|
||||
/// → ★ ThemeViewModel.setMode() ★ ← 你在这里
|
||||
/// → SetThemeUseCase.execute()
|
||||
/// → 幂等校验(相同模式直接返回)
|
||||
/// → onApply → ThemeModeNotifier.setMode() ← 更新内存状态
|
||||
/// → TODO: 持久化(storage_sdk)
|
||||
/// ```
|
||||
@riverpod
|
||||
class ThemeViewModel extends _$ThemeViewModel {
|
||||
/// 主题 ViewModel(手动 NotifierProvider)
|
||||
class ThemeViewModel extends Notifier<ThemeMode> {
|
||||
@override
|
||||
ThemeMode build() => ref.watch(themeModeProvider);
|
||||
|
||||
@@ -35,3 +17,6 @@ class ThemeViewModel extends _$ThemeViewModel {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final themeViewModelProvider =
|
||||
NotifierProvider<ThemeViewModel, ThemeMode>(ThemeViewModel.new);
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/workspace_repository_impl.dart';
|
||||
import 'package:im_app/domain/entities/workspace.dart';
|
||||
import 'package:im_app/domain/repositories/workspace_repository.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'workspace_provider.g.dart';
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,13 +13,11 @@ final workspaceRepositoryProvider = Provider<WorkspaceRepository>((ref) {
|
||||
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听所有工作空间
|
||||
@riverpod
|
||||
Stream<List<Workspace>> allWorkspaces(Ref ref) {
|
||||
final allWorkspacesProvider = StreamProvider<List<Workspace>>((ref) {
|
||||
return ref.watch(workspaceRepositoryProvider).watchAll();
|
||||
}
|
||||
});
|
||||
|
||||
/// 监听指定工作空间
|
||||
@riverpod
|
||||
Stream<Workspace?> workspaceById(Ref ref, int id) {
|
||||
final workspaceByIdProvider = StreamProvider.family<Workspace?, int>((ref, id) {
|
||||
return ref.watch(workspaceRepositoryProvider).watchById(id);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user