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,173 @@
# 红包与游戏横幅 — 架构文档
> 对应 Gitea issues #19#24
> 参考实现:`im-client-ios-swift-demo` RedEnvelopeSendView + BannerViewModel + PlatformBannerViewModel
> Bug 参考bug 截图文件夹currencyType=PEA 错误 + [红包] 文本未渲染)
---
## 1. 功能范围
| Issue | 功能 | 状态 |
|-------|------|------|
| #19 | currencyType 动态化(修复 PEA 硬编码) | ✅ 已实现 |
| #20 | 红包消息气泡typ=8三态 UI | ✅ 已实现 |
| #21 | 领取红包(/app/api/wallet/rp/receive | ✅ 已实现 |
| #22 | 发送红包 UISTANDARD_RP + LUCKY_RP | ✅ 已实现 |
| #23 | BannerViewModel — 游戏横幅 + WS NewRound | ✅ 已实现 |
| #24 | 游戏悬浮按钮MiniAppFloatButton | ✅ 已实现 |
---
## 2. 目录结构
```
data/remote/
├── red_envelope_request.dart # 发送/领取 API Request
└── banner_request.dart # 游戏横幅 API Request
features/chat/
├── di/
│ └── red_envelope_provider.dart # 红包 DI 装配
├── presentation/
│ └── banner_view_model.dart # 游戏横幅 ViewModel
├── usecases/
│ ├── send_red_envelope_usecase.dart # 发送红包
│ ├── receive_red_envelope_usecase.dart # 领取红包
│ └── fetch_banner_usecase.dart # 拉取游戏横幅
└── view/widgets/
├── red_envelope_bubble.dart # 红包气泡typ=8
├── send_red_envelope_sheet.dart # 发送 BottomSheet
├── banner_view.dart # 游戏横幅条
└── miniapp_float_button.dart # 游戏悬浮按钮
```
---
## 3. 关键数据格式
### 3.1 红包消息 contenttyp=8
```json
{
"id": "rp_xxx",
"rp_type": "STANDARD_RP",
"remark": "恭喜发财",
"total_amount": "10.00",
"total_num": 5,
"rp_status": 0
}
```
**rp_status 含义:**
| 值 | 含义 | UI |
|----|------|----|
| 0/1 | 未领取 | 橙色 + 领取按钮 |
| 2 | 已领取 | 灰色 "已领取" |
| 3 | 已过期 | 灰色 "红包已过期" |
| 4 | 已抢完 | 灰色 "手慢了" |
| 6 | 等待开奖NN_RP | 橙色 + 等待 |
### 3.2 发送红包 API
**POST /payment/rp/send**
```json
{
"amount": "10.00",
"currencyType": "PEA",
"chatID": 12345,
"chatType": 2,
"rpType": "STANDARD_RP",
"recipientIDs": [],
"rpNum": 5,
"remark": "恭喜发财",
"msgSendTime": 1234567890000000
}
```
**⚠️ currencyType 规则:**
- `workspaceId > 0` → 从 `/workspace/workspace/get``workspace.currency`(如 `"USDT"`
- `workspaceId == 0``"PEA"`(默认)
### 3.3 领取红包 API
**POST /app/api/wallet/rp/receive**
```json
{
"rpID": "rp_xxx",
"chatID": 12345,
"rpType": "STANDARD_RP",
"sendRpMsgID": 99999,
"supportMask": true,
"supportHideTail": true
}
```
**⚠️ 必须 JSON typed body非 form 字符串),否则 code=30007**
---
## 4. 数据流
### 4.1 发送红包
```
ChatPage → SendRedEnvelopeSheet
└─ SendRedEnvelopeViewModel.send(params)
├─ currencyType = workspaceId > 0 ? workspace.currency : "PEA"
├─ SendRedEnvelopeUseCase.execute() → POST /payment/rp/send → rpID
└─ 构建 rawContent JSON → 通过 ChatViewModel.sendMessage(typ=8, content)
```
### 4.2 领取红包
```
RedEnvelopeBubble.onTap
└─ ReceiveRedEnvelopeUseCase.execute(rpID, chatID, rpType, messageId)
└─ POST /app/api/wallet/rp/receive
→ grabFlag=true → SnackBar 显示金额
→ grabFlag=false → SnackBar 显示原因
```
### 4.3 游戏横幅
```
ChatPage (群聊)
└─ BannerViewModel(chatId).build()
├─ 解析 Group.topic JSON → gameId
├─ FetchBannerUseCase(gameId) → POST /lucky/banner/get
├─ Timer 每秒 tick → countdown
└─ SocketManager 消息流 → 过滤 miniapp.NewRound
→ applyGameInfo(bean) → 刷新横幅
```
---
## 5. Provider 设计
```
sendRedEnvelopeUseCaseProvider → networkSdkApiProvider
receiveRedEnvelopeUseCaseProvider → networkSdkApiProvider
fetchBannerUseCaseProvider → networkSdkApiProvider
bannerViewModelProvider.family(chatId)
```
---
## 6. Bug 修复说明
| Bug | 原因 | 修复 |
|-----|------|------|
| code=150001 | iOS 硬编码 `currencyType=PEA`workspace 用 USDT | Flutter 动态取 workspace.currency |
| `[红包]` 文本 | 旧消息 typ=1 content="[红包]",未路由到红包气泡 | `typ=8` → RedEnvelopeBubbletyp=1 content=="[红包]" fallback 检测 |
---
## 7. 待完成
- **地雷红包 / 牛牛红包**MINE_RP + NN_RP 发送 UI需 rp-config/get
- **Mini-app WebView**MiniAppFloatButton 点击后打开小程序(#25
- **领取详情页**抢红包排行榜iOS RedEnvelopeDetailSheet
- **Game settle polling**drawTime 后每 3s 轮询直到新一期

View File

@@ -32,6 +32,17 @@ class ApiPaths {
static const accountStoreGet = '/app/api/account/store/get-store'; static const accountStoreGet = '/app/api/account/store/get-store';
static const accountStoreUpdate = '/app/api/account/store/update-store'; static const accountStoreUpdate = '/app/api/account/store/update-store';
// ── Red Envelope ──
static const rpSend = '/payment/rp/send';
static const rpReceive = '/app/api/wallet/rp/receive';
static const rpConfigGet = '/payment/rp-config/get';
// ── Game Banner ──
static const bannerGet = '/lucky/banner/get';
// ── Workspace ──
static const workspaceGet = '/workspace/workspace/get';
// ── Upload ── // ── Upload ──
static const uploadFile = '/app/api/upload/file'; static const uploadFile = '/app/api/upload/file';

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

View File

@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/features/chat/usecases/send_red_envelope_usecase.dart';
import 'package:im_app/features/chat/usecases/receive_red_envelope_usecase.dart';
import 'package:im_app/features/chat/usecases/fetch_banner_usecase.dart';
/// 发送红包 UseCase Provider
final sendRedEnvelopeUseCaseProvider = Provider<SendRedEnvelopeUseCase>((ref) {
return SendRedEnvelopeUseCase(api: ref.read(networkSdkApiProvider));
});
/// 领取红包 UseCase Provider
final receiveRedEnvelopeUseCaseProvider =
Provider<ReceiveRedEnvelopeUseCase>((ref) {
return ReceiveRedEnvelopeUseCase(api: ref.read(networkSdkApiProvider));
});
/// 游戏横幅 UseCase Provider
final fetchBannerUseCaseProvider = Provider<FetchBannerUseCase>((ref) {
return FetchBannerUseCase(api: ref.read(networkSdkApiProvider));
});

View File

@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
/// 游戏横幅状态
class BannerState {
final String gameId;
final String gameName;
final String? lastResult;
final BannerGameStatus status;
final int countdownSeconds;
final bool isLoading;
final bool hasGame;
const BannerState({
this.gameId = '',
this.gameName = '',
this.lastResult,
this.status = BannerGameStatus.idle,
this.countdownSeconds = 0,
this.isLoading = false,
this.hasGame = false,
});
BannerState copyWith({
String? gameId,
String? gameName,
String? lastResult,
BannerGameStatus? status,
int? countdownSeconds,
bool? isLoading,
bool? hasGame,
}) {
return BannerState(
gameId: gameId ?? this.gameId,
gameName: gameName ?? this.gameName,
lastResult: lastResult ?? this.lastResult,
status: status ?? this.status,
countdownSeconds: countdownSeconds ?? this.countdownSeconds,
isLoading: isLoading ?? this.isLoading,
hasGame: hasGame ?? this.hasGame,
);
}
}
/// 游戏状态
enum BannerGameStatus {
idle, // 未初始化
open, // 开放下注
close, // 暂停下注 / 等待开奖
disconnected, // 连接断开
}
/// 游戏横幅 ViewModel
///
/// 对应 Gitea issue #23 / iOS BannerViewModel
///
/// ## 使用
///
/// ```dart
/// // ChatPage 打开群聊后调用
/// ref.read(bannerViewModelProvider.notifier).init(group.topic);
///
/// // WS NewRound 到达时(由 ChatViewModel 过滤后调用)
/// ref.read(bannerViewModelProvider.notifier).applyNewRound(data);
/// ```
///
/// ## Group.topic 双格式兼容
///
/// - JSON object: `{"topicid":"ks","appid":"lucky","enable":true,...}`
/// - JSON string: `"{\"topicid\":\"ks\",...}"`
class BannerViewModel extends Notifier<BannerState> {
Timer? _countdownTimer;
@override
BannerState build() => const BannerState();
// ── 公开 API ────────────────────────────────────────────────────────────────
/// 从 Group.topic JSON 初始化横幅
///
/// [topicJson] Group.topic 原始字符串JSON object 或 JSON string
void init(String? topicJson) {
final gameId = parseGameId(topicJson);
if (gameId.isEmpty) return;
state = state.copyWith(gameId: gameId, isLoading: true, hasGame: true);
Future.microtask(() => _loadBanner(gameId));
}
/// WS NewRound 到达时更新横幅
///
/// 由父级ChatPage从 SocketManager 消息流中过滤 `miniapp.NewRound` 后调用。
///
/// ```dart
/// // WS 消息过滤示例ChatViewModel 中)
/// if (msg['miniapp']?['appdata']?['action'] == 'NewRound') {
/// ref.read(bannerViewModelProvider.notifier)
/// .applyNewRound(msg['miniapp']['appdata']['data']);
/// }
/// ```
void applyNewRound(Map<String, dynamic> data) {
try {
final bean = GameBannerData.fromJson(data);
_applyGameInfo(bean);
} catch (_) {}
}
void cancelTimer() {
_countdownTimer?.cancel();
}
// ── 内部 ────────────────────────────────────────────────────────────────────
Future<void> _loadBanner(String gameId) async {
try {
final bean = await ref
.read(fetchBannerUseCaseProvider)
.execute(gameId);
if (bean != null) _applyGameInfo(bean);
} catch (_) {
state = state.copyWith(isLoading: false);
}
}
void _applyGameInfo(GameBannerData bean) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final round = bean.currentRound;
final serverNow = round?.serverTime ?? now;
final closure = round?.closureTime ?? serverNow;
final isOpen = serverNow < closure;
final countdown = isOpen ? (closure - serverNow).clamp(0, 86400) : 0;
final lastResult = bean.lastRound?.simple ?? bean.lastRound?.result;
state = state.copyWith(
gameId: bean.gameId.isNotEmpty ? bean.gameId : state.gameId,
gameName: bean.gameName.isNotEmpty ? bean.gameName : state.gameName,
lastResult: lastResult,
status: isOpen ? BannerGameStatus.open : BannerGameStatus.close,
countdownSeconds: countdown,
isLoading: false,
hasGame: true,
);
_restartCountdown();
}
void _restartCountdown() {
_countdownTimer?.cancel();
if (state.countdownSeconds <= 0) return;
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
final remaining = state.countdownSeconds - 1;
if (remaining <= 0) {
_countdownTimer?.cancel();
state = state.copyWith(
countdownSeconds: 0,
status: BannerGameStatus.close,
);
} else {
state = state.copyWith(countdownSeconds: remaining);
}
});
}
// ── 静态工具 ─────────────────────────────────────────────────────────────────
/// 解析 Group.topic JSON → gameId
///
/// 兼容 JSON object 和 JSON string 双格式。
static String parseGameId(String? topicJson) {
if (topicJson == null || topicJson.isEmpty) return '';
try {
dynamic decoded = jsonDecode(topicJson);
// 双重编码string 内再包一层 JSON string
if (decoded is String) decoded = jsonDecode(decoded);
if (decoded is! Map<String, dynamic>) return '';
final enabled = decoded['enable'] as bool? ?? true;
if (!enabled) return '';
return decoded['topicid'] as String? ?? '';
} catch (_) {
return '';
}
}
}
/// 游戏横幅 ViewModel Provider
final bannerViewModelProvider =
NotifierProvider<BannerViewModel, BannerState>(
BannerViewModel.new,
);

View File

@@ -0,0 +1,16 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 获取游戏横幅 UseCase
///
/// 对应 Gitea issue #23 / iOS BannerViewModel.loadBanner()
class FetchBannerUseCase {
final NetworksSdkApi _api;
const FetchBannerUseCase({required NetworksSdkApi api}) : _api = api;
Future<GameBannerData?> execute(String gameId) async {
return _api.executeRequest(FetchBannerRequest(gameId: gameId));
}
}

View File

@@ -0,0 +1,74 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 领取红包结果
class ReceiveRpResult {
final bool success;
final String amount;
final int rpStatus;
final String displayMessage;
const ReceiveRpResult({
required this.success,
required this.amount,
required this.rpStatus,
required this.displayMessage,
});
}
/// 领取红包 UseCase
///
/// 对应 Gitea issue #21 / iOS RedEnvelopeDetailSheet.claim()
///
/// ⚠️ `ReceiveRpRequest` 使用 JSON typed body非 form string
/// 服务端要求 bool/int 为原生类型,否则返回 code=30007。
class ReceiveRedEnvelopeUseCase {
final NetworksSdkApi _api;
const ReceiveRedEnvelopeUseCase({required NetworksSdkApi api}) : _api = api;
Future<ReceiveRpResult> execute({
required String rpId,
required int chatId,
required String rpType,
required int messageId,
}) async {
final result = await _api.executeRequest(
ReceiveRpRequest(
rpId: rpId,
chatId: chatId,
rpType: rpType,
sendRpMsgId: messageId,
),
);
if (result == null) {
return const ReceiveRpResult(
success: false,
amount: '0',
rpStatus: 0,
displayMessage: '领取失败',
);
}
final msg = _statusMessage(result.grabFlag, result.rpStatus, result.amount);
return ReceiveRpResult(
success: result.grabFlag,
amount: result.amount,
rpStatus: result.rpStatus,
displayMessage: msg,
);
}
String _statusMessage(bool grabbed, int status, String amount) {
if (grabbed) return '领取成功!获得 $amount';
return switch (status) {
2 => '您已领取过该红包',
3 => '红包已过期',
4 => '手慢了,红包已抢完',
5 => '不在领取范围内',
_ => '领取失败',
};
}
}

View File

@@ -0,0 +1,83 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/data/remote/red_envelope_request.dart';
/// 发送红包 UseCase
///
/// 对应 Gitea issue #19 + #22
///
/// ## currencyType 动态化(#19 bug fix
///
/// - `workspaceId > 0`:先请求 `/workspace/workspace/get` 取 workspace.currency如 USDT
/// - `workspaceId == 0`:默认 `"PEA"`
///
/// iOS 老项目硬编码 `currencyType=PEA`,导致 workspace 群报错 150001。
///
/// ## 成功后调用方需要
///
/// 使用返回的 `rpId` 构建 typ=8 消息 content通过 ChatViewModel.sendMessage() 发送。
class SendRedEnvelopeUseCase {
final NetworksSdkApi _api;
const SendRedEnvelopeUseCase({required NetworksSdkApi api}) : _api = api;
/// 发送红包,返回 rpId
///
/// [chatId] 会话 ID
/// [chatType] 1=私聊 / 2=群聊
/// [workspaceId] 群 workspaceId0 表示非工作台群)
/// [rpType] "STANDARD_RP" | "LUCKY_RP"(初期支持,后续扩展 MINE_RP / NN_RP
/// [amount] 总金额字符串,如 "10.00"
/// [rpNum] 红包数量
/// [remark] 红包祝福语
Future<String> execute({
required int chatId,
required int chatType,
required int workspaceId,
required String rpType,
required String amount,
required int rpNum,
required String remark,
}) async {
// #19 fix: 根据 workspaceId 动态决定 currencyType
final currencyType = await _resolveCurrencyType(workspaceId);
final result = await _api.executeRequest(
SendRpRequest(
amount: amount,
currencyType: currencyType,
chatId: chatId,
chatType: chatType,
rpType: rpType,
recipientIds: const [],
rpNum: rpNum,
remark: remark,
msgSendTime: DateTime.now().microsecondsSinceEpoch,
),
);
if (result == null || result.rpId.isEmpty) {
throw Exception('发送红包失败:未返回 rpId');
}
return result.rpId;
}
/// 根据 workspaceId 解析 currencyType
///
/// - `workspaceId > 0` → 请求 workspace 取 currency 字段
/// - `workspaceId == 0` → 直接返回 "PEA"
Future<String> _resolveCurrencyType(int workspaceId) async {
if (workspaceId <= 0) return 'PEA';
try {
final ws = await _api.executeRequest(
GetWorkspaceRequest(workspaceId: workspaceId),
);
final currency = ws?.currency ?? '';
return currency.isNotEmpty ? currency : 'PEA';
} catch (_) {
// 降级:取不到 workspace 信息时使用默认值
return 'PEA';
}
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/banner_view_model.dart';
/// 游戏横幅条(群聊顶部)
///
/// 对应 Gitea issue #23 / iOS BannerViewChatBannerView
///
/// ## 使用
///
/// ```dart
/// // ChatPage 群聊时,顶部 AppBar 下方插入
/// if (group.topic != null)
/// BannerView(onTapMiniApp: () { /* 打开小程序 */ })
/// ```
class BannerView extends ConsumerWidget {
const BannerView({super.key, this.onTapMiniApp});
/// 点击进入小程序回调
final VoidCallback? onTapMiniApp;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(bannerViewModelProvider);
if (!state.hasGame) return const SizedBox.shrink();
final theme = Theme.of(context);
final isOpen = state.status == BannerGameStatus.open;
return Material(
color: theme.colorScheme.secondaryContainer,
child: InkWell(
onTap: onTapMiniApp,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
// 游戏图标
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
_gameEmoji(state.gameId),
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: 8),
// 游戏名 + 状态
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
state.gameName.isNotEmpty
? state.gameName
: _gameLabel(state.gameId),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
_StatusBadge(isOpen: isOpen),
],
),
if (state.lastResult != null)
Text(
'上期:${state.lastResult}',
style: theme.textTheme.bodySmall
?.copyWith(color: Colors.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// 倒计时
if (state.countdownSeconds > 0)
_CountdownText(seconds: state.countdownSeconds),
const SizedBox(width: 4),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSecondaryContainer.withOpacity(0.5),
),
],
),
),
),
);
}
String _gameEmoji(String gameId) => switch (gameId) {
'bjl' => '🃏',
'lp' || 'lh' => '🐉',
'ks' => '💎',
'yxx' => '🐟',
'nn' => '🐮',
'ssc' || 'lhc' => '🎰',
'sg' => '🎲',
'pc' => '🥚',
_ => '🎮',
};
String _gameLabel(String gameId) => switch (gameId) {
'bjl' => '百家乐',
'lp' => '龙虎',
'lh' => '龙虎',
'ks' => '快三',
'yxx' => '鱼虾蟹',
'nn' => '牛牛',
'ssc' => '时时彩',
'lhc' => '六合彩',
'sg' => '色骰',
'pc' => '盘彩',
'dznz' => '多走牛牛',
'qkj' => '抢庄牌九',
_ => gameId,
};
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.isOpen});
final bool isOpen;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: isOpen ? Colors.green.withOpacity(0.15) : Colors.orange.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isOpen ? '下注中' : '等待开奖',
style: TextStyle(
fontSize: 9,
color: isOpen ? Colors.green.shade700 : Colors.orange.shade700,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _CountdownText extends StatelessWidget {
const _CountdownText({required this.seconds});
final int seconds;
@override
Widget build(BuildContext context) {
final mm = (seconds ~/ 60).toString().padLeft(2, '0');
final ss = (seconds % 60).toString().padLeft(2, '0');
return Text(
'$mm:$ss',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
fontWeight: FontWeight.w700,
color: seconds <= 10 ? Colors.red : null,
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/banner_view_model.dart';
/// 游戏悬浮按钮(群聊右下角)
///
/// 对应 Gitea issue #24 / iOS ChatRoomMiniAppFloatButton
///
/// ## 显示条件
///
/// 仅当 `BannerState.hasGame == true` 时可见(群有游戏 topic 且已成功加载横幅)。
///
/// ## 使用
///
/// ```dart
/// // ChatPage Stack 最顶层
/// Positioned(
/// right: 16,
/// bottom: 80, // 高于输入框
/// child: MiniAppFloatButton(
/// onTap: () {
/// // TODO #25: 打开小程序 WebView
/// // MiniAppRouter.open(context, gameId: bannerState.gameId);
/// },
/// ),
/// )
/// ```
class MiniAppFloatButton extends ConsumerWidget {
const MiniAppFloatButton({super.key, this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(bannerViewModelProvider);
// 仅在有游戏时显示
if (!state.hasGame) return const SizedBox.shrink();
return AnimatedScale(
scale: state.hasGame ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onTap: onTap ?? _defaultTap,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Text(
_gameEmoji(state.gameId),
style: const TextStyle(fontSize: 22),
),
),
),
),
);
}
void _defaultTap() {
// TODO #25: 打开小程序 WebView
// MiniAppRouter.open(context, gameId: state.gameId);
}
String _gameEmoji(String gameId) => switch (gameId) {
'bjl' => '🃏',
'lp' || 'lh' => '🐉',
'ks' => '💎',
'yxx' => '🐟',
'nn' => '🐮',
'ssc' || 'lhc' => '🎰',
'sg' => '🎲',
'pc' => '🥚',
_ => '🎮',
};
}

View File

@@ -0,0 +1,201 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
/// 红包消息气泡typ = 8
///
/// 对应 Gitea issue #20 / iOS RedEnvelopeBubble
///
/// ## Bug 修复
/// iOS 老消息 typ=1 content="[红包]" 显示为文本,
/// Flutter 通过 `typ=8` 正确路由到本 widget。
///
/// ## 状态
///
/// | rp_status | UI |
/// |-----------|-----|
/// | 0 / 1 | 橙色气泡,"领取红包" |
/// | 2 | 灰色,"您已领取" |
/// | 3 | 灰色,"红包已过期" |
/// | 4 | 灰色,"手慢了" |
/// | 6 | 橙色,"等待开奖" |
///
/// ## 使用
///
/// ```dart
/// RedEnvelopeBubble(
/// messageId: message.id,
/// rawContent: message.content ?? '',
/// chatId: chatId,
/// onReceived: (amount) { /* 刷新消息列表 */ },
/// )
/// ```
class RedEnvelopeBubble extends ConsumerWidget {
const RedEnvelopeBubble({
super.key,
required this.messageId,
required this.rawContent,
required this.chatId,
});
final int messageId;
final String rawContent;
final int chatId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final parsed = _parseContent(rawContent);
final rpId = parsed['id'] as String? ?? '';
final rpType = parsed['rp_type'] as String? ?? 'STANDARD_RP';
final remark = parsed['remark'] as String? ?? '恭喜发财';
final rpStatus = parsed['rp_status'] as int? ?? 0;
final isClaimed = rpStatus == 2;
final isExpired = rpStatus == 3;
final isGone = rpStatus == 4;
final isActive = !isClaimed && !isExpired && !isGone;
return GestureDetector(
onTap: isActive && rpId.isNotEmpty
? () => _claim(context, ref, rpId, rpType)
: null,
child: _RedEnvelopeCard(
remark: remark,
rpType: rpType,
isActive: isActive,
statusText: _statusText(rpStatus),
),
);
}
Future<void> _claim(
BuildContext context,
WidgetRef ref,
String rpId,
String rpType,
) async {
try {
final result = await ref
.read(receiveRedEnvelopeUseCaseProvider)
.execute(
rpId: rpId,
chatId: chatId,
rpType: rpType,
messageId: messageId,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.displayMessage),
backgroundColor: result.success ? Colors.green : null,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('领取失败:$e')),
);
}
}
String _statusText(int status) => switch (status) {
2 => '您已领取',
3 => '红包已过期',
4 => '手慢了',
_ => '',
};
Map<String, dynamic> _parseContent(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}
class _RedEnvelopeCard extends StatelessWidget {
const _RedEnvelopeCard({
required this.remark,
required this.rpType,
required this.isActive,
required this.statusText,
});
final String remark;
final String rpType;
final bool isActive;
final String statusText;
@override
Widget build(BuildContext context) {
final bgColor = isActive
? const Color(0xFFE8531E)
: Colors.grey.shade500;
final subText = isActive
? _rpTypeLabel(rpType)
: statusText;
return Container(
width: 200,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
// 红包图标
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
shape: BoxShape.circle,
),
child: const Icon(Icons.card_giftcard, color: Colors.white, size: 22),
),
const SizedBox(width: 10),
// 文字
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
remark.isNotEmpty ? remark : '恭喜发财',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
subText,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
),
),
],
),
),
],
),
);
}
String _rpTypeLabel(String rpType) => switch (rpType) {
'STANDARD_RP' => '普通红包',
'LUCKY_RP' => '拼手气红包',
'MINE_RP' => '地雷红包',
'NN_RP' => '牛牛红包',
_ => '红包',
};
}

View File

@@ -0,0 +1,289 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
/// 红包类型
enum _RpType {
standard('STANDARD_RP', '普通红包', '每人金额相同'),
lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发');
final String value;
final String label;
final String desc;
const _RpType(this.value, this.label, this.desc);
}
/// 发送红包 BottomSheet
///
/// 对应 Gitea issue #22 / iOS RedEnvelopeSendView
///
/// ## 使用
///
/// ```dart
/// // 在 ChatPage 发送按钮旁触发
/// SendRedEnvelopeSheet.show(
/// context: context,
/// chatId: chatId,
/// chatType: chatType,
/// workspaceId: group.workspaceId,
/// onSent: (content) { /* 将 content 作为 typ=8 消息发送 */ },
/// );
/// ```
class SendRedEnvelopeSheet extends ConsumerStatefulWidget {
const SendRedEnvelopeSheet({
super.key,
required this.chatId,
required this.chatType,
required this.workspaceId,
required this.onSent,
});
final int chatId;
final int chatType;
final int workspaceId;
/// 成功后回调,传入 typ=8 消息的 content JSON 字符串
final void Function(String content) onSent;
static Future<void> show({
required BuildContext context,
required int chatId,
required int chatType,
required int workspaceId,
required void Function(String content) onSent,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => SendRedEnvelopeSheet(
chatId: chatId,
chatType: chatType,
workspaceId: workspaceId,
onSent: onSent,
),
);
}
@override
ConsumerState<SendRedEnvelopeSheet> createState() =>
_SendRedEnvelopeSheetState();
}
class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
final _amountCtrl = TextEditingController();
final _remarkCtrl = TextEditingController(text: '恭喜发财');
final _numCtrl = TextEditingController(text: '1');
_RpType _rpType = _RpType.standard;
bool _sending = false;
String? _error;
@override
void dispose() {
_amountCtrl.dispose();
_remarkCtrl.dispose();
_numCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 标题行
Row(
children: [
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
color: Color(0xFFE8531E),
shape: BoxShape.circle,
),
child: const Icon(Icons.card_giftcard, color: Colors.white, size: 18),
),
const SizedBox(width: 10),
Text('发红包', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(),
const SizedBox(height: 8),
// 类型选择(群聊时显示,私聊只显示普通)
if (widget.chatType == 2) ...[
Text('红包类型', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 6),
Row(
children: _RpType.values.map((t) {
final selected = _rpType == t;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _rpType = t),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: selected
? const Color(0xFFE8531E).withOpacity(0.1)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selected
? Border.all(color: const Color(0xFFE8531E))
: null,
),
child: Column(
children: [
Text(t.label,
style: TextStyle(
fontWeight: FontWeight.w600,
color: selected ? const Color(0xFFE8531E) : null,
fontSize: 13,
)),
Text(t.desc,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey, fontSize: 10)),
],
),
),
),
);
}).toList(),
),
const SizedBox(height: 12),
],
// 数量(群聊)
if (widget.chatType == 2) ...[
Text('红包数量', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4),
TextField(
controller: _numCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
hintText: '1-100',
suffixText: '',
isDense: true,
),
),
const SizedBox(height: 12),
],
// 金额
Text('总金额', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4),
TextField(
controller: _amountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
hintText: '0.00',
prefixText: '¥ ',
isDense: true,
),
),
const SizedBox(height: 12),
// 祝福语
Text('祝福语', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4),
TextField(
controller: _remarkCtrl,
maxLength: 20,
decoration: const InputDecoration(isDense: true),
),
// 错误提示
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error, fontSize: 12)),
),
const SizedBox(height: 16),
// 发送按钮
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _sending ? null : _send,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE8531E),
),
child: _sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Text('塞钱进红包'),
),
),
],
),
),
);
}
Future<void> _send() async {
final amount = _amountCtrl.text.trim();
if (amount.isEmpty || double.tryParse(amount) == null) {
setState(() => _error = '请输入有效金额');
return;
}
final num = int.tryParse(_numCtrl.text.trim()) ?? 1;
if (num < 1 || num > 100) {
setState(() => _error = '数量范围 1-100');
return;
}
setState(() { _sending = true; _error = null; });
try {
final rpId = await ref.read(sendRedEnvelopeUseCaseProvider).execute(
chatId: widget.chatId,
chatType: widget.chatType,
workspaceId: widget.workspaceId,
rpType: _rpType.value,
amount: double.parse(amount).toStringAsFixed(2),
rpNum: num,
remark: _remarkCtrl.text.trim().isNotEmpty
? _remarkCtrl.text.trim()
: '恭喜发财',
);
// 构建 typ=8 消息 content
final content = jsonEncode({
'id': rpId,
'rp_type': _rpType.value,
'remark': _remarkCtrl.text.trim().isNotEmpty
? _remarkCtrl.text.trim()
: '恭喜发财',
'total_amount': double.parse(amount).toStringAsFixed(2),
'total_num': num,
'rp_status': 0,
});
if (mounted) {
Navigator.pop(context);
widget.onSent(content);
}
} catch (e) {
setState(() { _error = e.toString(); _sending = false; });
}
}
}