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:
@@ -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';
|
||||||
|
|||||||
117
apps/im_app/lib/core/services/audio_playback_service.dart
Normal file
117
apps/im_app/lib/core/services/audio_playback_service.dart
Normal 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,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/data/remote/red_envelope_request.dart';
|
||||||
|
|
||||||
|
/// 获取红包领取详情 UseCase
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #29 / iOS RedEnvelopeDetailSheet 领取排行榜数据源
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// final detail = await ref.read(getRpDetailUseCaseProvider)
|
||||||
|
/// .execute(rpId: parsedContent['id']);
|
||||||
|
/// ```
|
||||||
|
class GetRpDetailUseCase {
|
||||||
|
final NetworksSdkApi _api;
|
||||||
|
|
||||||
|
const GetRpDetailUseCase({required NetworksSdkApi api}) : _api = api;
|
||||||
|
|
||||||
|
Future<RpDetailData> execute({required String rpId}) async {
|
||||||
|
final result = await _api.executeRequest(
|
||||||
|
GetRpDetailRequest(rpId: rpId),
|
||||||
|
);
|
||||||
|
if (result == null) throw Exception('获取红包详情失败');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,11 @@ class SendRedEnvelopeUseCase {
|
|||||||
/// [chatId] 会话 ID
|
/// [chatId] 会话 ID
|
||||||
/// [chatType] 1=私聊 / 2=群聊
|
/// [chatType] 1=私聊 / 2=群聊
|
||||||
/// [workspaceId] 群 workspaceId(0 表示非工作台群)
|
/// [workspaceId] 群 workspaceId(0 表示非工作台群)
|
||||||
/// [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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
119
apps/im_app/lib/features/chat/view/video_player_page.dart
Normal file
119
apps/im_app/lib/features/chat/view/video_player_page.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:chewie/chewie.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
/// 视频全屏播放页
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #32 / iOS VideoPlayerViewController
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// // VideoMessageBubble 下载完成后
|
||||||
|
/// Navigator.of(context).push(
|
||||||
|
/// MaterialPageRoute(
|
||||||
|
/// builder: (_) => VideoPlayerPage(url: localPath, title: '视频'),
|
||||||
|
/// ),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class VideoPlayerPage extends StatefulWidget {
|
||||||
|
const VideoPlayerPage({super.key, required this.url, this.title = '视频'});
|
||||||
|
|
||||||
|
/// 本地文件路径("/"开头)或 HTTP URL
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPlayerPageState extends State<VideoPlayerPage> {
|
||||||
|
VideoPlayerController? _videoController;
|
||||||
|
ChewieController? _chewieController;
|
||||||
|
bool _hasError = false;
|
||||||
|
String? _errorMsg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
try {
|
||||||
|
final controller = widget.url.startsWith('/')
|
||||||
|
? VideoPlayerController.file(File(widget.url))
|
||||||
|
: VideoPlayerController.networkUrl(Uri.parse(widget.url));
|
||||||
|
|
||||||
|
await controller.initialize();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
await controller.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final chewie = ChewieController(
|
||||||
|
videoPlayerController: controller,
|
||||||
|
autoPlay: true,
|
||||||
|
allowFullScreen: true,
|
||||||
|
allowMuting: true,
|
||||||
|
showControls: true,
|
||||||
|
materialProgressColors: ChewieProgressColors(
|
||||||
|
playedColor: Theme.of(context).colorScheme.primary,
|
||||||
|
handleColor: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_videoController = controller;
|
||||||
|
_chewieController = chewie;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hasError = true;
|
||||||
|
_errorMsg = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chewieController?.dispose();
|
||||||
|
_videoController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(widget.title, style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
body: _hasError
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.white54, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_errorMsg ?? '播放失败',
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _chewieController != null
|
||||||
|
? Chewie(controller: _chewieController!)
|
||||||
|
: const Center(child: CircularProgressIndicator(color: Colors.white)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,30 +4,21 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package: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,
|
||||||
|
|||||||
@@ -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 → 触发下载
|
||||||
|
|||||||
@@ -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' => '🐉',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/data/remote/red_envelope_request.dart';
|
||||||
|
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||||
|
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||||
|
|
||||||
|
/// 红包领取排行榜详情页
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #29 / iOS RedEnvelopeDetailSheet
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// RedEnvelopeDetailSheet.show(context: context, rpId: '...', remark: '恭喜发财');
|
||||||
|
/// ```
|
||||||
|
class RedEnvelopeDetailSheet extends ConsumerStatefulWidget {
|
||||||
|
const RedEnvelopeDetailSheet({
|
||||||
|
super.key,
|
||||||
|
required this.rpId,
|
||||||
|
required this.remark,
|
||||||
|
this.rpType = 'STANDARD_RP',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String rpId;
|
||||||
|
final String remark;
|
||||||
|
final String rpType;
|
||||||
|
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required String rpId,
|
||||||
|
required String remark,
|
||||||
|
String rpType = 'STANDARD_RP',
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (_) => RedEnvelopeDetailSheet(
|
||||||
|
rpId: rpId,
|
||||||
|
remark: remark,
|
||||||
|
rpType: rpType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RedEnvelopeDetailSheet> createState() =>
|
||||||
|
_RedEnvelopeDetailSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RedEnvelopeDetailSheetState
|
||||||
|
extends ConsumerState<RedEnvelopeDetailSheet> {
|
||||||
|
RpDetailData? _detail;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final data = await ref
|
||||||
|
.read(getRpDetailUseCaseProvider)
|
||||||
|
.execute(rpId: widget.rpId);
|
||||||
|
if (mounted) setState(() { _detail = data; _loading = false; });
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _error = e.toString(); _loading = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.65,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.92,
|
||||||
|
builder: (_, scrollController) => Column(
|
||||||
|
children: [
|
||||||
|
// 拖动手柄
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 标题
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFE8531E),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.card_giftcard,
|
||||||
|
color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.remark.isNotEmpty ? widget.remark : '恭喜发财',
|
||||||
|
style: theme.textTheme.titleSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_rpTypeLabel(widget.rpType),
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 统计行
|
||||||
|
if (_detail != null) _SummaryRow(detail: _detail!),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// 领取列表
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? _ErrorView(error: _error!, onRetry: _load)
|
||||||
|
: _detail == null || _detail!.records.isEmpty
|
||||||
|
? const _EmptyView()
|
||||||
|
: ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: _detail!.records.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(height: 1, indent: 72),
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
_RecordTile(record: _detail!.records[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _rpTypeLabel(String rpType) => switch (rpType) {
|
||||||
|
'STANDARD_RP' => '普通红包',
|
||||||
|
'LUCKY_RP' => '拼手气红包',
|
||||||
|
'MINE_RP' => '地雷红包',
|
||||||
|
'NN_RP' => '牛牛红包',
|
||||||
|
_ => '红包',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryRow extends StatelessWidget {
|
||||||
|
const _SummaryRow({required this.detail});
|
||||||
|
final RpDetailData detail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_Stat(
|
||||||
|
label: '总金额',
|
||||||
|
value: '¥${detail.totalAmount}',
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
_VerticalDivider(),
|
||||||
|
_Stat(
|
||||||
|
label: '已领',
|
||||||
|
value: '¥${detail.receivedAmount}',
|
||||||
|
valueColor: const Color(0xFFE8531E),
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
_VerticalDivider(),
|
||||||
|
_Stat(
|
||||||
|
label: '领取进度',
|
||||||
|
value: '${detail.receivedNum}/${detail.totalNum}',
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VerticalDivider extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
height: 28,
|
||||||
|
width: 1,
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Stat extends StatelessWidget {
|
||||||
|
const _Stat({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.theme,
|
||||||
|
this.valueColor,
|
||||||
|
});
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final ThemeData theme;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: valueColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(label,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordTile extends StatelessWidget {
|
||||||
|
const _RecordTile({required this.record});
|
||||||
|
final RpRecordItem record;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final avatarUrl = CdnUrlResolver.resolve(record.avatarUrl);
|
||||||
|
final time = _formatTime(record.grabTime);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundImage: record.avatarUrl.isNotEmpty
|
||||||
|
? NetworkImage(avatarUrl)
|
||||||
|
: null,
|
||||||
|
child: record.avatarUrl.isEmpty
|
||||||
|
? Text(
|
||||||
|
record.nickname.isNotEmpty
|
||||||
|
? record.nickname[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
record.nickname.isNotEmpty ? record.nickname : 'Unknown',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(time, style: theme.textTheme.bodySmall),
|
||||||
|
trailing: Text(
|
||||||
|
'¥${record.amount}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFFE8531E),
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int unixSec) {
|
||||||
|
if (unixSec <= 0) return '';
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(unixSec * 1000);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
if (diff.inSeconds < 60) return '${diff.inSeconds}秒前';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}小时前';
|
||||||
|
return '${dt.month}-${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyView extends StatelessWidget {
|
||||||
|
const _EmptyView();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sentiment_dissatisfied_outlined,
|
||||||
|
size: 48, color: Colors.grey),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('还没有人领取', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
const _ErrorView({required this.error, required this.onRetry});
|
||||||
|
final String error;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Colors.grey),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(error,
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
166
apps/im_app/lib/features/miniapp/miniapp_webview_page.dart
Normal file
166
apps/im_app/lib/features/miniapp/miniapp_webview_page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user