feat(redpacket): 红包与游戏横幅全量实现 (#19~#24)

- #19 fix: SendRedEnvelopeUseCase 动态取 currencyType(workspaceId>0 取
  workspace.currency,修复 iOS 硬编码 PEA → 150001 错误)
- #20: RedEnvelopeBubble typ=8,四态(橙色领取/已领/过期/抢完)+ 领取按钮
- #21: ReceiveRedEnvelopeUseCase POST /app/api/wallet/rp/receive,
  typed JSON body(避免 code=30007),SnackBar 反馈
- #22: SendRedEnvelopeSheet BottomSheet,STANDARD_RP + LUCKY_RP,
  发送成功后构建 typ=8 content JSON 回调给 ChatPage
- #23: BannerViewModel Notifier,Group.topic 双格式解析(JSON object/string),
  FetchBannerUseCase + Timer 倒计时 + applyNewRound WS 接口
- #24: BannerView 游戏横幅条(状态/倒计时/上期结果),
  MiniAppFloatButton 悬浮按钮(hasGame 显示/隐藏,onTap TODO #25)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-23 23:11:29 +09:00
parent 83774f5f61
commit d9539d391c
12 changed files with 1616 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
// ── 发送红包 ──────────────────────────────────────────────────────────────────
/// 发送红包响应
class SendRpData {
final String rpId;
const SendRpData({required this.rpId});
factory SendRpData.fromJson(Map<String, dynamic> json) {
return SendRpData(
rpId: (json['rpID'] ?? json['rp_id'] ?? '').toString(),
);
}
}
/// 发送红包请求
///
/// 对应 Gitea issue #19 + #22
///
/// ## currencyType 规则(#19 bug fix
///
/// iOS 硬编码 `"PEA"` → `code=150001` 错误workspace 群使用 USDT
/// Flutter 修复:`currencyType` 由调用方传入UseCase 层根据 workspaceId 动态决定:
/// - workspaceId > 0 → 从 `/workspace/workspace/get` 取 workspace.currency
/// - workspaceId == 0 → 默认 `"PEA"`
class SendRpRequest extends ApiRequestable<SendRpData> {
final String amount;
final String currencyType;
final int chatId;
final int chatType;
final String rpType;
final List<int> recipientIds;
final int rpNum;
final String remark;
final int msgSendTime;
const SendRpRequest({
required this.amount,
required this.currencyType,
required this.chatId,
required this.chatType,
required this.rpType,
required this.recipientIds,
required this.rpNum,
required this.remark,
required this.msgSendTime,
});
@override
String get path => ApiPaths.rpSend;
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {
'amount': amount,
'currencyType': currencyType,
'chatID': chatId,
'chatType': chatType,
'rpType': rpType,
'recipientIDs': recipientIds,
'rpNum': rpNum,
'remark': remark,
'msgSendTime': msgSendTime,
};
@override
SendRpData? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return SendRpData.fromJson(data);
}
}
// ── 领取红包 ──────────────────────────────────────────────────────────────────
/// 领取红包响应
class ReceiveRpData {
final bool grabFlag;
final String amount;
final int rpStatus;
const ReceiveRpData({
required this.grabFlag,
required this.amount,
required this.rpStatus,
});
factory ReceiveRpData.fromJson(Map<String, dynamic> json) {
return ReceiveRpData(
grabFlag: json['grabFlag'] as bool? ?? false,
amount: (json['amount'] ?? '0').toString(),
rpStatus: json['rpStatus'] as int? ?? 0,
);
}
}
/// 领取红包请求
///
/// 对应 Gitea issue #21
///
/// ⚠️ 必须 JSON typed body非 form 字符串),否则 server 返回 code=30007。
/// `supportMask: true` + `supportHideTail: true` 为必填标志位。
class ReceiveRpRequest extends ApiRequestable<ReceiveRpData> {
final String rpId;
final int chatId;
final String rpType;
final int sendRpMsgId;
const ReceiveRpRequest({
required this.rpId,
required this.chatId,
required this.rpType,
required this.sendRpMsgId,
});
@override
String get path => ApiPaths.rpReceive;
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {
'rpID': rpId,
'chatID': chatId,
'rpType': rpType,
'sendRpMsgID': sendRpMsgId,
'supportMask': true,
'supportHideTail': true,
};
@override
ReceiveRpData? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return ReceiveRpData.fromJson(data);
}
}
// ── 游戏横幅 ──────────────────────────────────────────────────────────────────
/// 游戏横幅响应
class GameBannerData {
final String gameId;
final String gameName;
final String? appId;
final GameCurrentRound? currentRound;
final GameLastRound? lastRound;
const GameBannerData({
required this.gameId,
required this.gameName,
this.appId,
this.currentRound,
this.lastRound,
});
factory GameBannerData.fromJson(Map<String, dynamic> json) {
return GameBannerData(
gameId: json['gameId'] as String? ?? '',
gameName: json['gameName'] as String? ?? '',
appId: json['appid'] as String?,
currentRound: json['currentRound'] is Map<String, dynamic>
? GameCurrentRound.fromJson(json['currentRound'] as Map<String, dynamic>)
: null,
lastRound: json['lastCompletedRound'] is Map<String, dynamic>
? GameLastRound.fromJson(json['lastCompletedRound'] as Map<String, dynamic>)
: null,
);
}
}
class GameCurrentRound {
final String round;
final int? startTime;
final int? closureTime;
final int? drawTime;
final int? serverTime;
const GameCurrentRound({
required this.round,
this.startTime,
this.closureTime,
this.drawTime,
this.serverTime,
});
factory GameCurrentRound.fromJson(Map<String, dynamic> json) {
return GameCurrentRound(
round: json['round'] as String? ?? '',
startTime: json['startTime'] as int?,
closureTime: json['closureTime'] as int?,
drawTime: json['drawTime'] as int?,
serverTime: json['serverTime'] as int?,
);
}
}
class GameLastRound {
final String round;
final String result;
final String? simple;
const GameLastRound({
required this.round,
required this.result,
this.simple,
});
factory GameLastRound.fromJson(Map<String, dynamic> json) {
return GameLastRound(
round: json['round'] as String? ?? '',
result: json['result'] as String? ?? '',
simple: json['simple'] as String?,
);
}
}
/// 获取游戏横幅请求
class FetchBannerRequest extends ApiRequestable<GameBannerData> {
final String gameId;
const FetchBannerRequest({required this.gameId});
@override
String get path => ApiPaths.bannerGet;
@override
HttpMethod get method => HttpMethod.post;
@override
Map<String, dynamic> get parameters => {'game_id': gameId};
@override
GameBannerData? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return GameBannerData.fromJson(data);
}
}
// ── Workspace ─────────────────────────────────────────────────────────────────
/// Workspace 信息响应(用于获取 currency
class WorkspaceData {
final int id;
final String currency;
final String name;
const WorkspaceData({
required this.id,
required this.currency,
required this.name,
});
factory WorkspaceData.fromJson(Map<String, dynamic> json) {
final ws = json['workspace'] as Map<String, dynamic>? ?? json;
return WorkspaceData(
id: ws['id'] as int? ?? 0,
currency: ws['currency'] as String? ?? 'PEA',
name: ws['name'] as String? ?? '',
);
}
}
/// 获取 Workspace 信息请求
class GetWorkspaceRequest extends ApiRequestable<WorkspaceData> {
final int workspaceId;
const GetWorkspaceRequest({required this.workspaceId});
@override
String get path => ApiPaths.workspaceGet;
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {'id': workspaceId.toString()};
@override
WorkspaceData? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return WorkspaceData.fromJson(data);
}
}