diff --git a/apps/im_app/lib/core/foundation/api_paths.dart b/apps/im_app/lib/core/foundation/api_paths.dart index a324c17..8e4652e 100644 --- a/apps/im_app/lib/core/foundation/api_paths.dart +++ b/apps/im_app/lib/core/foundation/api_paths.dart @@ -36,6 +36,7 @@ class ApiPaths { static const rpSend = '/payment/rp/send'; static const rpReceive = '/app/api/wallet/rp/receive'; static const rpConfigGet = '/payment/rp-config/get'; + static const rpDetail = '/payment/rp/detail'; // ── Game Banner ── static const bannerGet = '/lucky/banner/get'; diff --git a/apps/im_app/lib/core/services/audio_playback_service.dart b/apps/im_app/lib/core/services/audio_playback_service.dart new file mode 100644 index 0000000..317e4a8 --- /dev/null +++ b/apps/im_app/lib/core/services/audio_playback_service.dart @@ -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 { + 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 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 stop() async { + await _player.stop(); + state = const AudioPlaybackState(); + } + + /// 跳转到指定位置 + Future seek(Duration position) async { + await _player.seek(position); + } +} + +final audioPlaybackServiceProvider = + NotifierProvider( + AudioPlaybackService.new, +); diff --git a/apps/im_app/lib/data/remote/red_envelope_request.dart b/apps/im_app/lib/data/remote/red_envelope_request.dart index 37b467c..a448cc9 100644 --- a/apps/im_app/lib/data/remote/red_envelope_request.dart +++ b/apps/im_app/lib/data/remote/red_envelope_request.dart @@ -18,7 +18,7 @@ class SendRpData { /// 发送红包请求 /// -/// 对应 Gitea issue #19 + #22 +/// 对应 Gitea issue #19 + #22 + #30 /// /// ## currencyType 规则(#19 bug fix) /// @@ -26,6 +26,10 @@ class SendRpData { /// Flutter 修复:`currencyType` 由调用方传入,UseCase 层根据 workspaceId 动态决定: /// - workspaceId > 0 → 从 `/workspace/workspace/get` 取 workspace.currency /// - workspaceId == 0 → 默认 `"PEA"` +/// +/// ## mineAmount(#30 MINE_RP) +/// +/// 地雷红包时附带 `mineAmount` 参数,null 时不传。 class SendRpRequest extends ApiRequestable { final String amount; final String currencyType; @@ -36,6 +40,7 @@ class SendRpRequest extends ApiRequestable { final int rpNum; final String remark; final int msgSendTime; + final String? mineAmount; const SendRpRequest({ required this.amount, @@ -47,6 +52,7 @@ class SendRpRequest extends ApiRequestable { required this.rpNum, required this.remark, required this.msgSendTime, + this.mineAmount, }); @override @@ -66,6 +72,8 @@ class SendRpRequest extends ApiRequestable { 'rpNum': rpNum, 'remark': remark, 'msgSendTime': msgSendTime, + if (mineAmount != null && mineAmount!.isNotEmpty) + 'mineAmount': mineAmount, }; @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 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 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 json) { + final raw = json['records']; + final records = raw is List + ? raw.whereType>().map(RpRecordItem.fromJson).toList() + : []; + 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 { + final String rpId; + + const GetRpDetailRequest({required this.rpId}); + + @override + String get path => ApiPaths.rpDetail; + + @override + HttpMethod get method => HttpMethod.get; + + @override + Map get parameters => {'rpId': rpId}; + + @override + RpDetailData? decodeResponse(dynamic response) { + final data = (response as dynamic).data; + if (data is! Map) return null; + return RpDetailData.fromJson(data); + } +} + +// ── Workspace ───────────────────────────────────────────────────────────────── + /// 获取 Workspace 信息请求 class GetWorkspaceRequest extends ApiRequestable { final int workspaceId; diff --git a/apps/im_app/lib/features/chat/di/red_envelope_provider.dart b/apps/im_app/lib/features/chat/di/red_envelope_provider.dart index 5ae0983..07873df 100644 --- a/apps/im_app/lib/features/chat/di/red_envelope_provider.dart +++ b/apps/im_app/lib/features/chat/di/red_envelope_provider.dart @@ -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((ref) { @@ -20,3 +21,8 @@ final receiveRedEnvelopeUseCaseProvider = final fetchBannerUseCaseProvider = Provider((ref) { return FetchBannerUseCase(api: ref.read(networkSdkApiProvider)); }); + +/// 红包领取详情 UseCase Provider (#29) +final getRpDetailUseCaseProvider = Provider((ref) { + return GetRpDetailUseCase(api: ref.read(networkSdkApiProvider)); +}); diff --git a/apps/im_app/lib/features/chat/presentation/banner_view_model.dart b/apps/im_app/lib/features/chat/presentation/banner_view_model.dart index 3ee52a9..360e133 100644 --- a/apps/im_app/lib/features/chat/presentation/banner_view_model.dart +++ b/apps/im_app/lib/features/chat/presentation/banner_view_model.dart @@ -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 { 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, diff --git a/apps/im_app/lib/features/chat/usecases/get_rp_detail_usecase.dart b/apps/im_app/lib/features/chat/usecases/get_rp_detail_usecase.dart new file mode 100644 index 0000000..acc4f2f --- /dev/null +++ b/apps/im_app/lib/features/chat/usecases/get_rp_detail_usecase.dart @@ -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 execute({required String rpId}) async { + final result = await _api.executeRequest( + GetRpDetailRequest(rpId: rpId), + ); + if (result == null) throw Exception('获取红包详情失败'); + return result; + } +} diff --git a/apps/im_app/lib/features/chat/usecases/send_red_envelope_usecase.dart b/apps/im_app/lib/features/chat/usecases/send_red_envelope_usecase.dart index 1f7be3d..32c84f9 100644 --- a/apps/im_app/lib/features/chat/usecases/send_red_envelope_usecase.dart +++ b/apps/im_app/lib/features/chat/usecases/send_red_envelope_usecase.dart @@ -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 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, ), ); diff --git a/apps/im_app/lib/features/chat/view/video_player_page.dart b/apps/im_app/lib/features/chat/view/video_player_page.dart new file mode 100644 index 0000000..e1b8de1 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/video_player_page.dart @@ -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 createState() => _VideoPlayerPageState(); +} + +class _VideoPlayerPageState extends State { + VideoPlayerController? _videoController; + ChewieController? _chewieController; + bool _hasError = false; + String? _errorMsg; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _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)), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart index 84e3135..71acff8 100644 --- a/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart @@ -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 _parseContent(String raw) { try { return jsonDecode(raw) as Map; @@ -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, diff --git a/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart index 1f45707..d435919 100644 --- a/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart @@ -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 _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 → 触发下载 diff --git a/apps/im_app/lib/features/chat/view/widgets/miniapp_float_button.dart b/apps/im_app/lib/features/chat/view/widgets/miniapp_float_button.dart index 173bf54..b756cf4 100644 --- a/apps/im_app/lib/features/chat/view/widgets/miniapp_float_button.dart +++ b/apps/im_app/lib/features/chat/view/widgets/miniapp_float_button.dart @@ -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' => '🐉', diff --git a/apps/im_app/lib/features/chat/view/widgets/red_envelope_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/red_envelope_bubble.dart index d418565..d1a129d 100644 --- a/apps/im_app/lib/features/chat/view/widgets/red_envelope_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/red_envelope_bubble.dart @@ -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( diff --git a/apps/im_app/lib/features/chat/view/widgets/red_envelope_detail_sheet.dart b/apps/im_app/lib/features/chat/view/widgets/red_envelope_detail_sheet.dart new file mode 100644 index 0000000..e8d77b3 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/red_envelope_detail_sheet.dart @@ -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 show({ + required BuildContext context, + required String rpId, + required String remark, + String rpType = 'STANDARD_RP', + }) { + return showModalBottomSheet( + 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 createState() => + _RedEnvelopeDetailSheetState(); +} + +class _RedEnvelopeDetailSheetState + extends ConsumerState { + RpDetailData? _detail; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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('重试')), + ], + ), + ); +} diff --git a/apps/im_app/lib/features/chat/view/widgets/send_red_envelope_sheet.dart b/apps/im_app/lib/features/chat/view/widgets/send_red_envelope_sheet.dart index d285656..3bdc826 100644 --- a/apps/im_app/lib/features/chat/view/widgets/send_red_envelope_sheet.dart +++ b/apps/im_app/lib/features/chat/view/widgets/send_red_envelope_sheet.dart @@ -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 { 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 { _amountCtrl.dispose(); _remarkCtrl.dispose(); _numCtrl.dispose(); + _mineAmountCtrl.dispose(); super.dispose(); } @@ -201,6 +206,21 @@ class _SendRedEnvelopeSheetState extends ConsumerState { ), ), 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 { 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 { remark: _remarkCtrl.text.trim().isNotEmpty ? _remarkCtrl.text.trim() : '恭喜发财', + mineAmount: mineAmount, ); // 构建 typ=8 消息 content @@ -276,6 +307,7 @@ class _SendRedEnvelopeSheetState extends ConsumerState { 'total_amount': double.parse(amount).toStringAsFixed(2), 'total_num': num, 'rp_status': 0, + if (mineAmount != null) 'mine_amount': mineAmount, }); if (mounted) { diff --git a/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart index 2f79c24..79679ee 100644 --- a/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart +++ b/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart @@ -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( + builder: (_) => VideoPlayerPage(url: resolvedVideo), + ), + ); + } + } + : null, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack( diff --git a/apps/im_app/lib/features/miniapp/miniapp_webview_page.dart b/apps/im_app/lib/features/miniapp/miniapp_webview_page.dart new file mode 100644 index 0000000..0bc7a2c --- /dev/null +++ b/apps/im_app/lib/features/miniapp/miniapp_webview_page.dart @@ -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 createState() => _MiniAppWebViewPageState(); +} + +class _MiniAppWebViewPageState extends ConsumerState { + 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( + builder: (_) => MiniAppWebViewPage( + gameId: gameId, + appId: appId.isNotEmpty ? appId : gameId, + chatId: chatId, + chatType: chatType, + title: title, + ), + ), + ); + } +} diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml index e077b26..1a68eec 100644 --- a/apps/im_app/pubspec.yaml +++ b/apps/im_app/pubspec.yaml @@ -90,6 +90,19 @@ dependencies: # 设备信息(deviceId / deviceName) 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: flutter_test: