更新测试案例

This commit is contained in:
Happi (哈比)
2026-03-09 13:03:44 +08:00
parent 56112e1fe3
commit 7b78da86e7
11 changed files with 721 additions and 278 deletions

View File

@@ -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';
/// 用户 DTOData 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<String, dynamic> 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<String, dynamic> 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),
);
}

View File

@@ -93,8 +93,6 @@ class ProfileData {
bio: bio, bio: bio,
relationship: relationship, relationship: relationship,
userAlias: userAlias, userAlias: userAlias,
channelId: channelId,
channelGroupId: channelGroupId,
hint: hint, hint: hint,
); );
} }

View File

@@ -88,8 +88,6 @@ class LoginProfile {
bio: bio, bio: bio,
relationship: relationship, relationship: relationship,
userAlias: userAlias, userAlias: userAlias,
channelId: channelId,
channelGroupId: channelGroupId,
hint: hint, hint: hint,
); );
} }

View File

@@ -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<User?> watchUser(int uid) {
return _storage
.watchFirst<DriftUser, $UsersTable>((t) => t.uid.equals(uid))
.map((row) => row != null ? _toEntity(row) : null);
}
/// 监听指定 uid 列表
@override
Stream<List<User>> watchUsers(List<int> uids) {
return _storage
.watchWhere<DriftUser, $UsersTable>((t) => t.uid.isIn(uids))
.map((rows) => rows.map(_toEntity).toList());
}
/// 监听所有用户
@override
Stream<List<User>> watchAllUsers() {
return _storage.watchAll<DriftUser>().map(
(rows) => rows.map(_toEntity).toList(),
);
}
// ── 读取 ─────────────────────────────────────────────────────────────────
@override
Future<User?> getUser(int uid) async {
final row = await _storage.selectFirst<DriftUser, $UsersTable>(
(t) => t.uid.equals(uid),
);
return row != null ? _toEntity(row) : null;
}
@override
Future<List<User>> getAllUsers() async {
final rows = await _storage.selectAll<DriftUser>();
return rows.map(_toEntity).toList();
}
@override
Future<List<User>> 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<int>('uid'),
uuid: row.readNullable<String>('uuid'),
lastOnline: row.readNullable<int>('last_online'),
profilePic: row.readNullable<String>('profile_pic'),
profilePicGaussian: row.readNullable<String>(
'profile_pic_gaussian',
),
nickname: row.readNullable<String>('nickname'),
depositName: row.readNullable<String>('deposit_name'),
hasSetDepositName: row.readNullable<int>('has_set_deposit_name'),
contact: row.readNullable<String>('contact'),
countryCode: row.readNullable<String>('country_code'),
username: row.readNullable<String>('username'),
role: row.readNullable<int>('role'),
relationship: row.readNullable<int>('relationship'),
friendStatus: row.readNullable<int>('friend_status'),
bio: row.readNullable<String>('bio'),
userAlias: row.readNullable<String>('user_alias'),
requestAt: row.readNullable<int>('request_at'),
deletedAt: row.readNullable<int>('deleted_at'),
email: row.readNullable<String>('email'),
recoveryEmail: row.readNullable<String>('recovery_email'),
remark: row.readNullable<String>('remark'),
source: row.readNullable<String>('source'),
addIndex: row.readNullable<int>('add_index'),
incomingSoundId: row.readNullable<int>('incoming_sound_id'),
outgoingSoundId: row.readNullable<int>('outgoing_sound_id'),
notificationSoundId: row.readNullable<int>('notification_sound_id'),
sendMessageSoundId: row.readNullable<int>('send_message_sound_id'),
groupNotificationSoundId: row.readNullable<int>(
'group_notification_sound_id',
),
groupTags: row.readNullable<String>('group_tags'),
friendTags: row.readNullable<String>('friend_tags'),
publicKey: row.readNullable<String>('public_key'),
configBits: row.readNullable<int>('config_bits'),
hint: row.readNullable<String>('hint'),
),
)
.toList();
}
@override
Future<int> countUsers() async {
return _storage.count<DriftUser, $UsersTable>();
}
// ── 写入 ─────────────────────────────────────────────────────────────────
@override
Future<void> saveUser(User user) async {
await _storage.insertOrReplace<DriftUser>(_toCompanion(user));
}
@override
Future<void> saveUsers(List<User> users) async {
await _storage.batchInsertOrReplace<DriftUser>(
users.map(_toCompanion).toList(),
);
}
/// 仅更新指定列,其他列不变
///
/// 示例:
/// ```dart
/// await userRepo.updateFields(uid, UsersCompanion(
/// nickname: Value('New Name'),
/// lastOnline: Value(DateTime.now().millisecondsSinceEpoch),
/// ));
/// ```
@override
Future<void> updateFields(int uid, UsersCompanion companion) async {
await _storage.updateWhere<DriftUser, $UsersTable>(
companion,
(t) => t.uid.equals(uid),
);
}
// ── 删除 ─────────────────────────────────────────────────────────────────
@override
Future<void> deleteUser(int uid) async {
await _storage.deleteWhere<DriftUser, $UsersTable>(
(t) => t.uid.equals(uid),
);
}
}

View File

@@ -3,13 +3,12 @@
/// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。 /// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。
/// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。 /// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。
/// ///
/// ## 数据流位置 /// ## 数据流
///
/// ``` /// ```
/// 服务端 JSON /// 服务端 JSON
/// → LoginDataResponse DTOdata/remote/login_request.dart /// → User.fromJson() ← 直接从网络创建
/// → LoginData.toEntity()
/// → ★ User ★ ← 你在这里 /// → ★ User ★ ← 你在这里
/// → userRepo.saveUser(user) ← 可选持久化
/// → ViewModel.state /// → ViewModel.state
/// → View 渲染 /// → View 渲染
/// ``` /// ```
@@ -20,16 +19,32 @@ class User {
final String? profilePic; final String? profilePic;
final String? profilePicGaussian; final String? profilePicGaussian;
final String? nickname; final String? nickname;
final String? depositName;
final int? hasSetDepositName;
final String? contact; final String? contact;
final String? countryCode; 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? email;
final String? recoveryEmail; final String? recoveryEmail;
final String? username; final String? remark;
final String? bio; final String? source;
final int? relationship; final int? addIndex;
final String? userAlias; final int? incomingSoundId;
final int? channelId; final int? outgoingSoundId;
final int? channelGroupId; final int? notificationSoundId;
final int? sendMessageSoundId;
final int? groupNotificationSoundId;
final String? groupTags;
final String? friendTags;
final String? publicKey;
final int? configBits;
final String? hint; final String? hint;
const User({ const User({
@@ -39,19 +54,73 @@ class User {
this.profilePic, this.profilePic,
this.profilePicGaussian, this.profilePicGaussian,
this.nickname, this.nickname,
this.depositName,
this.hasSetDepositName,
this.contact, this.contact,
this.countryCode, this.countryCode,
this.username,
this.role,
this.relationship,
this.friendStatus,
this.bio,
this.userAlias,
this.requestAt,
this.deletedAt,
this.email, this.email,
this.recoveryEmail, this.recoveryEmail,
this.username, this.remark,
this.bio, this.source,
this.relationship, this.addIndex,
this.userAlias, this.incomingSoundId,
this.channelId, this.outgoingSoundId,
this.channelGroupId, this.notificationSoundId,
this.sendMessageSoundId,
this.groupNotificationSoundId,
this.groupTags,
this.friendTags,
this.publicKey,
this.configBits,
this.hint, this.hint,
}); });
/// 直接从网络 JSON 创建 Domain 实体
factory User.fromJson(Map<String, dynamic> 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({ User copyWith({
int? uid, int? uid,
String? uuid, String? uuid,
@@ -59,16 +128,32 @@ class User {
String? profilePic, String? profilePic,
String? profilePicGaussian, String? profilePicGaussian,
String? nickname, String? nickname,
String? depositName,
int? hasSetDepositName,
String? contact, String? contact,
String? countryCode, String? countryCode,
String? username,
int? role,
int? relationship,
int? friendStatus,
String? bio,
String? userAlias,
int? requestAt,
int? deletedAt,
String? email, String? email,
String? recoveryEmail, String? recoveryEmail,
String? username, String? remark,
String? bio, String? source,
int? relationship, int? addIndex,
String? userAlias, int? incomingSoundId,
int? channelId, int? outgoingSoundId,
int? channelGroupId, int? notificationSoundId,
int? sendMessageSoundId,
int? groupNotificationSoundId,
String? groupTags,
String? friendTags,
String? publicKey,
int? configBits,
String? hint, String? hint,
}) { }) {
return User( return User(
@@ -78,16 +163,33 @@ class User {
profilePic: profilePic ?? this.profilePic, profilePic: profilePic ?? this.profilePic,
profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian, profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian,
nickname: nickname ?? this.nickname, nickname: nickname ?? this.nickname,
depositName: depositName ?? this.depositName,
hasSetDepositName: hasSetDepositName ?? this.hasSetDepositName,
contact: contact ?? this.contact, contact: contact ?? this.contact,
countryCode: countryCode ?? this.countryCode, 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, email: email ?? this.email,
recoveryEmail: recoveryEmail ?? this.recoveryEmail, recoveryEmail: recoveryEmail ?? this.recoveryEmail,
username: username ?? this.username, remark: remark ?? this.remark,
bio: bio ?? this.bio, source: source ?? this.source,
relationship: relationship ?? this.relationship, addIndex: addIndex ?? this.addIndex,
userAlias: userAlias ?? this.userAlias, incomingSoundId: incomingSoundId ?? this.incomingSoundId,
channelId: channelId ?? this.channelId, outgoingSoundId: outgoingSoundId ?? this.outgoingSoundId,
channelGroupId: channelGroupId ?? this.channelGroupId, 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, hint: hint ?? this.hint,
); );
} }

View File

@@ -0,0 +1,28 @@
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));
}
// ── Multiple Users ────────────────────────────────────────────────────────────
@riverpod
Stream<List<User>> users(Ref ref, Set<int> uids) {
return ref.watch(userRepositoryProvider).watchUsers(uids.toList());
}
// ── All Users ─────────────────────────────────────────────────────────────────
@riverpod
Stream<List<User>> allUsers(Ref ref) {
return ref.watch(userRepositoryProvider).watchAllUsers();
}

View File

@@ -0,0 +1,69 @@
import 'package:im_app/domain/presentation/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<User?> 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<void> saveUser(User user) async {
await _repo.saveUser(user);
}
Future<void> updateFields(UsersCompanion companion) async {
await _repo.updateFields(uid, companion); // uid from build arg
}
Future<void> updateUser(User user) async {
await _repo.saveUser(user);
}
// ── 删除 ─────────────────────────────────────────────────────────────────
Future<void> deleteUser() async {
await _repo.deleteUser(uid);
_cached = null;
state = const AsyncData(null);
}
}

View File

@@ -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<User?> watchUser(int uid);
/// 监听指定 uid 列表,任一变化自动反映
Stream<List<User>> watchUsers(List<int> uids);
/// 监听所有用户,任一变化自动反映
Stream<List<User>> watchAllUsers();
// ── 读取 ─────────────────────────────────────────────────────────────────
/// 从 DB 读取单个用户,不存在返回 null
Future<User?> getUser(int uid);
/// 从 DB 读取所有用户
Future<List<User>> getAllUsers();
/// 分页读取用户
///
/// [offset] 起始偏移量
/// [limit] 每页数量
///
/// 示例:
/// ```dart
/// // 第一页
/// final page1 = await repo.getUsers(offset: 0, limit: 50);
/// // 第二页
/// final page2 = await repo.getUsers(offset: 50, limit: 50);
/// ```
Future<List<User>> getUsers({required int offset, required int limit});
/// 统计 DB 中用户总数
Future<int> countUsers();
// ── 写入 ─────────────────────────────────────────────────────────────────
/// 保存完整用户insert or replace
/// 调用方决定是否持久化
Future<void> saveUser(User user);
/// 批量保存用户insert or replace
Future<void> saveUsers(List<User> users);
/// 仅更新指定字段,不影响其他列
///
/// 示例:
/// ```dart
/// await repo.updateFields(uid, UsersCompanion(
/// nickname: Value('New Name'),
/// lastOnline: Value(DateTime.now().millisecondsSinceEpoch),
/// ));
/// ```
Future<void> updateFields(int uid, UsersCompanion companion);
// ── 删除 ─────────────────────────────────────────────────────────────────
/// 删除指定用户
Future<void> deleteUser(int uid);
}

View File

@@ -1,117 +1,153 @@
import 'dart:math'; import 'dart:math';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/domain/entities/user.dart';
import 'package:im_app/data/local/drift/app_database.dart'; import 'package:im_app/domain/presentation/di/user_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_db_test_view_model.g.dart'; part 'chat_db_test_view_model.g.dart';
class TestResult {
final String title;
final String subtitle;
final String duration;
TestResult({
required this.title,
required this.subtitle,
required this.duration,
});
}
class ChatDbTestState { class ChatDbTestState {
final bool testStarted; final bool testStarted;
final List<TestResult> testResults; final List<User> users;
final String currentState; final String currentState;
final bool hasMore;
final int currentPage;
final int totalCount;
const ChatDbTestState({ const ChatDbTestState({
this.testStarted = false, this.testStarted = false,
this.testResults = const [], this.users = const [],
this.currentState = '', this.currentState = '',
this.hasMore = true,
this.currentPage = 0,
this.totalCount = 0,
}); });
ChatDbTestState copyWith({ ChatDbTestState copyWith({
bool? testStarted, bool? testStarted,
List<TestResult>? testResults, List<User>? users,
String? currentState, String? currentState,
bool? hasMore,
int? currentPage,
int? totalCount,
}) => ChatDbTestState( }) => ChatDbTestState(
testStarted: testStarted ?? this.testStarted, testStarted: testStarted ?? this.testStarted,
testResults: testResults ?? this.testResults, users: users ?? this.users,
currentState: currentState ?? this.currentState, currentState: currentState ?? this.currentState,
hasMore: hasMore ?? this.hasMore,
currentPage: currentPage ?? this.currentPage,
totalCount: totalCount ?? this.totalCount,
); );
} }
@riverpod @riverpod
class ChatDbTestViewModel extends _$ChatDbTestViewModel { class ChatDbTestViewModel extends _$ChatDbTestViewModel {
final _random = Random();
bool _isTesting = false;
static const _pageSize = 50;
@override @override
ChatDbTestState build() { ChatDbTestState build() {
// 这里就是 onInit Future.microtask(() => _loadNextPage(reset: true));
final List<TestResult> testResults = List.generate( return const ChatDbTestState(currentState: '加载中...');
1000,
(i) => TestResult(
title: '用户 ${Random().nextInt(9999)}',
subtitle: 'uid: ${Random().nextInt(999999)}',
duration: '${Random().nextInt(500)}ms',
),
);
return ChatDbTestState(testResults: testResults);
} }
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ────────────────────────── // ── 分页 ──────────────────────────────────────────────────────────────────
Future<void> _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<User>;
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) { void startDBTest(BuildContext context) {
_isTesting = true;
state = state.copyWith(testStarted: true, currentState: '开始测试'); state = state.copyWith(testStarted: true, currentState: '开始测试');
_testDBInsert(); _testDBInsert();
} }
/// 结束测试
void stopDBTest(BuildContext context) { void stopDBTest(BuildContext context) {
_isTesting = false;
state = state.copyWith(testStarted: false, currentState: '结束测试'); state = state.copyWith(testStarted: false, currentState: '结束测试');
} }
Future<void> _testDBInsert() async { Future<void> _testDBInsert() async {
final db = ref.read(storageSdkProvider); final repo = ref.read(userRepositoryProvider);
const count = 10000; const count = 10000;
const chunkSize = 50; const chunkSize = 200;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
debugPrint('开始测试: $count 条,每批 $chunkSize'); debugPrint('开始测试: $count 条,每批 $chunkSize');
final workingList = List<User>.from(state.users);
int completed = 0; int completed = 0;
for (var i = 0; i < count; i += chunkSize) { while (completed < count) {
if (!_isTesting) break;
final chunk = List.generate( final chunk = List.generate(
chunkSize.clamp(0, count - i), min(chunkSize, count - completed),
(j) => UsersCompanion.insert( (_) => User(
uid: i + j, uid: _random.nextInt(999999),
nickname: Value('User ${i + j}'), nickname: 'User ${_random.nextInt(9999)}',
), ),
); );
await db.batchInsertOrReplace<DriftUser>(chunk); await repo.saveUsers(chunk);
completed += chunk.length; completed += chunk.length;
workingList.addAll(chunk);
// 让出主线程 debugPrint(
await Future.delayed(Duration.zero); '已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)',
);
debugPrint('已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)');
// 更新 UI 状态
if (ref.mounted) { if (ref.mounted) {
state = state.copyWith( state = state.copyWith(
users: List.unmodifiable(workingList),
currentState: '已插入 $completed / $count', currentState: '已插入 $completed / $count',
); );
} }
} }
debugPrint('全部完成: ${stopwatch.elapsedMilliseconds}ms'); _isTesting = false;
final elapsed = stopwatch.elapsedMilliseconds;
debugPrint('全部完成: ${elapsed}ms');
if (ref.mounted) { if (ref.mounted) {
final total = await repo.countUsers();
state = state.copyWith( state = state.copyWith(
testStarted: false, testStarted: false,
currentState: '完成!共 $count 条,耗时 ${stopwatch.elapsedMilliseconds}ms', totalCount: total,
currentState: '完成!共 $total 条,耗时 ${elapsed}ms',
); );
} }
} }

View File

@@ -3,69 +3,111 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart'; import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
import '../../../core/ui/components/app_button.dart'; import '../../../core/ui/components/app_button.dart';
import '../presentation/chat_view_model.dart';
/// 聊天页Demo 按钮) class ChatDbTestPage extends ConsumerStatefulWidget {
///
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
/// - 「切换 Tab」 — go替换历史不可返回
/// - 「有参 pushextra」 — push + extraDart Record可返回
/// - 「有参 push路径参数」— push + URL 内嵌 id可返回
/// - 「无参 push」 — push可返回
/// - 「退出登录」 — 守卫自动重定向到 /login
///
/// 所有操作通过 [ChatViewModel] 处理View 不直接调用路由。
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
class ChatDbTestPage extends ConsumerWidget {
const ChatDbTestPage({super.key}); const ChatDbTestPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ChatDbTestPage> createState() => _ChatDbTestPageState();
}
class _ChatDbTestPageState extends ConsumerState<ChatDbTestPage> {
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 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( return Scaffold(
appBar: AppBar(title: const Text('测试数据库'), ), appBar: AppBar(title: Text('测试数据库 ($totalCount)')),
body: Column( body: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
spacing: 16, spacing: 16,
children: [ children: [
SizedBox(height: 4), const SizedBox(height: 4),
Padding( Padding(
padding: EdgeInsetsGeometry.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
AppButton.inverse( AppButton.inverse(
label: state.testStarted ? '结束' : '开始', label: testStarted ? '结束' : '开始',
onPressed: () => state.testStarted ? vm.stopDBTest(context) : vm.startDBTest(context), onPressed: () => testStarted
? vm.stopDBTest(context)
: vm.startDBTest(context),
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(child: Text(currentState, textAlign: TextAlign.end)),
child: Text(
state.currentState,
textAlign: TextAlign.end,
),
)
], ],
) ),
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: state.testResults.length, controller: _scrollController,
itemCount: users.length + (hasMore ? 1 : 0),
cacheExtent: 500,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final result = state.testResults[index]; if (index == users.length) {
return ListTile( return const Padding(
titleAlignment: ListTileTitleAlignment.center, padding: EdgeInsets.all(16),
title: Text(result.title), child: Center(child: CircularProgressIndicator()),
subtitle: Text(result.subtitle),
// trailing: Text(result.duration),
); );
}
final user = users[index];
return _UserTile(uid: user.uid, nickname: user.nickname);
}, },
), ),
) ),
], ],
), ),
); );
} }
} }
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'),
);
}
}

View File

@@ -1,8 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:im_app/data/models/user_dto.dart';
import 'package:im_app/data/remote/login_request.dart'; import 'package:im_app/data/remote/login_request.dart';
import 'package:im_app/domain/presentation/di/user_provider.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/app/di/db_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -61,22 +61,25 @@ class LoginViewModel extends _$LoginViewModel {
Future<void> demoLogin() async { Future<void> demoLogin() async {
final storageApi = ref.read(storageSdkProvider); final storageApi = ref.read(storageSdkProvider);
final storageLifeCycle = storageApi as StorageSdkLifecycle; final storageLifeCycle = storageApi as StorageSdkLifecycle;
final repositoryProvider = ref.read(userRepositoryProvider);
final provider = ref.read(authNotifierProvider); final provider = ref.read(authNotifierProvider);
// Read mock response from assets // Read mock response from assets
final String raw = await rootBundle.loadString('assets/loginData.json'); final String raw = await rootBundle.loadString('assets/loginData.json');
final Map<String, dynamic> json = jsonDecode(raw); final Map<String, dynamic> json = jsonDecode(raw);
// Parse into LoginData (nested under 'data' key) // Parse → Domain User directly
final loginResponse = LoginResponse.fromJson(json); final loginResponse = LoginResponse.fromJson(json);
final user = loginResponse.data.toEntity(); final user = loginResponse.data.toEntity();
provider.login();
// Open database for the user // Open database for the user
await storageLifeCycle.openDatabase(user.uid); await storageLifeCycle.openDatabase(user.uid);
///TODO: User 和 DTO和数据库之间转换
final userCompanion = UserDto.fromEntity(user).toCompanion(); // Save user to DB via repository
storageApi.insert(userCompanion); await repositoryProvider.saveUser(user);
// Trigger auth state
provider.login();
} }
/// 执行登录 /// 执行登录
@@ -88,10 +91,9 @@ class LoginViewModel extends _$LoginViewModel {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
try { try {
final user = await ref.read(loginUseCaseProvider).execute( final user = await ref
email: email, .read(loginUseCaseProvider)
password: password, .execute(email: email, password: password);
);
state = state.copyWith(user: user, isLoading: false); state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) { } on FormatException catch (e) {