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:
173
Doc/red_envelope_game_architecture.md
Normal file
173
Doc/red_envelope_game_architecture.md
Normal 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 | 发送红包 UI(STANDARD_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 红包消息 content(typ=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` → RedEnvelopeBubble;typ=1 content=="[红包]" fallback 检测 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 待完成
|
||||
|
||||
- **地雷红包 / 牛牛红包**:MINE_RP + NN_RP 发送 UI(需 rp-config/get)
|
||||
- **Mini-app WebView**:MiniAppFloatButton 点击后打开小程序(#25)
|
||||
- **领取详情页**:抢红包排行榜(iOS RedEnvelopeDetailSheet)
|
||||
- **Game settle polling**:drawTime 后每 3s 轮询直到新一期
|
||||
@@ -32,6 +32,17 @@ class ApiPaths {
|
||||
static const accountStoreGet = '/app/api/account/store/get-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 ──
|
||||
static const uploadFile = '/app/api/upload/file';
|
||||
|
||||
|
||||
292
apps/im_app/lib/data/remote/red_envelope_request.dart
Normal file
292
apps/im_app/lib/data/remote/red_envelope_request.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
22
apps/im_app/lib/features/chat/di/red_envelope_provider.dart
Normal file
22
apps/im_app/lib/features/chat/di/red_envelope_provider.dart
Normal 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));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 => '不在领取范围内',
|
||||
_ => '领取失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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] 群 workspaceId(0 表示非工作台群)
|
||||
/// [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';
|
||||
}
|
||||
}
|
||||
}
|
||||
173
apps/im_app/lib/features/chat/view/widgets/banner_view.dart
Normal file
173
apps/im_app/lib/features/chat/view/widgets/banner_view.dart
Normal 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 BannerView(ChatBannerView)
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' => '🥚',
|
||||
_ => '🎮',
|
||||
};
|
||||
}
|
||||
@@ -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' => '牛牛红包',
|
||||
_ => '红包',
|
||||
};
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user