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