feat: WebView/媒体/红包详情全量实现 (#25~#30)
#25 MiniAppWebViewPage + MiniAppRouter - webview_flutter 加载 {apiBaseUrl}/miniapp/{appId}/index.html?gameId=...&token=... - MiniAppFloatButton 接收 chatId/chatType,默认打开 WebView - BannerState 新增 appId 字段,由 GameBannerData.appId 填充 #26 open_filex 文件打开 - FileMessageBubble 下载完成后调用 OpenFilex.open(localPath) - 打开失败时 SnackBar 提示 #27 audioplayers 音频播放 - AudioPlaybackService(Notifier):单例 AudioPlayer,togglePlay/pause/seek - AudioMessageBubble 接入:播放态图标切换、进度 mm:ss 显示 #28 video_player + chewie 视频全屏 - VideoPlayerPage:本地文件 / HTTP 双模,chewie 控制栏 - VideoMessageBubble 默认 onTap → push VideoPlayerPage #29 红包领取排行榜详情页 - GET /payment/rp/detail → RpDetailData + RpRecordItem DTO - GetRpDetailUseCase + getRpDetailUseCaseProvider - RedEnvelopeDetailSheet:汇总行 + 领取排行列表,头像/昵称/金额/时间 #30 MINE_RP 地雷红包发包 UI - _RpType 新增 mine(MINE_RP),显示地雷金额输入框 - SendRpRequest.parameters 携带 mineAmount - RedEnvelopeBubble:非活跃状态直接打开详情,活跃状态领取后打开排行榜 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ 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';
|
||||
import 'package:im_app/features/chat/usecases/get_rp_detail_usecase.dart';
|
||||
|
||||
/// 发送红包 UseCase Provider
|
||||
final sendRedEnvelopeUseCaseProvider = Provider<SendRedEnvelopeUseCase>((ref) {
|
||||
@@ -20,3 +21,8 @@ final receiveRedEnvelopeUseCaseProvider =
|
||||
final fetchBannerUseCaseProvider = Provider<FetchBannerUseCase>((ref) {
|
||||
return FetchBannerUseCase(api: ref.read(networkSdkApiProvider));
|
||||
});
|
||||
|
||||
/// 红包领取详情 UseCase Provider (#29)
|
||||
final getRpDetailUseCaseProvider = Provider<GetRpDetailUseCase>((ref) {
|
||||
return GetRpDetailUseCase(api: ref.read(networkSdkApiProvider));
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||
class BannerState {
|
||||
final String gameId;
|
||||
final String gameName;
|
||||
/// 小程序 appId(用于 WebView URL 构造)
|
||||
final String appId;
|
||||
final String? lastResult;
|
||||
final BannerGameStatus status;
|
||||
final int countdownSeconds;
|
||||
@@ -19,6 +21,7 @@ class BannerState {
|
||||
const BannerState({
|
||||
this.gameId = '',
|
||||
this.gameName = '',
|
||||
this.appId = '',
|
||||
this.lastResult,
|
||||
this.status = BannerGameStatus.idle,
|
||||
this.countdownSeconds = 0,
|
||||
@@ -29,6 +32,7 @@ class BannerState {
|
||||
BannerState copyWith({
|
||||
String? gameId,
|
||||
String? gameName,
|
||||
String? appId,
|
||||
String? lastResult,
|
||||
BannerGameStatus? status,
|
||||
int? countdownSeconds,
|
||||
@@ -38,6 +42,7 @@ class BannerState {
|
||||
return BannerState(
|
||||
gameId: gameId ?? this.gameId,
|
||||
gameName: gameName ?? this.gameName,
|
||||
appId: appId ?? this.appId,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
status: status ?? this.status,
|
||||
countdownSeconds: countdownSeconds ?? this.countdownSeconds,
|
||||
@@ -139,6 +144,7 @@ class BannerViewModel extends Notifier<BannerState> {
|
||||
state = state.copyWith(
|
||||
gameId: bean.gameId.isNotEmpty ? bean.gameId : state.gameId,
|
||||
gameName: bean.gameName.isNotEmpty ? bean.gameName : state.gameName,
|
||||
appId: (bean.appId != null && bean.appId!.isNotEmpty) ? bean.appId : state.appId,
|
||||
lastResult: lastResult,
|
||||
status: isOpen ? BannerGameStatus.open : BannerGameStatus.close,
|
||||
countdownSeconds: countdown,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/data/remote/red_envelope_request.dart';
|
||||
|
||||
/// 获取红包领取详情 UseCase
|
||||
///
|
||||
/// 对应 Gitea issue #29 / iOS RedEnvelopeDetailSheet 领取排行榜数据源
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// final detail = await ref.read(getRpDetailUseCaseProvider)
|
||||
/// .execute(rpId: parsedContent['id']);
|
||||
/// ```
|
||||
class GetRpDetailUseCase {
|
||||
final NetworksSdkApi _api;
|
||||
|
||||
const GetRpDetailUseCase({required NetworksSdkApi api}) : _api = api;
|
||||
|
||||
Future<RpDetailData> execute({required String rpId}) async {
|
||||
final result = await _api.executeRequest(
|
||||
GetRpDetailRequest(rpId: rpId),
|
||||
);
|
||||
if (result == null) throw Exception('获取红包详情失败');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,11 @@ class SendRedEnvelopeUseCase {
|
||||
/// [chatId] 会话 ID
|
||||
/// [chatType] 1=私聊 / 2=群聊
|
||||
/// [workspaceId] 群 workspaceId(0 表示非工作台群)
|
||||
/// [rpType] "STANDARD_RP" | "LUCKY_RP"(初期支持,后续扩展 MINE_RP / NN_RP)
|
||||
/// [rpType] "STANDARD_RP" | "LUCKY_RP" | "MINE_RP"(NN_RP 由游戏自动触发)
|
||||
/// [amount] 总金额字符串,如 "10.00"
|
||||
/// [rpNum] 红包数量
|
||||
/// [remark] 红包祝福语
|
||||
/// [mineAmount] 地雷金额(仅 MINE_RP 时传入,如 "5.00")
|
||||
Future<String> execute({
|
||||
required int chatId,
|
||||
required int chatType,
|
||||
@@ -38,6 +39,7 @@ class SendRedEnvelopeUseCase {
|
||||
required String amount,
|
||||
required int rpNum,
|
||||
required String remark,
|
||||
String? mineAmount,
|
||||
}) async {
|
||||
// #19 fix: 根据 workspaceId 动态决定 currencyType
|
||||
final currencyType = await _resolveCurrencyType(workspaceId);
|
||||
@@ -53,6 +55,7 @@ class SendRedEnvelopeUseCase {
|
||||
rpNum: rpNum,
|
||||
remark: remark,
|
||||
msgSendTime: DateTime.now().microsecondsSinceEpoch,
|
||||
mineAmount: mineAmount,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
119
apps/im_app/lib/features/chat/view/video_player_page.dart
Normal file
119
apps/im_app/lib/features/chat/view/video_player_page.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// 视频全屏播放页
|
||||
///
|
||||
/// 对应 Gitea issue #32 / iOS VideoPlayerViewController
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // VideoMessageBubble 下载完成后
|
||||
/// Navigator.of(context).push(
|
||||
/// MaterialPageRoute(
|
||||
/// builder: (_) => VideoPlayerPage(url: localPath, title: '视频'),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
class VideoPlayerPage extends StatefulWidget {
|
||||
const VideoPlayerPage({super.key, required this.url, this.title = '视频'});
|
||||
|
||||
/// 本地文件路径("/"开头)或 HTTP URL
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
|
||||
}
|
||||
|
||||
class _VideoPlayerPageState extends State<VideoPlayerPage> {
|
||||
VideoPlayerController? _videoController;
|
||||
ChewieController? _chewieController;
|
||||
bool _hasError = false;
|
||||
String? _errorMsg;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
final controller = widget.url.startsWith('/')
|
||||
? VideoPlayerController.file(File(widget.url))
|
||||
: VideoPlayerController.networkUrl(Uri.parse(widget.url));
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final chewie = ChewieController(
|
||||
videoPlayerController: controller,
|
||||
autoPlay: true,
|
||||
allowFullScreen: true,
|
||||
allowMuting: true,
|
||||
showControls: true,
|
||||
materialProgressColors: ChewieProgressColors(
|
||||
playedColor: Theme.of(context).colorScheme.primary,
|
||||
handleColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_videoController = controller;
|
||||
_chewieController = chewie;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_errorMsg = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chewieController?.dispose();
|
||||
_videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(widget.title, style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
body: _hasError
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.white54, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMsg ?? '播放失败',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _chewieController != null
|
||||
? Chewie(controller: _chewieController!)
|
||||
: const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,30 +4,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/core/services/file_download_manager.dart';
|
||||
import 'package:im_app/core/services/audio_playback_service.dart';
|
||||
|
||||
/// 语音消息气泡(typ = 3)
|
||||
///
|
||||
/// 对应 Gitea issue #17 / iOS VoiceMessageBubble + AudioPlaybackService
|
||||
/// 对应 Gitea issue #17 + #31
|
||||
///
|
||||
/// ## 数据格式
|
||||
///
|
||||
/// rawContent JSON:`{ "url": "Voice/xxx.m4a", "duration": 5 }`
|
||||
///
|
||||
/// ## 当前实现
|
||||
/// ## 功能
|
||||
///
|
||||
/// 下载框架已实现,播放能力待 audioplayers 包接入:
|
||||
/// 1. 点击播放按钮 → 通过 FileDownloadManager 下载语音文件
|
||||
/// 2. 下载完成 → TODO: AudioPlayer().play(DeviceFileSource(localPath))
|
||||
///
|
||||
/// ## 接入 audioplayers
|
||||
///
|
||||
/// ```yaml
|
||||
/// # pubspec.yaml
|
||||
/// dependencies:
|
||||
/// audioplayers: ^6.0.0
|
||||
/// ```
|
||||
///
|
||||
/// 解开 TODO 注释即可完成播放功能。
|
||||
/// 1. 点击 → 触发 FileDownloadManager 下载语音文件
|
||||
/// 2. 下载完成后点击 → AudioPlaybackService.togglePlay(localPath) 播放/暂停
|
||||
/// 3. 播放时显示位置/总时长,波形激活态
|
||||
class AudioMessageBubble extends ConsumerWidget {
|
||||
const AudioMessageBubble({
|
||||
super.key,
|
||||
@@ -39,7 +30,7 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
final String rawContent;
|
||||
final String messageId;
|
||||
|
||||
/// 是否为自己发送的消息(影响气泡方向)
|
||||
/// 是否为自己发送的消息(影响气泡颜色)
|
||||
final bool isSelf;
|
||||
|
||||
@override
|
||||
@@ -53,15 +44,19 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
|
||||
const FileDownloadIdle();
|
||||
|
||||
final audioState = ref.watch(audioPlaybackServiceProvider);
|
||||
final localPath =
|
||||
downloadState is FileDownloadDone ? downloadState.localPath : null;
|
||||
final isPlaying = localPath != null && audioState.isPlayingUrl(localPath);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final isDownloading = downloadState is FileDownloadProgress;
|
||||
final isDownloaded = downloadState is FileDownloadDone;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: isDownloading || url.isEmpty
|
||||
? null
|
||||
: () => _handleTap(ref, url, fileName, downloadState),
|
||||
: () => _handleTap(ref, url, fileName, downloadState, localPath),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
constraints: const BoxConstraints(minWidth: 100, maxWidth: 220),
|
||||
@@ -74,14 +69,14 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildLeadingIcon(context, downloadState),
|
||||
_buildLeadingIcon(context, downloadState, isPlaying),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 时长 / 进度
|
||||
// 时长 / 播放进度 / 下载进度
|
||||
if (isDownloading)
|
||||
Text(
|
||||
'${((downloadState as FileDownloadProgress).progress * 100).toStringAsFixed(0)}%',
|
||||
@@ -89,16 +84,23 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
color: isSelf ? Colors.white70 : Colors.grey,
|
||||
),
|
||||
)
|
||||
else if (isPlaying && audioState.totalDuration.inSeconds > 0)
|
||||
Text(
|
||||
'${_fmt(audioState.position)} / ${_fmt(audioState.totalDuration)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isSelf ? Colors.white70 : Colors.grey,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
duration > 0 ? "${duration}''" : "语音",
|
||||
duration > 0 ? "$duration''" : '语音',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isSelf ? Colors.white : null,
|
||||
),
|
||||
),
|
||||
// 波形装饰(静态占位)
|
||||
// 波形装饰
|
||||
const SizedBox(height: 2),
|
||||
_WaveformDecoration(isSelf: isSelf, isPlaying: isDownloaded),
|
||||
_WaveformDecoration(isSelf: isSelf, isPlaying: isPlaying),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -108,9 +110,13 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeadingIcon(BuildContext context, FileDownloadState state) {
|
||||
final isSelfBubble = isSelf;
|
||||
final iconColor = isSelfBubble ? Colors.white : Theme.of(context).colorScheme.primary;
|
||||
Widget _buildLeadingIcon(
|
||||
BuildContext context,
|
||||
FileDownloadState state,
|
||||
bool isPlaying,
|
||||
) {
|
||||
final iconColor =
|
||||
isSelf ? Colors.white : Theme.of(context).colorScheme.primary;
|
||||
|
||||
switch (state) {
|
||||
case FileDownloadIdle():
|
||||
@@ -126,8 +132,11 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
case FileDownloadDone():
|
||||
// TODO: AudioPlaybackService 接入后区分 playing / paused 态
|
||||
return Icon(Icons.pause_circle_outline, size: 28, color: iconColor);
|
||||
return Icon(
|
||||
isPlaying ? Icons.pause_circle : Icons.play_circle,
|
||||
size: 28,
|
||||
color: iconColor,
|
||||
);
|
||||
case FileDownloadFailed():
|
||||
return Icon(Icons.error_outline, size: 28, color: Colors.red.shade300);
|
||||
}
|
||||
@@ -138,12 +147,10 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
String url,
|
||||
String fileName,
|
||||
FileDownloadState state,
|
||||
String? localPath,
|
||||
) {
|
||||
if (state is FileDownloadDone) {
|
||||
// TODO: 接入 audioplayers 后播放:
|
||||
// import 'package:audioplayers/audioplayers.dart';
|
||||
// final player = AudioPlayer();
|
||||
// await player.play(DeviceFileSource(state.localPath));
|
||||
if (state is FileDownloadDone && localPath != null) {
|
||||
ref.read(audioPlaybackServiceProvider.notifier).togglePlay(localPath);
|
||||
return;
|
||||
}
|
||||
ref.read(fileDownloadManagerProvider.notifier).download(
|
||||
@@ -152,6 +159,13 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化 Duration → "mm:ss"
|
||||
String _fmt(Duration d) {
|
||||
final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$mm:$ss';
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parseContent(String raw) {
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
@@ -161,7 +175,7 @@ class AudioMessageBubble extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态波形装饰(未接入音频分析时的占位)
|
||||
/// 静态波形装饰
|
||||
class _WaveformDecoration extends StatelessWidget {
|
||||
const _WaveformDecoration({required this.isSelf, required this.isPlaying});
|
||||
|
||||
@@ -173,7 +187,7 @@ class _WaveformDecoration extends StatelessWidget {
|
||||
final baseColor = isSelf ? Colors.white54 : Colors.grey.shade400;
|
||||
final activeColor =
|
||||
isSelf ? Colors.white : Theme.of(context).colorScheme.primary;
|
||||
final heights = [3.0, 6.0, 4.0, 8.0, 5.0, 7.0, 3.0, 6.0, 4.0];
|
||||
const heights = [3.0, 6.0, 4.0, 8.0, 5.0, 7.0, 3.0, 6.0, 4.0];
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
|
||||
import 'package:im_app/core/services/file_download_manager.dart';
|
||||
|
||||
@@ -20,7 +21,7 @@ import 'package:im_app/core/services/file_download_manager.dart';
|
||||
/// fileName: 'report.pdf',
|
||||
/// fileSize: 1234567,
|
||||
/// onTap: (localPath) async {
|
||||
/// // TODO: open_filex 接入后: await OpenFilex.open(localPath);
|
||||
/// // open_filex 已接入,默认自动调用;onTap 用于额外处理。
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
@@ -195,16 +196,19 @@ class _FileMessageBubbleContent extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTap(
|
||||
Future<void> _handleTap(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
FileDownloadState state,
|
||||
) {
|
||||
) async {
|
||||
if (state is FileDownloadDone) {
|
||||
onTap?.call(state.localPath);
|
||||
// TODO: open_filex 接入后:
|
||||
// import 'package:open_filex/open_filex.dart';
|
||||
// await OpenFilex.open(state.localPath);
|
||||
final result = await OpenFilex.open(state.localPath);
|
||||
if (result.type != ResultType.done && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('无法打开文件:${result.message}')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// idle / failed → 触发下载
|
||||
|
||||
@@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/presentation/banner_view_model.dart';
|
||||
import 'package:im_app/features/miniapp/miniapp_webview_page.dart';
|
||||
|
||||
/// 游戏悬浮按钮(群聊右下角)
|
||||
///
|
||||
/// 对应 Gitea issue #24 / iOS ChatRoomMiniAppFloatButton
|
||||
/// 对应 Gitea issue #25 / iOS ChatRoomMiniAppFloatButton
|
||||
///
|
||||
/// ## 显示条件
|
||||
///
|
||||
@@ -19,16 +20,23 @@ import 'package:im_app/features/chat/presentation/banner_view_model.dart';
|
||||
/// right: 16,
|
||||
/// bottom: 80, // 高于输入框
|
||||
/// child: MiniAppFloatButton(
|
||||
/// onTap: () {
|
||||
/// // TODO #25: 打开小程序 WebView
|
||||
/// // MiniAppRouter.open(context, gameId: bannerState.gameId);
|
||||
/// },
|
||||
/// chatId: chatId,
|
||||
/// chatType: chatType,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class MiniAppFloatButton extends ConsumerWidget {
|
||||
const MiniAppFloatButton({super.key, this.onTap});
|
||||
const MiniAppFloatButton({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.chatType,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final int chatId;
|
||||
final int chatType;
|
||||
|
||||
/// 自定义点击回调;null 时默认打开 [MiniAppWebViewPage]
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
@@ -42,7 +50,16 @@ class MiniAppFloatButton extends ConsumerWidget {
|
||||
scale: state.hasGame ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? _defaultTap,
|
||||
onTap: onTap ??
|
||||
() => MiniAppRouter.open(
|
||||
context,
|
||||
ref,
|
||||
gameId: state.gameId,
|
||||
appId: state.appId,
|
||||
chatId: chatId,
|
||||
chatType: chatType,
|
||||
title: state.gameName.isNotEmpty ? state.gameName : '游戏',
|
||||
),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
@@ -68,11 +85,6 @@ class MiniAppFloatButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _defaultTap() {
|
||||
// TODO #25: 打开小程序 WebView
|
||||
// MiniAppRouter.open(context, gameId: state.gameId);
|
||||
}
|
||||
|
||||
String _gameEmoji(String gameId) => switch (gameId) {
|
||||
'bjl' => '🃏',
|
||||
'lp' || 'lh' => '🐉',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||
import 'package:im_app/features/chat/view/widgets/red_envelope_detail_sheet.dart';
|
||||
|
||||
/// 红包消息气泡(typ = 8)
|
||||
///
|
||||
@@ -59,8 +60,23 @@ class RedEnvelopeBubble extends ConsumerWidget {
|
||||
final isActive = !isClaimed && !isExpired && !isGone;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isActive && rpId.isNotEmpty
|
||||
? () => _claim(context, ref, rpId, rpType)
|
||||
onTap: rpId.isNotEmpty
|
||||
? () => isActive
|
||||
? _claim(context, ref, rpId, rpType, remark)
|
||||
: RedEnvelopeDetailSheet.show(
|
||||
context: context,
|
||||
rpId: rpId,
|
||||
remark: remark,
|
||||
rpType: rpType,
|
||||
)
|
||||
: null,
|
||||
onLongPress: rpId.isNotEmpty
|
||||
? () => RedEnvelopeDetailSheet.show(
|
||||
context: context,
|
||||
rpId: rpId,
|
||||
remark: remark,
|
||||
rpType: rpType,
|
||||
)
|
||||
: null,
|
||||
child: _RedEnvelopeCard(
|
||||
remark: remark,
|
||||
@@ -76,6 +92,7 @@ class RedEnvelopeBubble extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
String rpId,
|
||||
String rpType,
|
||||
String remark,
|
||||
) async {
|
||||
try {
|
||||
final result = await ref
|
||||
@@ -94,6 +111,16 @@ class RedEnvelopeBubble extends ConsumerWidget {
|
||||
backgroundColor: result.success ? Colors.green : null,
|
||||
),
|
||||
);
|
||||
|
||||
// 领取后自动打开详情排行榜
|
||||
if (context.mounted) {
|
||||
await RedEnvelopeDetailSheet.show(
|
||||
context: context,
|
||||
rpId: rpId,
|
||||
remark: remark,
|
||||
rpType: rpType,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
|
||||
/// 红包领取排行榜详情页
|
||||
///
|
||||
/// 对应 Gitea issue #29 / iOS RedEnvelopeDetailSheet
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// RedEnvelopeDetailSheet.show(context: context, rpId: '...', remark: '恭喜发财');
|
||||
/// ```
|
||||
class RedEnvelopeDetailSheet extends ConsumerStatefulWidget {
|
||||
const RedEnvelopeDetailSheet({
|
||||
super.key,
|
||||
required this.rpId,
|
||||
required this.remark,
|
||||
this.rpType = 'STANDARD_RP',
|
||||
});
|
||||
|
||||
final String rpId;
|
||||
final String remark;
|
||||
final String rpType;
|
||||
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required String rpId,
|
||||
required String remark,
|
||||
String rpType = 'STANDARD_RP',
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => RedEnvelopeDetailSheet(
|
||||
rpId: rpId,
|
||||
remark: remark,
|
||||
rpType: rpType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<RedEnvelopeDetailSheet> createState() =>
|
||||
_RedEnvelopeDetailSheetState();
|
||||
}
|
||||
|
||||
class _RedEnvelopeDetailSheetState
|
||||
extends ConsumerState<RedEnvelopeDetailSheet> {
|
||||
RpDetailData? _detail;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await ref
|
||||
.read(getRpDetailUseCaseProvider)
|
||||
.execute(rpId: widget.rpId);
|
||||
if (mounted) setState(() { _detail = data; _loading = false; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _error = e.toString(); _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.92,
|
||||
builder: (_, scrollController) => Column(
|
||||
children: [
|
||||
// 拖动手柄
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: 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),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.remark.isNotEmpty ? widget.remark : '恭喜发财',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
_rpTypeLabel(widget.rpType),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 统计行
|
||||
if (_detail != null) _SummaryRow(detail: _detail!),
|
||||
const Divider(height: 1),
|
||||
// 领取列表
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorView(error: _error!, onRetry: _load)
|
||||
: _detail == null || _detail!.records.isEmpty
|
||||
? const _EmptyView()
|
||||
: ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _detail!.records.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (_, i) =>
|
||||
_RecordTile(record: _detail!.records[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _rpTypeLabel(String rpType) => switch (rpType) {
|
||||
'STANDARD_RP' => '普通红包',
|
||||
'LUCKY_RP' => '拼手气红包',
|
||||
'MINE_RP' => '地雷红包',
|
||||
'NN_RP' => '牛牛红包',
|
||||
_ => '红包',
|
||||
};
|
||||
}
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
const _SummaryRow({required this.detail});
|
||||
final RpDetailData detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_Stat(
|
||||
label: '总金额',
|
||||
value: '¥${detail.totalAmount}',
|
||||
theme: theme,
|
||||
),
|
||||
_VerticalDivider(),
|
||||
_Stat(
|
||||
label: '已领',
|
||||
value: '¥${detail.receivedAmount}',
|
||||
valueColor: const Color(0xFFE8531E),
|
||||
theme: theme,
|
||||
),
|
||||
_VerticalDivider(),
|
||||
_Stat(
|
||||
label: '领取进度',
|
||||
value: '${detail.receivedNum}/${detail.totalNum}',
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerticalDivider extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
height: 28,
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
);
|
||||
}
|
||||
|
||||
class _Stat extends StatelessWidget {
|
||||
const _Stat({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.theme,
|
||||
this.valueColor,
|
||||
});
|
||||
final String label;
|
||||
final String value;
|
||||
final ThemeData theme;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(label,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _RecordTile extends StatelessWidget {
|
||||
const _RecordTile({required this.record});
|
||||
final RpRecordItem record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final avatarUrl = CdnUrlResolver.resolve(record.avatarUrl);
|
||||
final time = _formatTime(record.grabTime);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: record.avatarUrl.isNotEmpty
|
||||
? NetworkImage(avatarUrl)
|
||||
: null,
|
||||
child: record.avatarUrl.isEmpty
|
||||
? Text(
|
||||
record.nickname.isNotEmpty
|
||||
? record.nickname[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
record.nickname.isNotEmpty ? record.nickname : 'Unknown',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(time, style: theme.textTheme.bodySmall),
|
||||
trailing: Text(
|
||||
'¥${record.amount}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFE8531E),
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(int unixSec) {
|
||||
if (unixSec <= 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(unixSec * 1000);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inSeconds < 60) return '${diff.inSeconds}秒前';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inHours < 24) return '${diff.inHours}小时前';
|
||||
return '${dt.month}-${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyView extends StatelessWidget {
|
||||
const _EmptyView();
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.sentiment_dissatisfied_outlined,
|
||||
size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 8),
|
||||
Text('还没有人领取', style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.error, required this.onRetry});
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 8),
|
||||
Text(error,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(onPressed: onRetry, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||
|
||||
/// 红包类型
|
||||
///
|
||||
/// NN_RP 由游戏自动触发,不在发包 UI 显示。
|
||||
enum _RpType {
|
||||
standard('STANDARD_RP', '普通红包', '每人金额相同'),
|
||||
lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发');
|
||||
lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发'),
|
||||
mine('MINE_RP', '地雷红包', '一人中雷,赔付红包');
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
@@ -81,6 +84,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
final _amountCtrl = TextEditingController();
|
||||
final _remarkCtrl = TextEditingController(text: '恭喜发财');
|
||||
final _numCtrl = TextEditingController(text: '1');
|
||||
final _mineAmountCtrl = TextEditingController();
|
||||
|
||||
_RpType _rpType = _RpType.standard;
|
||||
bool _sending = false;
|
||||
@@ -91,6 +95,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
_amountCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
_numCtrl.dispose();
|
||||
_mineAmountCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -201,6 +206,21 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 地雷金额(仅 MINE_RP)
|
||||
if (_rpType == _RpType.mine) ...[
|
||||
Text('地雷金额', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _mineAmountCtrl,
|
||||
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),
|
||||
@@ -251,6 +271,16 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
String? mineAmount;
|
||||
if (_rpType == _RpType.mine) {
|
||||
final ma = _mineAmountCtrl.text.trim();
|
||||
if (ma.isEmpty || double.tryParse(ma) == null) {
|
||||
setState(() => _error = '请输入有效地雷金额');
|
||||
return;
|
||||
}
|
||||
mineAmount = double.parse(ma).toStringAsFixed(2);
|
||||
}
|
||||
|
||||
setState(() { _sending = true; _error = null; });
|
||||
|
||||
try {
|
||||
@@ -264,6 +294,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
remark: _remarkCtrl.text.trim().isNotEmpty
|
||||
? _remarkCtrl.text.trim()
|
||||
: '恭喜发财',
|
||||
mineAmount: mineAmount,
|
||||
);
|
||||
|
||||
// 构建 typ=8 消息 content
|
||||
@@ -276,6 +307,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
'total_amount': double.parse(amount).toStringAsFixed(2),
|
||||
'total_num': num,
|
||||
'rp_status': 0,
|
||||
if (mineAmount != null) 'mine_amount': mineAmount,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
import 'package:im_app/features/chat/view/video_player_page.dart';
|
||||
|
||||
/// 视频消息气泡(typ = 4 / typ = 24)
|
||||
///
|
||||
@@ -54,8 +55,24 @@ class VideoMessageBubble extends StatelessWidget {
|
||||
final resolvedThumb =
|
||||
thumbUrl.isNotEmpty ? CdnUrlResolver.resolve(thumbUrl) : null;
|
||||
|
||||
final resolvedVideo = videoUrl.isNotEmpty
|
||||
? CdnUrlResolver.resolve(videoUrl)
|
||||
: '';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: videoUrl.isNotEmpty ? () => onTap?.call(videoUrl) : null,
|
||||
onTap: resolvedVideo.isNotEmpty
|
||||
? () {
|
||||
if (onTap != null) {
|
||||
onTap!.call(videoUrl);
|
||||
} else {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VideoPlayerPage(url: resolvedVideo),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
|
||||
Reference in New Issue
Block a user