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:
pp-bot
2026-03-24 12:53:55 +09:00
parent e715a0673b
commit 23fc6b0c86
17 changed files with 1068 additions and 59 deletions

View File

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

View File

@@ -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,

View File

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

View File

@@ -26,10 +26,11 @@ class SendRedEnvelopeUseCase {
/// [chatId] 会话 ID
/// [chatType] 1=私聊 / 2=群聊
/// [workspaceId] 群 workspaceId0 表示非工作台群)
/// [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,
),
);

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

View File

@@ -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,

View File

@@ -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 → 触发下载

View File

@@ -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' => '🐉',

View File

@@ -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(

View File

@@ -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('重试')),
],
),
);
}

View File

@@ -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) {

View File

@@ -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(