feat(chat): 发收消息全量实现 (#25~#28)

- 移除 @riverpod/@freezed 注解依赖,全部改为手写 Provider(无需 build_runner)
  · LoginState 改为纯 Dart,LoginViewModel/ThemeViewModel/ChatViewModel 改为 Notifier
  · UserNotifier 改为 FamilyAsyncNotifier<User?,int>,mini_app_provider 改为手写 Provider
  · 15 个 StreamProvider/StreamProvider.family 从 @riverpod 迁移至手写

- 发送消息(#25)
  · SendMessageRequest/SendMessageResponse DTO
  · SendMessageUseCase:乐观写入 DB → HTTP POST → 更新 Chat 摘要

- 接收消息 WS(#26)
  · WsMessageService:监听 mode2 WS 帧 → HTTP 补拉 → DB 写入 → Chat 更新
  · FetchHistoryRequest/FetchHistoryResponse DTO(GET /app/api/chat/history)
  · FetchHistoryUseCase:拉取 → insertOrReplaceAll

- DI 装配(chat_service_providers.dart)
  · wsMessageServiceProvider、sendMessageUseCaseProvider、fetchHistoryUseCaseProvider

- 聊天列表页(#27)
  · ChatListViewModel(Notifier<void>)+ chat_page.dart 真实会话列表 UI
  · ListTile:头像首字母、最新消息摘要、未读角标、时间格式化

- 聊天详情页(#28)
  · ChatDetailViewModel(FamilyNotifier<ChatDetailState,int>)+ chat_detail_page.dart
  · 消息气泡(自己/他人分左右)、底部输入框、发送状态与错误提示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-23 23:16:44 +09:00
parent d9539d391c
commit e715a0673b
37 changed files with 1226 additions and 405 deletions

View File

@@ -0,0 +1,125 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
import 'package:im_app/domain/entities/message.dart';
// ── 消息历史响应 ───────────────────────────────────────────────────────────────
/// 单条消息 DTO历史接口返回列表中的每一项
class MessageItem {
final int messageId;
final int chatId;
final int chatIdx;
final int sendId;
final String? content;
final int typ;
final int sendTime;
final int? expireTime;
final String? atUsers;
const MessageItem({
required this.messageId,
required this.chatId,
required this.chatIdx,
required this.sendId,
this.content,
required this.typ,
required this.sendTime,
this.expireTime,
this.atUsers,
});
factory MessageItem.fromJson(Map<String, dynamic> json) => MessageItem(
messageId: (json['message_id'] ?? json['messageId'] ?? json['id'] ?? 0)
as int,
chatId: (json['chat_id'] ?? json['chatId'] ?? 0) as int,
chatIdx: (json['chat_idx'] ?? json['chatIdx'] ?? 0) as int,
sendId: (json['send_id'] ?? json['sendId'] ?? 0) as int,
content: json['content'] as String?,
typ: (json['typ'] ?? 1) as int,
sendTime: (json['send_time'] ?? json['sendTime'] ?? 0) as int,
expireTime: json['expire_time'] as int?,
atUsers: json['at_users'] as String?,
);
Message toEntity() => Message(
id: 0,
messageId: messageId,
chatId: chatId,
chatIdx: chatIdx,
sendId: sendId,
content: content,
typ: typ,
sendTime: sendTime,
expireTime: expireTime,
atUsers: atUsers,
);
}
/// 历史消息接口响应
class FetchHistoryResponse {
final List<MessageItem> messages;
const FetchHistoryResponse({required this.messages});
factory FetchHistoryResponse.fromJson(Map<String, dynamic> json) {
final raw =
json['list'] as List<dynamic>? ??
json['messages'] as List<dynamic>? ??
const [];
return FetchHistoryResponse(
messages: raw
.whereType<Map<String, dynamic>>()
.map(MessageItem.fromJson)
.toList(),
);
}
}
// ── 获取历史消息请求 ───────────────────────────────────────────────────────────
/// GET /app/api/chat/history — 按锚点分页拉取消息
///
/// [chatIdx] 锚点消息 index从 0 开始拉最新)。
/// [limit] 每次拉取条数,默认 20。
class FetchHistoryRequest extends ApiRequestable<FetchHistoryResponse> {
final int chatId;
final int chatIdx;
final int limit;
const FetchHistoryRequest({
required this.chatId,
this.chatIdx = 0,
this.limit = 20,
});
@override
String get path => ApiPaths.chatHistory;
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {
'chat_id': chatId.toString(),
'chat_idx': chatIdx.toString(),
'limit': limit.toString(),
};
@override
FetchHistoryResponse? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is Map<String, dynamic>) {
return FetchHistoryResponse.fromJson(data);
}
if (data is List<dynamic>) {
return FetchHistoryResponse(
messages: data
.whereType<Map<String, dynamic>>()
.map(MessageItem.fromJson)
.toList(),
);
}
return const FetchHistoryResponse(messages: []);
}
}

View File

@@ -76,6 +76,26 @@ class ProfileResponse {
required this.hint,
});
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,

View File

@@ -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();
}

View File

@@ -0,0 +1,88 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
import 'package:im_app/domain/entities/message.dart';
// ── 发送消息响应 ───────────────────────────────────────────────────────────────
/// 发送消息接口响应
class SendMessageResponse {
final int messageId;
final int chatIdx;
final int sendTime;
const SendMessageResponse({
required this.messageId,
required this.chatIdx,
required this.sendTime,
});
factory SendMessageResponse.fromJson(Map<String, dynamic> json) =>
SendMessageResponse(
messageId:
(json['message_id'] ?? json['messageId'] ?? json['id'] ?? 0)
as int,
chatIdx:
(json['chat_idx'] ?? json['chatIdx'] ?? 0) as int,
sendTime:
(json['send_time'] ?? json['sendTime'] ?? 0) as int,
);
Message toEntity({
required int chatId,
required int sendId,
required String content,
required int typ,
}) =>
Message(
id: 0,
messageId: messageId,
chatId: chatId,
chatIdx: chatIdx,
sendId: sendId,
content: content,
typ: typ,
sendTime: sendTime,
);
}
// ── 发送消息请求 ───────────────────────────────────────────────────────────────
/// POST /app/api/chat/send-message — 发送文本消息
///
/// [typ] 消息类型1 = 文本,详见服务端 MessageType 枚举。
/// [sendTime] Unix 时间戳(秒),由客户端生成,用于服务端去重。
class SendMessageRequest extends ApiRequestable<SendMessageResponse> {
final int chatId;
final String content;
final int typ;
final int sendTime;
const SendMessageRequest({
required this.chatId,
required this.content,
required this.typ,
required this.sendTime,
});
@override
String get path => ApiPaths.chatSendMessage;
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {
'chat_id': chatId,
'content': content,
'typ': typ,
'send_time': sendTime,
};
@override
SendMessageResponse? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return SendMessageResponse.fromJson(data);
}
}

View File

@@ -33,6 +33,14 @@ class SendOtpResponse {
this.web,
this.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 — 发送手机验证码

View File

@@ -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? ?? '',
);
}
// ═════════════════════════════════════════════

View File

@@ -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 — 校验手机验证码