feat(redpacket): 红包与游戏横幅全量实现 (#19~#24)
- #19 fix: SendRedEnvelopeUseCase 动态取 currencyType(workspaceId>0 取 workspace.currency,修复 iOS 硬编码 PEA → 150001 错误) - #20: RedEnvelopeBubble typ=8,四态(橙色领取/已领/过期/抢完)+ 领取按钮 - #21: ReceiveRedEnvelopeUseCase POST /app/api/wallet/rp/receive, typed JSON body(避免 code=30007),SnackBar 反馈 - #22: SendRedEnvelopeSheet BottomSheet,STANDARD_RP + LUCKY_RP, 发送成功后构建 typ=8 content JSON 回调给 ChatPage - #23: BannerViewModel Notifier,Group.topic 双格式解析(JSON object/string), FetchBannerUseCase + Timer 倒计时 + applyNewRound WS 接口 - #24: BannerView 游戏横幅条(状态/倒计时/上期结果), MiniAppFloatButton 悬浮按钮(hasGame 显示/隐藏,onTap TODO #25) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
apps/im_app/lib/features/chat/view/widgets/banner_view.dart
Normal file
173
apps/im_app/lib/features/chat/view/widgets/banner_view.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/presentation/banner_view_model.dart';
|
||||
|
||||
/// 游戏横幅条(群聊顶部)
|
||||
///
|
||||
/// 对应 Gitea issue #23 / iOS BannerView(ChatBannerView)
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // ChatPage 群聊时,顶部 AppBar 下方插入
|
||||
/// if (group.topic != null)
|
||||
/// BannerView(onTapMiniApp: () { /* 打开小程序 */ })
|
||||
/// ```
|
||||
class BannerView extends ConsumerWidget {
|
||||
const BannerView({super.key, this.onTapMiniApp});
|
||||
|
||||
/// 点击进入小程序回调
|
||||
final VoidCallback? onTapMiniApp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(bannerViewModelProvider);
|
||||
|
||||
if (!state.hasGame) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final isOpen = state.status == BannerGameStatus.open;
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
child: InkWell(
|
||||
onTap: onTapMiniApp,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// 游戏图标
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_gameEmoji(state.gameId),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 游戏名 + 状态
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
state.gameName.isNotEmpty
|
||||
? state.gameName
|
||||
: _gameLabel(state.gameId),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_StatusBadge(isOpen: isOpen),
|
||||
],
|
||||
),
|
||||
if (state.lastResult != null)
|
||||
Text(
|
||||
'上期:${state.lastResult}',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 倒计时
|
||||
if (state.countdownSeconds > 0)
|
||||
_CountdownText(seconds: state.countdownSeconds),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSecondaryContainer.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _gameEmoji(String gameId) => switch (gameId) {
|
||||
'bjl' => '🃏',
|
||||
'lp' || 'lh' => '🐉',
|
||||
'ks' => '💎',
|
||||
'yxx' => '🐟',
|
||||
'nn' => '🐮',
|
||||
'ssc' || 'lhc' => '🎰',
|
||||
'sg' => '🎲',
|
||||
'pc' => '🥚',
|
||||
_ => '🎮',
|
||||
};
|
||||
|
||||
String _gameLabel(String gameId) => switch (gameId) {
|
||||
'bjl' => '百家乐',
|
||||
'lp' => '龙虎',
|
||||
'lh' => '龙虎',
|
||||
'ks' => '快三',
|
||||
'yxx' => '鱼虾蟹',
|
||||
'nn' => '牛牛',
|
||||
'ssc' => '时时彩',
|
||||
'lhc' => '六合彩',
|
||||
'sg' => '色骰',
|
||||
'pc' => '盘彩',
|
||||
'dznz' => '多走牛牛',
|
||||
'qkj' => '抢庄牌九',
|
||||
_ => gameId,
|
||||
};
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.isOpen});
|
||||
final bool isOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: isOpen ? Colors.green.withOpacity(0.15) : Colors.orange.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
isOpen ? '下注中' : '等待开奖',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOpen ? Colors.green.shade700 : Colors.orange.shade700,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CountdownText extends StatelessWidget {
|
||||
const _CountdownText({required this.seconds});
|
||||
final int seconds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mm = (seconds ~/ 60).toString().padLeft(2, '0');
|
||||
final ss = (seconds % 60).toString().padLeft(2, '0');
|
||||
return Text(
|
||||
'$mm:$ss',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.w700,
|
||||
color: seconds <= 10 ? Colors.red : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/presentation/banner_view_model.dart';
|
||||
|
||||
/// 游戏悬浮按钮(群聊右下角)
|
||||
///
|
||||
/// 对应 Gitea issue #24 / iOS ChatRoomMiniAppFloatButton
|
||||
///
|
||||
/// ## 显示条件
|
||||
///
|
||||
/// 仅当 `BannerState.hasGame == true` 时可见(群有游戏 topic 且已成功加载横幅)。
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // ChatPage Stack 最顶层
|
||||
/// Positioned(
|
||||
/// right: 16,
|
||||
/// bottom: 80, // 高于输入框
|
||||
/// child: MiniAppFloatButton(
|
||||
/// onTap: () {
|
||||
/// // TODO #25: 打开小程序 WebView
|
||||
/// // MiniAppRouter.open(context, gameId: bannerState.gameId);
|
||||
/// },
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class MiniAppFloatButton extends ConsumerWidget {
|
||||
const MiniAppFloatButton({super.key, this.onTap});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(bannerViewModelProvider);
|
||||
|
||||
// 仅在有游戏时显示
|
||||
if (!state.hasGame) return const SizedBox.shrink();
|
||||
|
||||
return AnimatedScale(
|
||||
scale: state.hasGame ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? _defaultTap,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_gameEmoji(state.gameId),
|
||||
style: const TextStyle(fontSize: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _defaultTap() {
|
||||
// TODO #25: 打开小程序 WebView
|
||||
// MiniAppRouter.open(context, gameId: state.gameId);
|
||||
}
|
||||
|
||||
String _gameEmoji(String gameId) => switch (gameId) {
|
||||
'bjl' => '🃏',
|
||||
'lp' || 'lh' => '🐉',
|
||||
'ks' => '💎',
|
||||
'yxx' => '🐟',
|
||||
'nn' => '🐮',
|
||||
'ssc' || 'lhc' => '🎰',
|
||||
'sg' => '🎲',
|
||||
'pc' => '🥚',
|
||||
_ => '🎮',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||
|
||||
/// 红包消息气泡(typ = 8)
|
||||
///
|
||||
/// 对应 Gitea issue #20 / iOS RedEnvelopeBubble
|
||||
///
|
||||
/// ## Bug 修复
|
||||
/// iOS 老消息 typ=1 content="[红包]" 显示为文本,
|
||||
/// Flutter 通过 `typ=8` 正确路由到本 widget。
|
||||
///
|
||||
/// ## 状态
|
||||
///
|
||||
/// | rp_status | UI |
|
||||
/// |-----------|-----|
|
||||
/// | 0 / 1 | 橙色气泡,"领取红包" |
|
||||
/// | 2 | 灰色,"您已领取" |
|
||||
/// | 3 | 灰色,"红包已过期" |
|
||||
/// | 4 | 灰色,"手慢了" |
|
||||
/// | 6 | 橙色,"等待开奖" |
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// RedEnvelopeBubble(
|
||||
/// messageId: message.id,
|
||||
/// rawContent: message.content ?? '',
|
||||
/// chatId: chatId,
|
||||
/// onReceived: (amount) { /* 刷新消息列表 */ },
|
||||
/// )
|
||||
/// ```
|
||||
class RedEnvelopeBubble extends ConsumerWidget {
|
||||
const RedEnvelopeBubble({
|
||||
super.key,
|
||||
required this.messageId,
|
||||
required this.rawContent,
|
||||
required this.chatId,
|
||||
});
|
||||
|
||||
final int messageId;
|
||||
final String rawContent;
|
||||
final int chatId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final parsed = _parseContent(rawContent);
|
||||
final rpId = parsed['id'] as String? ?? '';
|
||||
final rpType = parsed['rp_type'] as String? ?? 'STANDARD_RP';
|
||||
final remark = parsed['remark'] as String? ?? '恭喜发财';
|
||||
final rpStatus = parsed['rp_status'] as int? ?? 0;
|
||||
|
||||
final isClaimed = rpStatus == 2;
|
||||
final isExpired = rpStatus == 3;
|
||||
final isGone = rpStatus == 4;
|
||||
final isActive = !isClaimed && !isExpired && !isGone;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isActive && rpId.isNotEmpty
|
||||
? () => _claim(context, ref, rpId, rpType)
|
||||
: null,
|
||||
child: _RedEnvelopeCard(
|
||||
remark: remark,
|
||||
rpType: rpType,
|
||||
isActive: isActive,
|
||||
statusText: _statusText(rpStatus),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _claim(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String rpId,
|
||||
String rpType,
|
||||
) async {
|
||||
try {
|
||||
final result = await ref
|
||||
.read(receiveRedEnvelopeUseCaseProvider)
|
||||
.execute(
|
||||
rpId: rpId,
|
||||
chatId: chatId,
|
||||
rpType: rpType,
|
||||
messageId: messageId,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.displayMessage),
|
||||
backgroundColor: result.success ? Colors.green : null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('领取失败:$e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _statusText(int status) => switch (status) {
|
||||
2 => '您已领取',
|
||||
3 => '红包已过期',
|
||||
4 => '手慢了',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
Map<String, dynamic> _parseContent(String raw) {
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RedEnvelopeCard extends StatelessWidget {
|
||||
const _RedEnvelopeCard({
|
||||
required this.remark,
|
||||
required this.rpType,
|
||||
required this.isActive,
|
||||
required this.statusText,
|
||||
});
|
||||
|
||||
final String remark;
|
||||
final String rpType;
|
||||
final bool isActive;
|
||||
final String statusText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isActive
|
||||
? const Color(0xFFE8531E)
|
||||
: Colors.grey.shade500;
|
||||
final subText = isActive
|
||||
? _rpTypeLabel(rpType)
|
||||
: statusText;
|
||||
|
||||
return Container(
|
||||
width: 200,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 红包图标
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.card_giftcard, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// 文字
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
remark.isNotEmpty ? remark : '恭喜发财',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subText,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _rpTypeLabel(String rpType) => switch (rpType) {
|
||||
'STANDARD_RP' => '普通红包',
|
||||
'LUCKY_RP' => '拼手气红包',
|
||||
'MINE_RP' => '地雷红包',
|
||||
'NN_RP' => '牛牛红包',
|
||||
_ => '红包',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:im_app/features/chat/di/red_envelope_provider.dart';
|
||||
|
||||
/// 红包类型
|
||||
enum _RpType {
|
||||
standard('STANDARD_RP', '普通红包', '每人金额相同'),
|
||||
lucky('LUCKY_RP', '拼手气红包', '随机金额,运气爆发');
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
final String desc;
|
||||
const _RpType(this.value, this.label, this.desc);
|
||||
}
|
||||
|
||||
/// 发送红包 BottomSheet
|
||||
///
|
||||
/// 对应 Gitea issue #22 / iOS RedEnvelopeSendView
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // 在 ChatPage 发送按钮旁触发
|
||||
/// SendRedEnvelopeSheet.show(
|
||||
/// context: context,
|
||||
/// chatId: chatId,
|
||||
/// chatType: chatType,
|
||||
/// workspaceId: group.workspaceId,
|
||||
/// onSent: (content) { /* 将 content 作为 typ=8 消息发送 */ },
|
||||
/// );
|
||||
/// ```
|
||||
class SendRedEnvelopeSheet extends ConsumerStatefulWidget {
|
||||
const SendRedEnvelopeSheet({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.chatType,
|
||||
required this.workspaceId,
|
||||
required this.onSent,
|
||||
});
|
||||
|
||||
final int chatId;
|
||||
final int chatType;
|
||||
final int workspaceId;
|
||||
|
||||
/// 成功后回调,传入 typ=8 消息的 content JSON 字符串
|
||||
final void Function(String content) onSent;
|
||||
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required int chatId,
|
||||
required int chatType,
|
||||
required int workspaceId,
|
||||
required void Function(String content) onSent,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => SendRedEnvelopeSheet(
|
||||
chatId: chatId,
|
||||
chatType: chatType,
|
||||
workspaceId: workspaceId,
|
||||
onSent: onSent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<SendRedEnvelopeSheet> createState() =>
|
||||
_SendRedEnvelopeSheetState();
|
||||
}
|
||||
|
||||
class _SendRedEnvelopeSheetState extends ConsumerState<SendRedEnvelopeSheet> {
|
||||
final _amountCtrl = TextEditingController();
|
||||
final _remarkCtrl = TextEditingController(text: '恭喜发财');
|
||||
final _numCtrl = TextEditingController(text: '1');
|
||||
|
||||
_RpType _rpType = _RpType.standard;
|
||||
bool _sending = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
_numCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题行
|
||||
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),
|
||||
Text('发红包', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// 类型选择(群聊时显示,私聊只显示普通)
|
||||
if (widget.chatType == 2) ...[
|
||||
Text('红包类型', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: _RpType.values.map((t) {
|
||||
final selected = _rpType == t;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _rpType = t),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? const Color(0xFFE8531E).withOpacity(0.1)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: selected
|
||||
? Border.all(color: const Color(0xFFE8531E))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(t.label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? const Color(0xFFE8531E) : null,
|
||||
fontSize: 13,
|
||||
)),
|
||||
Text(t.desc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// 数量(群聊)
|
||||
if (widget.chatType == 2) ...[
|
||||
Text('红包数量', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _numCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
hintText: '1-100',
|
||||
suffixText: '个',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// 金额
|
||||
Text('总金额', style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _amountCtrl,
|
||||
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),
|
||||
TextField(
|
||||
controller: _remarkCtrl,
|
||||
maxLength: 20,
|
||||
decoration: const InputDecoration(isDense: true),
|
||||
),
|
||||
// 错误提示
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 发送按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _sending ? null : _send,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFE8531E),
|
||||
),
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
)
|
||||
: const Text('塞钱进红包'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final amount = _amountCtrl.text.trim();
|
||||
if (amount.isEmpty || double.tryParse(amount) == null) {
|
||||
setState(() => _error = '请输入有效金额');
|
||||
return;
|
||||
}
|
||||
final num = int.tryParse(_numCtrl.text.trim()) ?? 1;
|
||||
if (num < 1 || num > 100) {
|
||||
setState(() => _error = '数量范围 1-100');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() { _sending = true; _error = null; });
|
||||
|
||||
try {
|
||||
final rpId = await ref.read(sendRedEnvelopeUseCaseProvider).execute(
|
||||
chatId: widget.chatId,
|
||||
chatType: widget.chatType,
|
||||
workspaceId: widget.workspaceId,
|
||||
rpType: _rpType.value,
|
||||
amount: double.parse(amount).toStringAsFixed(2),
|
||||
rpNum: num,
|
||||
remark: _remarkCtrl.text.trim().isNotEmpty
|
||||
? _remarkCtrl.text.trim()
|
||||
: '恭喜发财',
|
||||
);
|
||||
|
||||
// 构建 typ=8 消息 content
|
||||
final content = jsonEncode({
|
||||
'id': rpId,
|
||||
'rp_type': _rpType.value,
|
||||
'remark': _remarkCtrl.text.trim().isNotEmpty
|
||||
? _remarkCtrl.text.trim()
|
||||
: '恭喜发财',
|
||||
'total_amount': double.parse(amount).toStringAsFixed(2),
|
||||
'total_num': num,
|
||||
'rp_status': 0,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
widget.onSent(content);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() { _error = e.toString(); _sending = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user