diff --git a/apps/im_app/lib/app/di/user_provider.dart b/apps/im_app/lib/app/di/user_provider.dart new file mode 100644 index 0000000..9d6b95c --- /dev/null +++ b/apps/im_app/lib/app/di/user_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:im_app/domain/usecases/insert_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) { + return UserRepositoryImpl(ref.watch(storageSdkProvider)); +} + +// ── Use Cases ───────────────────────────────────────────────────────────────── + +/// 批量插入用户用例 Provider +/// +/// 封装去重 + 分批插入逻辑,ViewModel 只需传入用户列表 +final insertUsersUseCaseProvider = Provider((ref) { + return InsertUsersUseCase(userRepository: ref.read(userRepositoryProvider)); +}); + +// ── Streams ─────────────────────────────────────────────────────────────────── + +@riverpod +Stream> users(Ref ref, Set uids) { + return ref.watch(userRepositoryProvider).watchUsers(uids.toList()); +} + +@riverpod +Stream> allUsers(Ref ref) { + return ref.watch(userRepositoryProvider).watchAllUsers(); +} diff --git a/apps/im_app/lib/app/notifiers/user_notifier.dart b/apps/im_app/lib/app/notifiers/user_notifier.dart new file mode 100644 index 0000000..56ce957 --- /dev/null +++ b/apps/im_app/lib/app/notifiers/user_notifier.dart @@ -0,0 +1,69 @@ +import 'package:im_app/app/di/user_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:im_app/data/local/drift/app_database.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) +/// +/// ## 用法 +/// ```dart +/// // 监听 — 自动重建 +/// final userAsync = ref.watch(userNotifierProvider(123)); +/// +/// // 即时读取,无需 await +/// final user = ref.read(userNotifierProvider(123).notifier).current; +/// +/// // 部分更新 +/// ref.read(userNotifierProvider(123).notifier).updateFields( +/// UsersCompanion(nickname: Value('New Name')), +/// ); +/// ``` +@riverpod +class UserNotifier extends _$UserNotifier { + User? _cached; + + UserRepository get _repo => ref.watch(userRepositoryProvider); + + @override + Future build(int uid) async { + ref.onDispose(() => _cached = null); + + // Stream starts automatically — uid is the family arg + _repo.watchUser(uid).listen((user) { + _cached = user; + state = AsyncData(user); + }); + + // Return initial DB value + return _repo.getUser(uid); + } + + // ── 即时访问,无需 await ────────────────────────────────────────────────── + + User? get current => _cached; + + // ── 写入 ───────────────────────────────────────────────────────────────── + + Future saveUser(User user) async { + await _repo.saveUser(user); + } + + Future updateFields(UsersCompanion companion) async { + await _repo.updateFields(uid, companion); // uid from build arg + } + + Future updateUser(User user) async { + await _repo.saveUser(user); + } + + // ── 删除 ───────────────────────────────────────────────────────────────── + + Future deleteUser() async { + await _repo.deleteUser(uid); + _cached = null; + state = const AsyncData(null); + } +} diff --git a/apps/im_app/lib/data/models/user_dto.dart b/apps/im_app/lib/data/models/user_dto.dart deleted file mode 100644 index b47ae3c..0000000 --- a/apps/im_app/lib/data/models/user_dto.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:im_app/data/local/drift/app_database.dart'; -import 'package:im_app/domain/entities/user.dart'; - -/// 用户 DTO(Data Transfer Object) -/// -/// local / remote 共用的数据传输对象,放在 data/models/。 -/// 提供与 Domain Entity [User] 之间的双向转换。 -class UserDto { - final int uid; - final String? uuid; - final int? lastOnline; - final String? profilePic; - final String? profilePicGaussian; - final String? nickname; - final String? contact; - final String? countryCode; - final String? email; - final String? recoveryEmail; - final String? username; - final String? bio; - final int? relationship; - final String? userAlias; - final int? channelId; - final int? channelGroupId; - final String? hint; - - const UserDto({ - required this.uid, - this.uuid, - this.lastOnline, - this.profilePic, - this.profilePicGaussian, - this.nickname, - this.contact, - this.countryCode, - this.email, - this.recoveryEmail, - this.username, - this.bio, - this.relationship, - this.userAlias, - this.channelId, - this.channelGroupId, - this.hint, - }); - - factory UserDto.fromJson(Map json) => UserDto( - uid: json['uid'] as int, - uuid: json['uuid'], - lastOnline: json['last_online'], - profilePic: json['profile_pic'], - profilePicGaussian: json['profile_pic_gaussian'] ?? '', - nickname: json['nickname'], - contact: json['contact'], - countryCode: json['country_code'], - email: json['email'], - recoveryEmail: json['recovery_email'] ?? '', - username: json['username'], - bio: json['bio'] ?? '', - relationship: json['relationship'], - userAlias: json['user_alias'], - channelId: json['channel_id'], - channelGroupId: json['channel_group_id'], - hint: json['hint'], - ); - - Map toJson() => { - 'uid': uid, - 'uuid': uuid, - 'last_online': lastOnline, - 'profile_pic': profilePic, - 'profile_pic_gaussian': profilePicGaussian, - 'nickname': nickname, - 'contact': contact, - 'country_code': countryCode, - 'email': email, - 'recovery_email': recoveryEmail, - 'username': username, - 'bio': bio, - 'relationship': relationship, - 'user_alias': userAlias, - 'channel_id': channelId, - 'channel_group_id': channelGroupId, - 'hint': hint, - }; - - /// DTO → Domain Entity - User toEntity() => User( - uid: uid, - uuid: uuid, - lastOnline: lastOnline, - profilePic: profilePic, - profilePicGaussian: profilePicGaussian, - nickname: nickname, - contact: contact, - countryCode: countryCode, - email: email, - recoveryEmail: recoveryEmail, - username: username, - bio: bio, - relationship: relationship, - userAlias: userAlias, - channelId: channelId, - channelGroupId: channelGroupId, - hint: hint, - ); - - /// Domain Entity → DTO - factory UserDto.fromEntity(User user) => UserDto( - uid: user.uid, - uuid: user.uuid, - lastOnline: user.lastOnline, - profilePic: user.profilePic, - profilePicGaussian: user.profilePicGaussian, - nickname: user.nickname, - contact: user.contact, - countryCode: user.countryCode, - email: user.email, - recoveryEmail: user.recoveryEmail, - username: user.username, - bio: user.bio, - relationship: user.relationship, - userAlias: user.userAlias, - channelId: user.channelId, - channelGroupId: user.channelGroupId, - hint: user.hint, - ); - - /// DTO → Drift Companion (for DB insert/update) - UsersCompanion toCompanion() => UsersCompanion( - uid: Value(uid), - uuid: Value(uuid), - lastOnline: Value(lastOnline), - profilePic: Value(profilePic), - profilePicGaussian: Value(profilePicGaussian ?? ''), - nickname: Value(nickname), - contact: Value(contact), - countryCode: Value(countryCode), - email: Value(email), - recoveryEmail: Value(recoveryEmail), - username: Value(username), - bio: Value(bio), - relationship: Value(relationship), - userAlias: Value(userAlias), - hint: Value(hint), - ); -} diff --git a/apps/im_app/lib/data/remote/get_profile_request.dart b/apps/im_app/lib/data/remote/get_profile_request.dart index 5030fec..c146970 100644 --- a/apps/im_app/lib/data/remote/get_profile_request.dart +++ b/apps/im_app/lib/data/remote/get_profile_request.dart @@ -91,8 +91,6 @@ class ProfileResponse { bio: bio, relationship: relationship, userAlias: userAlias, - channelId: channelId, - channelGroupId: channelGroupId, hint: hint, ); } diff --git a/apps/im_app/lib/data/remote/login_request.dart b/apps/im_app/lib/data/remote/login_request.dart index b8b18a7..eaacb11 100644 --- a/apps/im_app/lib/data/remote/login_request.dart +++ b/apps/im_app/lib/data/remote/login_request.dart @@ -88,8 +88,6 @@ class LoginProfile { bio: bio, relationship: relationship, userAlias: userAlias, - channelId: channelId, - channelGroupId: channelGroupId, hint: hint, ); } diff --git a/apps/im_app/lib/data/repositories/user_repository_impl.dart b/apps/im_app/lib/data/repositories/user_repository_impl.dart new file mode 100644 index 0000000..10fbc74 --- /dev/null +++ b/apps/im_app/lib/data/repositories/user_repository_impl.dart @@ -0,0 +1,238 @@ +import 'package:drift/drift.dart'; +import 'package:im_app/data/local/drift/app_database.dart'; +import 'package:im_app/domain/entities/user.dart'; +import 'package:im_app/domain/repositories/user_repository.dart'; +import 'package:storage_sdk/storage_sdk.dart'; + +/// 用户仓储实现 +/// +/// ## 职责 +/// - 所有 DB 操作通过 [StorageSdkApi],不直接接触 AppDatabase +/// - DriftUser ↔ Domain User 映射 +/// - 持久化决策由调用方决定 +/// +/// ## 数据流 +/// ``` +/// 网络:User.fromJson(json) → (可选) saveUser(user) → StorageSdkApi → DB +/// 监听:StorageSdkApi.watchWhere/watchAll → DriftUser → _toEntity() → UI +/// 部分更新:updateFields(uid, UsersCompanion) → StorageSdkApi.updateWhere +/// ``` +class UserRepositoryImpl implements UserRepository { + final StorageSdkApi _storage; + + UserRepositoryImpl(this._storage); + + // ── DB row → Domain ────────────────────────────────────────────────────── + + User _toEntity(DriftUser row) => User( + uid: row.uid, + uuid: row.uuid, + lastOnline: row.lastOnline, + profilePic: row.profilePic, + profilePicGaussian: row.profilePicGaussian, + nickname: row.nickname, + depositName: row.depositName, + hasSetDepositName: row.hasSetDepositName, + contact: row.contact, + countryCode: row.countryCode, + username: row.username, + role: row.role, + relationship: row.relationship, + friendStatus: row.friendStatus, + bio: row.bio, + userAlias: row.userAlias, + requestAt: row.requestAt, + deletedAt: row.deletedAt, + email: row.email, + recoveryEmail: row.recoveryEmail, + remark: row.remark, + source: row.source, + addIndex: row.addIndex, + incomingSoundId: row.incomingSoundId, + outgoingSoundId: row.outgoingSoundId, + notificationSoundId: row.notificationSoundId, + sendMessageSoundId: row.sendMessageSoundId, + groupNotificationSoundId: row.groupNotificationSoundId, + groupTags: row.groupTags, + friendTags: row.friendTags, + publicKey: row.publicKey, + configBits: row.configBits, + hint: row.hint, + ); + + // ── Domain → DB companion ──────────────────────────────────────────────── + + UsersCompanion _toCompanion(User user) => UsersCompanion( + uid: Value(user.uid), + uuid: Value(user.uuid), + lastOnline: Value(user.lastOnline), + profilePic: Value(user.profilePic), + profilePicGaussian: Value(user.profilePicGaussian ?? ''), + nickname: Value(user.nickname), + depositName: Value(user.depositName), + hasSetDepositName: Value(user.hasSetDepositName ?? 0), + contact: Value(user.contact), + countryCode: Value(user.countryCode), + username: Value(user.username), + role: Value(user.role), + relationship: Value(user.relationship), + friendStatus: Value(user.friendStatus), + bio: Value(user.bio), + userAlias: Value(user.userAlias), + requestAt: Value(user.requestAt), + deletedAt: Value(user.deletedAt), + email: Value(user.email), + recoveryEmail: Value(user.recoveryEmail), + remark: Value(user.remark), + source: Value(user.source), + addIndex: Value(user.addIndex), + incomingSoundId: Value(user.incomingSoundId ?? 0), + outgoingSoundId: Value(user.outgoingSoundId ?? 0), + notificationSoundId: Value(user.notificationSoundId ?? 0), + sendMessageSoundId: Value(user.sendMessageSoundId ?? 0), + groupNotificationSoundId: Value(user.groupNotificationSoundId ?? 0), + groupTags: Value(user.groupTags ?? '[]'), + friendTags: Value(user.friendTags ?? '[]'), + publicKey: Value(user.publicKey), + configBits: Value(user.configBits ?? 0), + hint: Value(user.hint), + ); + + // ── 监听 ───────────────────────────────────────────────────────────────── + + /// 监听单个用户 + @override + Stream watchUser(int uid) { + return _storage + .watchFirst((t) => t.uid.equals(uid)) + .map((row) => row != null ? _toEntity(row) : null); + } + + /// 监听指定 uid 列表 + @override + Stream> watchUsers(List uids) { + return _storage + .watchWhere((t) => t.uid.isIn(uids)) + .map((rows) => rows.map(_toEntity).toList()); + } + + /// 监听所有用户 + @override + Stream> watchAllUsers() { + return _storage.watchAll().map( + (rows) => rows.map(_toEntity).toList(), + ); + } + + // ── 读取 ───────────────────────────────────────────────────────────────── + + @override + Future getUser(int uid) async { + final row = await _storage.selectFirst( + (t) => t.uid.equals(uid), + ); + return row != null ? _toEntity(row) : null; + } + + @override + Future> getAllUsers() async { + final rows = await _storage.selectAll(); + return rows.map(_toEntity).toList(); + } + + @override + Future> getUsers({required int offset, required int limit}) async { + final rows = await _storage.rawQuery( + 'SELECT * FROM user ORDER BY id LIMIT ? OFFSET ?', + [limit, offset], + ); + return rows + .map( + (row) => User( + uid: row.read('uid'), + uuid: row.readNullable('uuid'), + lastOnline: row.readNullable('last_online'), + profilePic: row.readNullable('profile_pic'), + profilePicGaussian: row.readNullable( + 'profile_pic_gaussian', + ), + nickname: row.readNullable('nickname'), + depositName: row.readNullable('deposit_name'), + hasSetDepositName: row.readNullable('has_set_deposit_name'), + contact: row.readNullable('contact'), + countryCode: row.readNullable('country_code'), + username: row.readNullable('username'), + role: row.readNullable('role'), + relationship: row.readNullable('relationship'), + friendStatus: row.readNullable('friend_status'), + bio: row.readNullable('bio'), + userAlias: row.readNullable('user_alias'), + requestAt: row.readNullable('request_at'), + deletedAt: row.readNullable('deleted_at'), + email: row.readNullable('email'), + recoveryEmail: row.readNullable('recovery_email'), + remark: row.readNullable('remark'), + source: row.readNullable('source'), + addIndex: row.readNullable('add_index'), + incomingSoundId: row.readNullable('incoming_sound_id'), + outgoingSoundId: row.readNullable('outgoing_sound_id'), + notificationSoundId: row.readNullable('notification_sound_id'), + sendMessageSoundId: row.readNullable('send_message_sound_id'), + groupNotificationSoundId: row.readNullable( + 'group_notification_sound_id', + ), + groupTags: row.readNullable('group_tags'), + friendTags: row.readNullable('friend_tags'), + publicKey: row.readNullable('public_key'), + configBits: row.readNullable('config_bits'), + hint: row.readNullable('hint'), + ), + ) + .toList(); + } + + @override + Future countUsers() async { + return _storage.count(); + } + + // ── 写入 ───────────────────────────────────────────────────────────────── + + @override + Future saveUser(User user) async { + await _storage.insertOrReplace(_toCompanion(user)); + } + + @override + Future saveUsers(List users) async { + await _storage.batchInsertOrReplace( + users.map(_toCompanion).toList(), + ); + } + + /// 仅更新指定列,其他列不变 + /// + /// 示例: + /// ```dart + /// await userRepo.updateFields(uid, UsersCompanion( + /// nickname: Value('New Name'), + /// lastOnline: Value(DateTime.now().millisecondsSinceEpoch), + /// )); + /// ``` + @override + Future updateFields(int uid, UsersCompanion companion) async { + await _storage.updateWhere( + companion, + (t) => t.uid.equals(uid), + ); + } + + // ── 删除 ───────────────────────────────────────────────────────────────── + + @override + Future deleteUser(int uid) async { + await _storage.deleteWhere( + (t) => t.uid.equals(uid), + ); + } +} diff --git a/apps/im_app/lib/domain/entities/user.dart b/apps/im_app/lib/domain/entities/user.dart index a801c1c..7e29144 100644 --- a/apps/im_app/lib/domain/entities/user.dart +++ b/apps/im_app/lib/domain/entities/user.dart @@ -3,13 +3,12 @@ /// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。 /// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。 /// -/// ## 数据流位置 -/// +/// ## 数据流 /// ``` /// 服务端 JSON -/// → LoginData(Response DTO,data/remote/login_request.dart) -/// → LoginData.toEntity() -/// → ★ User ★ ← 你在这里 +/// → User.fromJson() ← 直接从网络创建 +/// → ★ User ★ ← 你在这里 +/// → userRepo.saveUser(user) ← 可选持久化 /// → ViewModel.state /// → View 渲染 /// ``` @@ -20,16 +19,32 @@ class User { final String? profilePic; final String? profilePicGaussian; final String? nickname; + final String? depositName; + final int? hasSetDepositName; final String? contact; final String? countryCode; + final String? username; + final int? role; + final int? relationship; + final int? friendStatus; + final String? bio; + final String? userAlias; + final int? requestAt; + final int? deletedAt; final String? email; final String? recoveryEmail; - final String? username; - final String? bio; - final int? relationship; - final String? userAlias; - final int? channelId; - final int? channelGroupId; + final String? remark; + final String? source; + final int? addIndex; + final int? incomingSoundId; + final int? outgoingSoundId; + final int? notificationSoundId; + final int? sendMessageSoundId; + final int? groupNotificationSoundId; + final String? groupTags; + final String? friendTags; + final String? publicKey; + final int? configBits; final String? hint; const User({ @@ -39,19 +54,73 @@ class User { this.profilePic, this.profilePicGaussian, this.nickname, + this.depositName, + this.hasSetDepositName, this.contact, this.countryCode, + this.username, + this.role, + this.relationship, + this.friendStatus, + this.bio, + this.userAlias, + this.requestAt, + this.deletedAt, this.email, this.recoveryEmail, - this.username, - this.bio, - this.relationship, - this.userAlias, - this.channelId, - this.channelGroupId, + this.remark, + this.source, + this.addIndex, + this.incomingSoundId, + this.outgoingSoundId, + this.notificationSoundId, + this.sendMessageSoundId, + this.groupNotificationSoundId, + this.groupTags, + this.friendTags, + this.publicKey, + this.configBits, this.hint, }); + /// 直接从网络 JSON 创建 Domain 实体 + factory User.fromJson(Map json) => User( + uid: json['uid'] as int, + uuid: json['uuid'], + lastOnline: json['last_online'], + profilePic: json['profile_pic'], + profilePicGaussian: json['profile_pic_gaussian'], + nickname: json['nickname'], + depositName: json['deposit_name'], + hasSetDepositName: json['has_set_deposit_name'], + contact: json['contact'], + countryCode: json['country_code'], + username: json['username'], + role: json['role'], + relationship: json['relationship'], + friendStatus: json['friend_status'], + bio: json['bio'], + userAlias: json['user_alias'], + requestAt: json['request_at'], + deletedAt: json['deleted_at'], + email: json['email'], + recoveryEmail: json['recovery_email'], + remark: json['remark'], + source: json['source'], + addIndex: json['__add_index'], + incomingSoundId: json['incoming_sound_id'], + outgoingSoundId: json['outgoing_sound_id'], + notificationSoundId: json['notification_sound_id'], + sendMessageSoundId: json['send_message_sound_id'], + groupNotificationSoundId: json['group_notification_sound_id'], + groupTags: json['group_tags'], + friendTags: json['friend_tags'], + publicKey: json['public_key'], + configBits: json['config_bits'], + hint: json['hint'], + ); + + /// 仅更新部分字段 User copyWith({ int? uid, String? uuid, @@ -59,16 +128,32 @@ class User { String? profilePic, String? profilePicGaussian, String? nickname, + String? depositName, + int? hasSetDepositName, String? contact, String? countryCode, + String? username, + int? role, + int? relationship, + int? friendStatus, + String? bio, + String? userAlias, + int? requestAt, + int? deletedAt, String? email, String? recoveryEmail, - String? username, - String? bio, - int? relationship, - String? userAlias, - int? channelId, - int? channelGroupId, + String? remark, + String? source, + int? addIndex, + int? incomingSoundId, + int? outgoingSoundId, + int? notificationSoundId, + int? sendMessageSoundId, + int? groupNotificationSoundId, + String? groupTags, + String? friendTags, + String? publicKey, + int? configBits, String? hint, }) { return User( @@ -78,16 +163,33 @@ class User { profilePic: profilePic ?? this.profilePic, profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian, nickname: nickname ?? this.nickname, + depositName: depositName ?? this.depositName, + hasSetDepositName: hasSetDepositName ?? this.hasSetDepositName, contact: contact ?? this.contact, countryCode: countryCode ?? this.countryCode, + username: username ?? this.username, + role: role ?? this.role, + relationship: relationship ?? this.relationship, + friendStatus: friendStatus ?? this.friendStatus, + bio: bio ?? this.bio, + userAlias: userAlias ?? this.userAlias, + requestAt: requestAt ?? this.requestAt, + deletedAt: deletedAt ?? this.deletedAt, email: email ?? this.email, recoveryEmail: recoveryEmail ?? this.recoveryEmail, - username: username ?? this.username, - bio: bio ?? this.bio, - relationship: relationship ?? this.relationship, - userAlias: userAlias ?? this.userAlias, - channelId: channelId ?? this.channelId, - channelGroupId: channelGroupId ?? this.channelGroupId, + remark: remark ?? this.remark, + source: source ?? this.source, + addIndex: addIndex ?? this.addIndex, + incomingSoundId: incomingSoundId ?? this.incomingSoundId, + outgoingSoundId: outgoingSoundId ?? this.outgoingSoundId, + notificationSoundId: notificationSoundId ?? this.notificationSoundId, + sendMessageSoundId: sendMessageSoundId ?? this.sendMessageSoundId, + groupNotificationSoundId: + groupNotificationSoundId ?? this.groupNotificationSoundId, + groupTags: groupTags ?? this.groupTags, + friendTags: friendTags ?? this.friendTags, + publicKey: publicKey ?? this.publicKey, + configBits: configBits ?? this.configBits, hint: hint ?? this.hint, ); } diff --git a/apps/im_app/lib/domain/repositories/user_repository.dart b/apps/im_app/lib/domain/repositories/user_repository.dart new file mode 100644 index 0000000..3fa4571 --- /dev/null +++ b/apps/im_app/lib/domain/repositories/user_repository.dart @@ -0,0 +1,78 @@ +import 'package:im_app/data/local/drift/app_database.dart'; +import 'package:im_app/domain/entities/user.dart'; + +/// 用户仓储接口 +/// +/// ## 职责 +/// - StorageSdkApi ↔ Domain User 映射 +/// - CRUD 操作(通过 StorageSdkApi,不直接接触 DB) +/// - 实时监听(单个 / 多个 / 全部) +/// +/// ## 数据流 +/// ``` +/// 写入:Domain User → _toCompanion() → StorageSdkApi → DB +/// 读取:DB row (DriftUser) → _toEntity() → Domain User +/// 监听:DB 变化 → stream → Domain User → UI +/// ``` +abstract class UserRepository { + // ── 监听 ───────────────────────────────────────────────────────────────── + + /// 监听单个用户,DB 变化自动反映 + Stream watchUser(int uid); + + /// 监听指定 uid 列表,任一变化自动反映 + Stream> watchUsers(List uids); + + /// 监听所有用户,任一变化自动反映 + Stream> watchAllUsers(); + + // ── 读取 ───────────────────────────────────────────────────────────────── + + /// 从 DB 读取单个用户,不存在返回 null + Future getUser(int uid); + + /// 从 DB 读取所有用户 + Future> getAllUsers(); + + /// 分页读取用户 + /// + /// [offset] 起始偏移量 + /// [limit] 每页数量 + /// + /// 示例: + /// ```dart + /// // 第一页 + /// final page1 = await repo.getUsers(offset: 0, limit: 50); + /// // 第二页 + /// final page2 = await repo.getUsers(offset: 50, limit: 50); + /// ``` + Future> getUsers({required int offset, required int limit}); + + /// 统计 DB 中用户总数 + Future countUsers(); + + // ── 写入 ───────────────────────────────────────────────────────────────── + + /// 保存完整用户(insert or replace) + /// 调用方决定是否持久化 + Future saveUser(User user); + + /// 批量保存用户(insert or replace) + Future saveUsers(List users); + + /// 仅更新指定字段,不影响其他列 + /// + /// 示例: + /// ```dart + /// await repo.updateFields(uid, UsersCompanion( + /// nickname: Value('New Name'), + /// lastOnline: Value(DateTime.now().millisecondsSinceEpoch), + /// )); + /// ``` + Future updateFields(int uid, UsersCompanion companion); + + // ── 删除 ───────────────────────────────────────────────────────────────── + + /// 删除指定用户 + Future deleteUser(int uid); +} diff --git a/apps/im_app/lib/domain/usecases/insert_users_use_case.dart b/apps/im_app/lib/domain/usecases/insert_users_use_case.dart new file mode 100644 index 0000000..7223b1b --- /dev/null +++ b/apps/im_app/lib/domain/usecases/insert_users_use_case.dart @@ -0,0 +1,55 @@ +import 'package:im_app/domain/entities/user.dart'; +import 'package:im_app/domain/repositories/user_repository.dart'; + +/// 批量插入用户用例 +/// +/// ## 职责 +/// - 封装用户插入的业务规则 +/// - 去重(uid 相同时保留最后一个) +/// - 分批插入,避免单次写入过大 +/// +/// ## 数据流 +/// ``` +/// ViewModel +/// → InsertUsersUseCase.execute(users) +/// → 去重 +/// → UserRepository.saveUsers(chunk) ← 分批写入 +/// → onProgress(completed, total) ← 可选进度回调 +/// ← 实际插入数量 +/// ``` +class InsertUsersUseCase { + final UserRepository _repo; + static const _chunkSize = 200; + + InsertUsersUseCase({required UserRepository userRepository}) + : _repo = userRepository; + + /// 批量插入用户 + /// + /// [users] 要插入的用户列表 + /// [onProgress] 可选回调,每批完成后触发 + /// + /// 返回实际插入数量 + Future execute( + List users, { + void Function(int completed, int total, List chunk)? onProgress, + }) async { + if (users.isEmpty) return 0; + + final deduped = {for (final u in users) u.uid: u}.values.toList(); + final total = deduped.length; + int completed = 0; + + while (completed < total) { + final end = (completed + _chunkSize).clamp(0, total); + final chunk = deduped.sublist(completed, end); + + await _repo.saveUsers(chunk); + completed += chunk.length; + + onProgress?.call(completed, total, chunk); + } + + return completed; + } +} diff --git a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart index 4386f91..e9a60e9 100644 --- a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart @@ -1,83 +1,149 @@ import 'dart:math'; -import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; -import 'package:im_app/app/di/db_provider.dart'; -import 'package:im_app/data/local/drift/app_database.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'; -import 'chat_db_test_state.dart'; - -export 'chat_db_test_state.dart'; - part 'chat_db_test_view_model.g.dart'; +class ChatDbTestState { + final bool testStarted; + final List users; + final String currentState; + final bool hasMore; + final int currentPage; + final int totalCount; + + const ChatDbTestState({ + this.testStarted = false, + this.users = const [], + this.currentState = '', + this.hasMore = true, + this.currentPage = 0, + this.totalCount = 0, + }); + + ChatDbTestState copyWith({ + bool? testStarted, + List? users, + String? currentState, + bool? hasMore, + int? currentPage, + int? totalCount, + }) => ChatDbTestState( + testStarted: testStarted ?? this.testStarted, + users: users ?? this.users, + currentState: currentState ?? this.currentState, + hasMore: hasMore ?? this.hasMore, + currentPage: currentPage ?? this.currentPage, + totalCount: totalCount ?? this.totalCount, + ); +} + @riverpod class ChatDbTestViewModel extends _$ChatDbTestViewModel { + final _random = Random(); + bool _isTesting = false; + static const _pageSize = 50; + @override ChatDbTestState build() { - // 这里就是 onInit - final List testResults = List.generate( - 1000, - (i) => TestResult( - title: '用户 ${Random().nextInt(9999)}', - subtitle: 'uid: ${Random().nextInt(999999)}', - duration: '${Random().nextInt(500)}ms', - ), - ); - return ChatDbTestState(testResults: testResults); + Future.microtask(() => _loadNextPage(reset: true)); + return const ChatDbTestState(currentState: '加载中...'); } - // ── 操作(Demo 按钮,正式开发后随 UI 一并替换) ────────────────────────── + // ── 分页 ────────────────────────────────────────────────────────────────── - /// 切换测试状态(开始 / 停止) - void toggleDBTest() { - if (state.testStarted) { - state = state.copyWith(testStarted: false, currentState: '结束测试'); - } else { - state = state.copyWith(testStarted: true, currentState: '开始测试'); - _testDBInsert(); - } + Future _loadNextPage({bool reset = false}) async { + if (!state.hasMore && !reset) return; + + final repo = ref.read(userRepositoryProvider); + final page = reset ? 0 : state.currentPage; + final offset = page * _pageSize; + + final results = await Future.wait([ + repo.getUsers(offset: offset, limit: _pageSize), + repo.countUsers(), + ]); + + if (!ref.mounted) return; + + final newUsers = results[0] as List; + final total = results[1] as int; + + state = state.copyWith( + users: reset ? newUsers : [...state.users, ...newUsers], + currentPage: page + 1, + hasMore: newUsers.length >= _pageSize, + totalCount: total, + currentState: reset && newUsers.isEmpty ? '暂无数据' : '空闲 (共 $total 条)', + ); + } + + /// Called by ListView when reaching the end + void loadMore() { + if (!state.hasMore || state.testStarted) return; + _loadNextPage(); + } + + // ── 测试 ────────────────────────────────────────────────────────────────── + + void startDBTest(BuildContext context) { + _isTesting = true; + state = state.copyWith(testStarted: true, currentState: '开始测试'); + _testDBInsert(); + } + + void stopDBTest(BuildContext context) { + _isTesting = false; + state = state.copyWith(testStarted: false, currentState: '结束测试'); } Future _testDBInsert() async { - final db = ref.read(storageSdkProvider); + final useCase = ref.read(insertUsersUseCaseProvider); + final repo = ref.read(userRepositoryProvider); const count = 10000; - const chunkSize = 50; final stopwatch = Stopwatch()..start(); + final baseUid = DateTime.now().microsecondsSinceEpoch; - debugPrint('开始测试: $count 条,每批 $chunkSize 条'); + debugPrint('开始测试: $count 条'); - int completed = 0; + final workingList = List.from(state.users); - for (var i = 0; i < count; i += chunkSize) { - final chunk = List.generate( - chunkSize.clamp(0, count - i), - (j) => - UsersCompanion.insert(uid: i + j, nickname: Value('User ${i + j}')), - ); + final allUsers = List.generate( + count, + (i) => User(uid: baseUid + i, nickname: 'User ${_random.nextInt(9999)}'), + ); - await db.batchInsertOrReplace(chunk); - completed += chunk.length; + await useCase.execute( + allUsers, + onProgress: (completed, total, chunk) { + workingList.addAll(chunk); - // 让出主线程 - await Future.delayed(Duration.zero); + debugPrint( + '已完成: $completed / $total (${stopwatch.elapsedMilliseconds}ms)', + ); - debugPrint( - '已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)', - ); + if (ref.mounted && _isTesting) { + state = state.copyWith( + users: List.unmodifiable(workingList), + currentState: '已插入 $completed / $total 条', + ); + } + }, + ); - // 更新 UI 状态 - if (ref.mounted) { - state = state.copyWith(currentState: '已插入 $completed / $count 条'); - } - } + _isTesting = false; + final elapsed = stopwatch.elapsedMilliseconds; + debugPrint('全部完成: ${elapsed}ms'); - debugPrint('全部完成: ${stopwatch.elapsedMilliseconds}ms'); if (ref.mounted) { + final total = await repo.countUsers(); state = state.copyWith( testStarted: false, - currentState: '完成!共 $count 条,耗时 ${stopwatch.elapsedMilliseconds}ms', + totalCount: total, + currentState: '完成!共 $total 条,耗时 ${elapsed}ms', ); } } diff --git a/apps/im_app/lib/features/chat/usecases/insert_users_use_case.dart b/apps/im_app/lib/features/chat/usecases/insert_users_use_case.dart new file mode 100644 index 0000000..7223b1b --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/insert_users_use_case.dart @@ -0,0 +1,55 @@ +import 'package:im_app/domain/entities/user.dart'; +import 'package:im_app/domain/repositories/user_repository.dart'; + +/// 批量插入用户用例 +/// +/// ## 职责 +/// - 封装用户插入的业务规则 +/// - 去重(uid 相同时保留最后一个) +/// - 分批插入,避免单次写入过大 +/// +/// ## 数据流 +/// ``` +/// ViewModel +/// → InsertUsersUseCase.execute(users) +/// → 去重 +/// → UserRepository.saveUsers(chunk) ← 分批写入 +/// → onProgress(completed, total) ← 可选进度回调 +/// ← 实际插入数量 +/// ``` +class InsertUsersUseCase { + final UserRepository _repo; + static const _chunkSize = 200; + + InsertUsersUseCase({required UserRepository userRepository}) + : _repo = userRepository; + + /// 批量插入用户 + /// + /// [users] 要插入的用户列表 + /// [onProgress] 可选回调,每批完成后触发 + /// + /// 返回实际插入数量 + Future execute( + List users, { + void Function(int completed, int total, List chunk)? onProgress, + }) async { + if (users.isEmpty) return 0; + + final deduped = {for (final u in users) u.uid: u}.values.toList(); + final total = deduped.length; + int completed = 0; + + while (completed < total) { + final end = (completed + _chunkSize).clamp(0, total); + final chunk = deduped.sublist(completed, end); + + await _repo.saveUsers(chunk); + completed += chunk.length; + + onProgress?.call(completed, total, chunk); + } + + return completed; + } +} diff --git a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart index 3c8c025..fe3f90f 100644 --- a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart @@ -4,53 +4,89 @@ import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart'; import '../../../core/ui/components/app_button.dart'; -/// 数据库性能测试页(Demo) -/// -/// 批量插入 10000 条用户记录,验证 Drift 批量写入性能。 -/// 所有操作通过 [ChatDbTestViewModel] 处理,View 只负责渲染。 -/// 正式开发后此页面将被删除。 -class ChatDbTestPage extends ConsumerWidget { +class ChatDbTestPage extends ConsumerStatefulWidget { const ChatDbTestPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ChatDbTestPageState(); +} + +class _ChatDbTestPageState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 300) { + ref.read(chatDbTestViewModelProvider.notifier).loadMore(); + } + } + + @override + Widget build(BuildContext context) { final vm = ref.read(chatDbTestViewModelProvider.notifier); - final state = ref.watch(chatDbTestViewModelProvider); + final testStarted = ref.watch( + chatDbTestViewModelProvider.select((s) => s.testStarted), + ); + final currentState = ref.watch( + chatDbTestViewModelProvider.select((s) => s.currentState), + ); + final users = ref.watch(chatDbTestViewModelProvider.select((s) => s.users)); + final hasMore = ref.watch( + chatDbTestViewModelProvider.select((s) => s.hasMore), + ); + final totalCount = ref.watch( + chatDbTestViewModelProvider.select((s) => s.totalCount), + ); return Scaffold( - appBar: AppBar(title: const Text('测试数据库')), + appBar: AppBar(title: Text('测试数据库 ($totalCount)')), body: Column( mainAxisSize: MainAxisSize.max, spacing: 16, children: [ - SizedBox(height: 4), + const SizedBox(height: 4), Padding( - padding: EdgeInsetsGeometry.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ AppButton.inverse( - label: state.buttonLabel, - onPressed: () => vm.toggleDBTest(), - ), - SizedBox(width: 8), - Expanded( - child: Text(state.currentState, textAlign: TextAlign.end), + label: testStarted ? '结束' : '开始', + onPressed: () => testStarted + ? vm.stopDBTest(context) + : vm.startDBTest(context), ), + const SizedBox(width: 8), + Expanded(child: Text(currentState, textAlign: TextAlign.end)), ], ), ), Expanded( child: ListView.builder( - itemCount: state.testResults.length, + controller: _scrollController, + itemCount: users.length + (hasMore ? 1 : 0), + cacheExtent: 500, itemBuilder: (context, index) { - final result = state.testResults[index]; - return ListTile( - titleAlignment: ListTileTitleAlignment.center, - title: Text(result.title), - subtitle: Text(result.subtitle), - // trailing: Text(result.duration), - ); + if (index == users.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final user = users[index]; + return _UserTile(uid: user.uid, nickname: user.nickname); }, ), ), @@ -59,3 +95,19 @@ class ChatDbTestPage extends ConsumerWidget { ); } } + +class _UserTile extends StatelessWidget { + final int uid; + final String? nickname; + + const _UserTile({required this.uid, required this.nickname}); + + @override + Widget build(BuildContext context) { + return ListTile( + titleAlignment: ListTileTitleAlignment.center, + title: Text(nickname ?? '-'), + subtitle: Text('uid: $uid'), + ); + } +} diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart index 2cf2b45..3ae37a0 100644 --- a/apps/im_app/lib/features/login/presentation/login_view_model.dart +++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:im_app/app/di/db_provider.dart'; -import 'package:im_app/data/models/user_dto.dart'; +import 'package:im_app/app/di/user_provider.dart'; import 'package:im_app/domain/entities/user.dart'; import 'package:networks_sdk/networks_sdk.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -62,11 +62,12 @@ class LoginViewModel extends _$LoginViewModel { // 防止连点重入:第一次调用未完成前忽略后续调用 if (state.isLoading) return; state = state.copyWith(isLoading: true, error: null); + final storageApi = ref.read(storageSdkProvider); + final storageLifeCycle = storageApi as StorageSdkLifecycle; + final repositoryProvider = ref.read(userRepositoryProvider); + final provider = ref.read(authNotifierProvider); try { - final storageApi = ref.read(storageSdkProvider); - final storageLifeCycle = storageApi as StorageSdkLifecycle; - // 读取 mock 数据(loginData.json 结构: { code, message, data: {...} }) // 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为 final raw = await rootBundle.loadString('assets/loginData.json'); @@ -90,23 +91,18 @@ class LoginViewModel extends _$LoginViewModel { bio: profile['bio'] as String, relationship: profile['relationship'] as int, userAlias: profile['user_alias'] as String?, - channelId: profile['channel_id'] as int, - channelGroupId: profile['channel_group_id'] as int, hint: profile['hint'] as String, ); // 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录) await storageLifeCycle.openDatabase(user.uid); - final userCompanion = UserDto.fromEntity(user).toCompanion(); - await storageApi.insertOrReplace(userCompanion); + // Save user to DB via repository + await repositoryProvider.saveUser(user); - // 全部成功后再更新登录状态,触发路由守卫重定向 - // 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state - if (!ref.mounted) return; - ref.read(authNotifierProvider).login(); + // Trigger auth state + provider.login(); } catch (e) { // 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state - if (!ref.mounted) return; state = state.copyWith(error: e.toString(), isLoading: false); } } @@ -118,25 +114,19 @@ class LoginViewModel extends _$LoginViewModel { /// 3. 成功:写入 user;失败:写入 error Future login(String email, String password) async { state = state.copyWith(isLoading: true, error: null); + final provider = ref.read(loginUseCaseProvider); try { - final user = await ref - .read(loginUseCaseProvider) - .execute(email: email, password: password); - - if (!ref.mounted) return; + final user = await provider.execute(email: email, password: password); state = state.copyWith(user: user, isLoading: false); } on FormatException catch (e) { // 格式校验失败(UseCase 层抛出) - if (!ref.mounted) return; state = state.copyWith(error: e.message, isLoading: false); } on ApiError catch (e) { // 网络 / 服务端错误(Repository → SDK 透传) - if (!ref.mounted) return; state = state.copyWith(error: e.displayMessage, isLoading: false); } catch (e) { // 兜底:防止未预期的异常导致 isLoading 死锁 - if (!ref.mounted) return; state = state.copyWith(error: e.toString(), isLoading: false); } }