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:
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 — 校验手机验证码
|
||||
|
||||
Reference in New Issue
Block a user