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:
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user