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

@@ -36,6 +36,7 @@ class ApiPaths {
static const rpSend = '/payment/rp/send'; static const rpSend = '/payment/rp/send';
static const rpReceive = '/app/api/wallet/rp/receive'; static const rpReceive = '/app/api/wallet/rp/receive';
static const rpConfigGet = '/payment/rp-config/get'; static const rpConfigGet = '/payment/rp-config/get';
static const rpDetail = '/payment/rp/detail';
// ── Game Banner ── // ── Game Banner ──
static const bannerGet = '/lucky/banner/get'; static const bannerGet = '/lucky/banner/get';

View File

@@ -0,0 +1,117 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 音频播放状态
///
/// 对应 Gitea issue #31
class AudioPlaybackState {
/// 当前加载的本地文件路径null = 空闲)
final String? currentUrl;
final bool isPlaying;
final Duration position;
final Duration totalDuration;
const AudioPlaybackState({
this.currentUrl,
this.isPlaying = false,
this.position = Duration.zero,
this.totalDuration = Duration.zero,
});
bool isPlayingUrl(String url) => currentUrl == url && isPlaying;
bool isLoadedUrl(String url) => currentUrl == url;
}
/// 音频播放服务(全局单例,每次只允许一条音频播放)
///
/// ## 使用
///
/// ```dart
/// // AudioMessageBubble
/// final state = ref.watch(audioPlaybackServiceProvider);
/// final isPlaying = state.isPlayingUrl(localPath);
///
/// // 点击播放/暂停
/// ref.read(audioPlaybackServiceProvider.notifier).togglePlay(localPath);
/// ```
class AudioPlaybackService extends Notifier<AudioPlaybackState> {
late final AudioPlayer _player;
@override
AudioPlaybackState build() {
_player = AudioPlayer();
_player.onPlayerStateChanged.listen((playerState) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: playerState == PlayerState.playing,
position: state.position,
totalDuration: state.totalDuration,
);
});
_player.onPositionChanged.listen((pos) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: state.isPlaying,
position: pos,
totalDuration: state.totalDuration,
);
});
_player.onDurationChanged.listen((dur) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: state.isPlaying,
position: state.position,
totalDuration: dur,
);
});
_player.onPlayerComplete.listen((_) {
state = AudioPlaybackState(
currentUrl: state.currentUrl,
isPlaying: false,
position: Duration.zero,
totalDuration: state.totalDuration,
);
});
ref.onDispose(_player.dispose);
return const AudioPlaybackState();
}
/// 播放/暂停 [localPath] 对应的音频文件
///
/// - 若当前已在播放同一文件:暂停
/// - 若当前已暂停同一文件:继续
/// - 否则:停止旧文件,播放新文件
Future<void> togglePlay(String localPath) async {
if (state.currentUrl == localPath && state.isPlaying) {
await _player.pause();
} else if (state.currentUrl == localPath && !state.isPlaying) {
await _player.resume();
} else {
await _player.stop();
state = const AudioPlaybackState();
state = AudioPlaybackState(currentUrl: localPath);
await _player.play(DeviceFileSource(localPath));
}
}
/// 停止播放并重置状态
Future<void> stop() async {
await _player.stop();
state = const AudioPlaybackState();
}
/// 跳转到指定位置
Future<void> seek(Duration position) async {
await _player.seek(position);
}
}
final audioPlaybackServiceProvider =
NotifierProvider<AudioPlaybackService, AudioPlaybackState>(
AudioPlaybackService.new,
);

View File

@@ -18,7 +18,7 @@ class SendRpData {
/// 发送红包请求 /// 发送红包请求
/// ///
/// 对应 Gitea issue #19 + #22 /// 对应 Gitea issue #19 + #22 + #30
/// ///
/// ## currencyType 规则(#19 bug fix /// ## currencyType 规则(#19 bug fix
/// ///
@@ -26,6 +26,10 @@ class SendRpData {
/// Flutter 修复:`currencyType` 由调用方传入UseCase 层根据 workspaceId 动态决定: /// Flutter 修复:`currencyType` 由调用方传入UseCase 层根据 workspaceId 动态决定:
/// - workspaceId > 0 → 从 `/workspace/workspace/get` 取 workspace.currency /// - workspaceId > 0 → 从 `/workspace/workspace/get` 取 workspace.currency
/// - workspaceId == 0 → 默认 `"PEA"` /// - workspaceId == 0 → 默认 `"PEA"`
///
/// ## mineAmount#30 MINE_RP
///
/// 地雷红包时附带 `mineAmount` 参数null 时不传。
class SendRpRequest extends ApiRequestable<SendRpData> { class SendRpRequest extends ApiRequestable<SendRpData> {
final String amount; final String amount;
final String currencyType; final String currencyType;
@@ -36,6 +40,7 @@ class SendRpRequest extends ApiRequestable<SendRpData> {
final int rpNum; final int rpNum;
final String remark; final String remark;
final int msgSendTime; final int msgSendTime;
final String? mineAmount;
const SendRpRequest({ const SendRpRequest({
required this.amount, required this.amount,
@@ -47,6 +52,7 @@ class SendRpRequest extends ApiRequestable<SendRpData> {
required this.rpNum, required this.rpNum,
required this.remark, required this.remark,
required this.msgSendTime, required this.msgSendTime,
this.mineAmount,
}); });
@override @override
@@ -66,6 +72,8 @@ class SendRpRequest extends ApiRequestable<SendRpData> {
'rpNum': rpNum, 'rpNum': rpNum,
'remark': remark, 'remark': remark,
'msgSendTime': msgSendTime, 'msgSendTime': msgSendTime,
if (mineAmount != null && mineAmount!.isNotEmpty)
'mineAmount': mineAmount,
}; };
@override @override
@@ -268,6 +276,107 @@ class WorkspaceData {
} }
} }
// ── 红包领取详情 ───────────────────────────────────────────────────────────────
/// 单条领取记录
class RpRecordItem {
final int userId;
final String nickname;
final String avatarUrl;
final String amount;
final int grabTime;
const RpRecordItem({
required this.userId,
required this.nickname,
required this.avatarUrl,
required this.amount,
required this.grabTime,
});
factory RpRecordItem.fromJson(Map<String, dynamic> json) {
return RpRecordItem(
userId: json['userId'] as int? ?? 0,
nickname: json['nickname'] as String? ?? '',
avatarUrl: json['avatarUrl'] as String? ?? '',
amount: (json['amount'] ?? '0').toString(),
grabTime: json['grabTime'] as int? ?? 0,
);
}
}
/// 红包领取详情响应
///
/// 对应 Gitea issue #29
class RpDetailData {
final String rpId;
final String rpType;
final String remark;
final String totalAmount;
final int totalNum;
final String receivedAmount;
final int receivedNum;
final int status;
final List<RpRecordItem> records;
const RpDetailData({
required this.rpId,
required this.rpType,
required this.remark,
required this.totalAmount,
required this.totalNum,
required this.receivedAmount,
required this.receivedNum,
required this.status,
required this.records,
});
factory RpDetailData.fromJson(Map<String, dynamic> json) {
final raw = json['records'];
final records = raw is List
? raw.whereType<Map<String, dynamic>>().map(RpRecordItem.fromJson).toList()
: <RpRecordItem>[];
return RpDetailData(
rpId: json['rpId'] as String? ?? '',
rpType: json['rpType'] as String? ?? '',
remark: json['remark'] as String? ?? '恭喜发财',
totalAmount: (json['totalAmount'] ?? '0').toString(),
totalNum: json['totalNum'] as int? ?? 0,
receivedAmount: (json['receivedAmount'] ?? '0').toString(),
receivedNum: json['receivedNum'] as int? ?? 0,
status: json['status'] as int? ?? 0,
records: records,
);
}
}
/// 获取红包领取详情请求
///
/// GET /payment/rp/detail?rpId=xxx
class GetRpDetailRequest extends ApiRequestable<RpDetailData> {
final String rpId;
const GetRpDetailRequest({required this.rpId});
@override
String get path => ApiPaths.rpDetail;
@override
HttpMethod get method => HttpMethod.get;
@override
Map<String, dynamic> get parameters => {'rpId': rpId};
@override
RpDetailData? decodeResponse(dynamic response) {
final data = (response as dynamic).data;
if (data is! Map<String, dynamic>) return null;
return RpDetailData.fromJson(data);
}
}
// ── Workspace ─────────────────────────────────────────────────────────────────
/// 获取 Workspace 信息请求 /// 获取 Workspace 信息请求
class GetWorkspaceRequest extends ApiRequestable<WorkspaceData> { class GetWorkspaceRequest extends ApiRequestable<WorkspaceData> {
final int workspaceId; final int workspaceId;

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/send_red_envelope_usecase.dart';
import 'package:im_app/features/chat/usecases/receive_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/fetch_banner_usecase.dart';
import 'package:im_app/features/chat/usecases/get_rp_detail_usecase.dart';
/// 发送红包 UseCase Provider /// 发送红包 UseCase Provider
final sendRedEnvelopeUseCaseProvider = Provider<SendRedEnvelopeUseCase>((ref) { final sendRedEnvelopeUseCaseProvider = Provider<SendRedEnvelopeUseCase>((ref) {
@@ -20,3 +21,8 @@ final receiveRedEnvelopeUseCaseProvider =
final fetchBannerUseCaseProvider = Provider<FetchBannerUseCase>((ref) { final fetchBannerUseCaseProvider = Provider<FetchBannerUseCase>((ref) {
return FetchBannerUseCase(api: ref.read(networkSdkApiProvider)); 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 { class BannerState {
final String gameId; final String gameId;
final String gameName; final String gameName;
/// 小程序 appId用于 WebView URL 构造)
final String appId;
final String? lastResult; final String? lastResult;
final BannerGameStatus status; final BannerGameStatus status;
final int countdownSeconds; final int countdownSeconds;
@@ -19,6 +21,7 @@ class BannerState {
const BannerState({ const BannerState({
this.gameId = '', this.gameId = '',
this.gameName = '', this.gameName = '',
this.appId = '',
this.lastResult, this.lastResult,
this.status = BannerGameStatus.idle, this.status = BannerGameStatus.idle,
this.countdownSeconds = 0, this.countdownSeconds = 0,
@@ -29,6 +32,7 @@ class BannerState {
BannerState copyWith({ BannerState copyWith({
String? gameId, String? gameId,
String? gameName, String? gameName,
String? appId,
String? lastResult, String? lastResult,
BannerGameStatus? status, BannerGameStatus? status,
int? countdownSeconds, int? countdownSeconds,
@@ -38,6 +42,7 @@ class BannerState {
return BannerState( return BannerState(
gameId: gameId ?? this.gameId, gameId: gameId ?? this.gameId,
gameName: gameName ?? this.gameName, gameName: gameName ?? this.gameName,
appId: appId ?? this.appId,
lastResult: lastResult ?? this.lastResult, lastResult: lastResult ?? this.lastResult,
status: status ?? this.status, status: status ?? this.status,
countdownSeconds: countdownSeconds ?? this.countdownSeconds, countdownSeconds: countdownSeconds ?? this.countdownSeconds,
@@ -139,6 +144,7 @@ class BannerViewModel extends Notifier<BannerState> {
state = state.copyWith( state = state.copyWith(
gameId: bean.gameId.isNotEmpty ? bean.gameId : state.gameId, gameId: bean.gameId.isNotEmpty ? bean.gameId : state.gameId,
gameName: bean.gameName.isNotEmpty ? bean.gameName : state.gameName, gameName: bean.gameName.isNotEmpty ? bean.gameName : state.gameName,
appId: (bean.appId != null && bean.appId!.isNotEmpty) ? bean.appId : state.appId,
lastResult: lastResult, lastResult: lastResult,
status: isOpen ? BannerGameStatus.open : BannerGameStatus.close, status: isOpen ? BannerGameStatus.open : BannerGameStatus.close,
countdownSeconds: countdown, 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 /// [chatId] 会话 ID
/// [chatType] 1=私聊 / 2=群聊 /// [chatType] 1=私聊 / 2=群聊
/// [workspaceId] 群 workspaceId0 表示非工作台群) /// [workspaceId] 群 workspaceId0 表示非工作台群)
/// [rpType] "STANDARD_RP" | "LUCKY_RP"(初期支持,后续扩展 MINE_RP / NN_RP /// [rpType] "STANDARD_RP" | "LUCKY_RP" | "MINE_RP"NN_RP 由游戏自动触发
/// [amount] 总金额字符串,如 "10.00" /// [amount] 总金额字符串,如 "10.00"
/// [rpNum] 红包数量 /// [rpNum] 红包数量
/// [remark] 红包祝福语 /// [remark] 红包祝福语
/// [mineAmount] 地雷金额(仅 MINE_RP 时传入,如 "5.00"
Future<String> execute({ Future<String> execute({
required int chatId, required int chatId,
required int chatType, required int chatType,
@@ -38,6 +39,7 @@ class SendRedEnvelopeUseCase {
required String amount, required String amount,
required int rpNum, required int rpNum,
required String remark, required String remark,
String? mineAmount,
}) async { }) async {
// #19 fix: 根据 workspaceId 动态决定 currencyType // #19 fix: 根据 workspaceId 动态决定 currencyType
final currencyType = await _resolveCurrencyType(workspaceId); final currencyType = await _resolveCurrencyType(workspaceId);
@@ -53,6 +55,7 @@ class SendRedEnvelopeUseCase {
rpNum: rpNum, rpNum: rpNum,
remark: remark, remark: remark,
msgSendTime: DateTime.now().microsecondsSinceEpoch, 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/services/file_download_manager.dart'; import 'package:im_app/core/services/file_download_manager.dart';
import 'package:im_app/core/services/audio_playback_service.dart';
/// 语音消息气泡typ = 3 /// 语音消息气泡typ = 3
/// ///
/// 对应 Gitea issue #17 / iOS VoiceMessageBubble + AudioPlaybackService /// 对应 Gitea issue #17 + #31
/// ///
/// ## 数据格式 /// ## 数据格式
/// ///
/// rawContent JSON`{ "url": "Voice/xxx.m4a", "duration": 5 }` /// rawContent JSON`{ "url": "Voice/xxx.m4a", "duration": 5 }`
/// ///
/// ## 当前实现 /// ## 功能
/// ///
/// 下载框架已实现,播放能力待 audioplayers 包接入: /// 1. 点击 → 触发 FileDownloadManager 下载语音文件
/// 1. 点击播放按钮 → 通过 FileDownloadManager 下载语音文件 /// 2. 下载完成后点击 → AudioPlaybackService.togglePlay(localPath) 播放/暂停
/// 2. 下载完成 → TODO: AudioPlayer().play(DeviceFileSource(localPath)) /// 3. 播放时显示位置/总时长,波形激活态
///
/// ## 接入 audioplayers
///
/// ```yaml
/// # pubspec.yaml
/// dependencies:
/// audioplayers: ^6.0.0
/// ```
///
/// 解开 TODO 注释即可完成播放功能。
class AudioMessageBubble extends ConsumerWidget { class AudioMessageBubble extends ConsumerWidget {
const AudioMessageBubble({ const AudioMessageBubble({
super.key, super.key,
@@ -39,7 +30,7 @@ class AudioMessageBubble extends ConsumerWidget {
final String rawContent; final String rawContent;
final String messageId; final String messageId;
/// 是否为自己发送的消息(影响气泡方向 /// 是否为自己发送的消息(影响气泡颜色
final bool isSelf; final bool isSelf;
@override @override
@@ -53,15 +44,19 @@ class AudioMessageBubble extends ConsumerWidget {
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ?? ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
const FileDownloadIdle(); 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 theme = Theme.of(context);
final isDownloading = downloadState is FileDownloadProgress; final isDownloading = downloadState is FileDownloadProgress;
final isDownloaded = downloadState is FileDownloadDone;
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: isDownloading || url.isEmpty onTap: isDownloading || url.isEmpty
? null ? null
: () => _handleTap(ref, url, fileName, downloadState), : () => _handleTap(ref, url, fileName, downloadState, localPath),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: const BoxConstraints(minWidth: 100, maxWidth: 220), constraints: const BoxConstraints(minWidth: 100, maxWidth: 220),
@@ -74,14 +69,14 @@ class AudioMessageBubble extends ConsumerWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildLeadingIcon(context, downloadState), _buildLeadingIcon(context, downloadState, isPlaying),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 时长 / 进度 // 时长 / 播放进度 / 下载进度
if (isDownloading) if (isDownloading)
Text( Text(
'${((downloadState as FileDownloadProgress).progress * 100).toStringAsFixed(0)}%', '${((downloadState as FileDownloadProgress).progress * 100).toStringAsFixed(0)}%',
@@ -89,16 +84,23 @@ class AudioMessageBubble extends ConsumerWidget {
color: isSelf ? Colors.white70 : Colors.grey, 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 else
Text( Text(
duration > 0 ? "${duration}''" : "语音", duration > 0 ? "$duration''" : '语音',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: isSelf ? Colors.white : null, color: isSelf ? Colors.white : null,
), ),
), ),
// 波形装饰(静态占位) // 波形装饰
const SizedBox(height: 2), 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) { Widget _buildLeadingIcon(
final isSelfBubble = isSelf; BuildContext context,
final iconColor = isSelfBubble ? Colors.white : Theme.of(context).colorScheme.primary; FileDownloadState state,
bool isPlaying,
) {
final iconColor =
isSelf ? Colors.white : Theme.of(context).colorScheme.primary;
switch (state) { switch (state) {
case FileDownloadIdle(): case FileDownloadIdle():
@@ -126,8 +132,11 @@ class AudioMessageBubble extends ConsumerWidget {
), ),
); );
case FileDownloadDone(): case FileDownloadDone():
// TODO: AudioPlaybackService 接入后区分 playing / paused 态 return Icon(
return Icon(Icons.pause_circle_outline, size: 28, color: iconColor); isPlaying ? Icons.pause_circle : Icons.play_circle,
size: 28,
color: iconColor,
);
case FileDownloadFailed(): case FileDownloadFailed():
return Icon(Icons.error_outline, size: 28, color: Colors.red.shade300); return Icon(Icons.error_outline, size: 28, color: Colors.red.shade300);
} }
@@ -138,12 +147,10 @@ class AudioMessageBubble extends ConsumerWidget {
String url, String url,
String fileName, String fileName,
FileDownloadState state, FileDownloadState state,
String? localPath,
) { ) {
if (state is FileDownloadDone) { if (state is FileDownloadDone && localPath != null) {
// TODO: 接入 audioplayers 后播放: ref.read(audioPlaybackServiceProvider.notifier).togglePlay(localPath);
// import 'package:audioplayers/audioplayers.dart';
// final player = AudioPlayer();
// await player.play(DeviceFileSource(state.localPath));
return; return;
} }
ref.read(fileDownloadManagerProvider.notifier).download( 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) { Map<String, dynamic> _parseContent(String raw) {
try { try {
return jsonDecode(raw) as Map<String, dynamic>; return jsonDecode(raw) as Map<String, dynamic>;
@@ -161,7 +175,7 @@ class AudioMessageBubble extends ConsumerWidget {
} }
} }
/// 静态波形装饰(未接入音频分析时的占位) /// 静态波形装饰
class _WaveformDecoration extends StatelessWidget { class _WaveformDecoration extends StatelessWidget {
const _WaveformDecoration({required this.isSelf, required this.isPlaying}); 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 baseColor = isSelf ? Colors.white54 : Colors.grey.shade400;
final activeColor = final activeColor =
isSelf ? Colors.white : Theme.of(context).colorScheme.primary; 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( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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'; 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', /// fileName: 'report.pdf',
/// fileSize: 1234567, /// fileSize: 1234567,
/// onTap: (localPath) async { /// 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, BuildContext context,
WidgetRef ref, WidgetRef ref,
FileDownloadState state, FileDownloadState state,
) { ) async {
if (state is FileDownloadDone) { if (state is FileDownloadDone) {
onTap?.call(state.localPath); onTap?.call(state.localPath);
// TODO: open_filex 接入后: final result = await OpenFilex.open(state.localPath);
// import 'package:open_filex/open_filex.dart'; if (result.type != ResultType.done && context.mounted) {
// await OpenFilex.open(state.localPath); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('无法打开文件:${result.message}')),
);
}
return; return;
} }
// idle / failed → 触发下载 // idle / failed → 触发下载

View File

@@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/banner_view_model.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, /// right: 16,
/// bottom: 80, // 高于输入框 /// bottom: 80, // 高于输入框
/// child: MiniAppFloatButton( /// child: MiniAppFloatButton(
/// onTap: () { /// chatId: chatId,
/// // TODO #25: 打开小程序 WebView /// chatType: chatType,
/// // MiniAppRouter.open(context, gameId: bannerState.gameId);
/// },
/// ), /// ),
/// ) /// )
/// ``` /// ```
class MiniAppFloatButton extends ConsumerWidget { 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; final VoidCallback? onTap;
@override @override
@@ -42,7 +50,16 @@ class MiniAppFloatButton extends ConsumerWidget {
scale: state.hasGame ? 1.0 : 0.0, scale: state.hasGame ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: GestureDetector( 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( child: Container(
width: 48, width: 48,
height: 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) { String _gameEmoji(String gameId) => switch (gameId) {
'bjl' => '🃏', 'bjl' => '🃏',
'lp' || 'lh' => '🐉', 'lp' || 'lh' => '🐉',

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/di/red_envelope_provider.dart';
import 'package:im_app/features/chat/view/widgets/red_envelope_detail_sheet.dart';
/// 红包消息气泡typ = 8 /// 红包消息气泡typ = 8
/// ///
@@ -59,8 +60,23 @@ class RedEnvelopeBubble extends ConsumerWidget {
final isActive = !isClaimed && !isExpired && !isGone; final isActive = !isClaimed && !isExpired && !isGone;
return GestureDetector( return GestureDetector(
onTap: isActive && rpId.isNotEmpty onTap: rpId.isNotEmpty
? () => _claim(context, ref, rpId, rpType) ? () => 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, : null,
child: _RedEnvelopeCard( child: _RedEnvelopeCard(
remark: remark, remark: remark,
@@ -76,6 +92,7 @@ class RedEnvelopeBubble extends ConsumerWidget {
WidgetRef ref, WidgetRef ref,
String rpId, String rpId,
String rpType, String rpType,
String remark,
) async { ) async {
try { try {
final result = await ref final result = await ref
@@ -94,6 +111,16 @@ class RedEnvelopeBubble extends ConsumerWidget {
backgroundColor: result.success ? Colors.green : null, backgroundColor: result.success ? Colors.green : null,
), ),
); );
// 领取后自动打开详情排行榜
if (context.mounted) {
await RedEnvelopeDetailSheet.show(
context: context,
rpId: rpId,
remark: remark,
rpType: rpType,
);
}
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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'; import 'package:im_app/features/chat/di/red_envelope_provider.dart';
/// 红包类型 /// 红包类型
///
/// NN_RP 由游戏自动触发,不在发包 UI 显示。
enum _RpType { enum _RpType {
standard('STANDARD_RP', '普通红包', '每人金额相同'), standard('STANDARD_RP', '普通红包', '每人金额相同'),
lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发'); lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发'),
mine('MINE_RP', '地雷红包', '一人中雷,赔付红包');
final String value; final String value;
final String label; final String label;
@@ -81,6 +84,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
final _amountCtrl = TextEditingController(); final _amountCtrl = TextEditingController();
final _remarkCtrl = TextEditingController(text: '恭喜发财'); final _remarkCtrl = TextEditingController(text: '恭喜发财');
final _numCtrl = TextEditingController(text: '1'); final _numCtrl = TextEditingController(text: '1');
final _mineAmountCtrl = TextEditingController();
_RpType _rpType = _RpType.standard; _RpType _rpType = _RpType.standard;
bool _sending = false; bool _sending = false;
@@ -91,6 +95,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
_amountCtrl.dispose(); _amountCtrl.dispose();
_remarkCtrl.dispose(); _remarkCtrl.dispose();
_numCtrl.dispose(); _numCtrl.dispose();
_mineAmountCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -201,6 +206,21 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
), ),
), ),
const SizedBox(height: 12), 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)), Text('祝福语', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -251,6 +271,16 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
return; 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; }); setState(() { _sending = true; _error = null; });
try { try {
@@ -264,6 +294,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
remark: _remarkCtrl.text.trim().isNotEmpty remark: _remarkCtrl.text.trim().isNotEmpty
? _remarkCtrl.text.trim() ? _remarkCtrl.text.trim()
: '恭喜发财', : '恭喜发财',
mineAmount: mineAmount,
); );
// 构建 typ=8 消息 content // 构建 typ=8 消息 content
@@ -276,6 +307,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
'total_amount': double.parse(amount).toStringAsFixed(2), 'total_amount': double.parse(amount).toStringAsFixed(2),
'total_num': num, 'total_num': num,
'rp_status': 0, 'rp_status': 0,
if (mineAmount != null) 'mine_amount': mineAmount,
}); });
if (mounted) { if (mounted) {

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:im_app/core/services/cdn_url_resolver.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 /// 视频消息气泡typ = 4 / typ = 24
/// ///
@@ -54,8 +55,24 @@ class VideoMessageBubble extends StatelessWidget {
final resolvedThumb = final resolvedThumb =
thumbUrl.isNotEmpty ? CdnUrlResolver.resolve(thumbUrl) : null; thumbUrl.isNotEmpty ? CdnUrlResolver.resolve(thumbUrl) : null;
final resolvedVideo = videoUrl.isNotEmpty
? CdnUrlResolver.resolve(videoUrl)
: '';
return GestureDetector( 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( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Stack( child: Stack(

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/core/foundation/config.dart';
/// 小程序 WebView 页
///
/// 对应 Gitea issue #25 / iOS ChatRoomMiniAppFloatButton → WebView
///
/// ## URL 构造
///
/// `{apiBaseUrl}/miniapp/{appId}/index.html?gameId={gameId}&token={token}&chatId={chatId}&chatType={chatType}`
///
/// ## 使用
///
/// ```dart
/// MiniAppRouter.open(
/// context, ref,
/// gameId: state.gameId,
/// appId: state.appId,
/// chatId: chatId,
/// chatType: chatType,
/// );
/// ```
class MiniAppWebViewPage extends ConsumerStatefulWidget {
const MiniAppWebViewPage({
super.key,
required this.gameId,
required this.appId,
required this.chatId,
required this.chatType,
this.title = '游戏',
});
final String gameId;
final String appId;
final int chatId;
final int chatType;
final String title;
@override
ConsumerState<MiniAppWebViewPage> createState() => _MiniAppWebViewPageState();
}
class _MiniAppWebViewPageState extends ConsumerState<MiniAppWebViewPage> {
late final WebViewController _controller;
bool _isLoading = true;
String? _errorMsg;
@override
void initState() {
super.initState();
_initController();
}
void _initController() {
final token = ref.read(apiConfigProvider).token ?? '';
final url = _buildUrl(token);
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) {
if (mounted) setState(() { _isLoading = true; _errorMsg = null; });
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMsg = '加载失败:${error.description}';
});
}
},
),
)
..loadRequest(Uri.parse(url));
}
String _buildUrl(String token) {
var base = AppConfig.apiBaseUrl;
if (base.endsWith('/')) base = base.substring(0, base.length - 1);
final appId = widget.appId.isNotEmpty ? widget.appId : widget.gameId;
return '$base/miniapp/$appId/index.html'
'?gameId=${Uri.encodeComponent(widget.gameId)}'
'&token=${Uri.encodeComponent(token)}'
'&chatId=${widget.chatId}'
'&chatType=${widget.chatType}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _initController,
),
],
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
if (_errorMsg != null)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.grey),
const SizedBox(height: 12),
Text(_errorMsg!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initController,
child: const Text('重试'),
),
],
),
),
),
],
),
);
}
}
/// 小程序路由入口
///
/// 通过 [Navigator] push [MiniAppWebViewPage],保留在当前 ProviderScope 中。
class MiniAppRouter {
MiniAppRouter._();
static void open(
BuildContext context,
WidgetRef ref, {
required String gameId,
String appId = '',
required int chatId,
required int chatType,
String title = '游戏',
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => MiniAppWebViewPage(
gameId: gameId,
appId: appId.isNotEmpty ? appId : gameId,
chatId: chatId,
chatType: chatType,
title: title,
),
),
);
}
}

View File

@@ -90,6 +90,19 @@ dependencies:
# 设备信息deviceId / deviceName # 设备信息deviceId / deviceName
device_info_plus: ^11.0.0 device_info_plus: ^11.0.0
# 文件打开(#30
open_filex: ^4.4.1
# 音频播放(#31
audioplayers: ^6.1.0
# 视频播放(#32
video_player: ^2.9.2
chewie: ^1.9.0
# 小程序 WebView#25
webview_flutter: ^4.8.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: