feat(mobile/coupons): complete coupon holdings feature
- wallet_coupons_page: pass full CouponModel to detail route (was coupon.id) - wallet_coupons_page: fix status filters (in_circulation=可使用, listed=挂售中) - my_coupon_detail_page: full rewrite — StatefulWidget, accept CouponModel or String ID, real QR code via qr_flutter, barcode toggle, real data (faceValue/price/expiry/orderNo/resale), conditional action buttons per isTransferable + resaleCount, grey gradient for expired coupons - profile_page: convert to StatefulWidget, load holdingsSummary on init, show real hold count & totalSaved in quick stats (tappable → /wallet/coupons), add "我的持仓" menu entry - i18n: add myCoupon.switchQr + common.notFound to all 4 locales Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c00c48c8bd
commit
4065d586a9
|
|
@ -21,6 +21,7 @@ const Map<String, String> en = {
|
||||||
'common.today': 'Today',
|
'common.today': 'Today',
|
||||||
'common.thisWeek': 'This Week',
|
'common.thisWeek': 'This Week',
|
||||||
'common.thisMonth': 'This Month',
|
'common.thisMonth': 'This Month',
|
||||||
|
'common.notFound': 'Not found',
|
||||||
|
|
||||||
// ============ Navigation ============
|
// ============ Navigation ============
|
||||||
'nav.home': 'Home',
|
'nav.home': 'Home',
|
||||||
|
|
@ -272,6 +273,7 @@ const Map<String, String> en = {
|
||||||
'myCoupon.active': 'Active',
|
'myCoupon.active': 'Active',
|
||||||
'myCoupon.showQrHint': 'Show this QR code to the merchant to redeem',
|
'myCoupon.showQrHint': 'Show this QR code to the merchant to redeem',
|
||||||
'myCoupon.switchBarcode': 'Switch to Barcode',
|
'myCoupon.switchBarcode': 'Switch to Barcode',
|
||||||
|
'myCoupon.switchQr': 'Switch to QR Code',
|
||||||
'myCoupon.faceValue': 'Face Value',
|
'myCoupon.faceValue': 'Face Value',
|
||||||
'myCoupon.purchasePrice': 'Purchase Price',
|
'myCoupon.purchasePrice': 'Purchase Price',
|
||||||
'myCoupon.validUntil': 'Valid Until',
|
'myCoupon.validUntil': 'Valid Until',
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const Map<String, String> ja = {
|
||||||
'common.today': '今日',
|
'common.today': '今日',
|
||||||
'common.thisWeek': '今週',
|
'common.thisWeek': '今週',
|
||||||
'common.thisMonth': '今月',
|
'common.thisMonth': '今月',
|
||||||
|
'common.notFound': 'データが見つかりません',
|
||||||
|
|
||||||
// ============ Navigation ============
|
// ============ Navigation ============
|
||||||
'nav.home': 'ホーム',
|
'nav.home': 'ホーム',
|
||||||
|
|
@ -273,6 +274,7 @@ const Map<String, String> ja = {
|
||||||
'myCoupon.active': '利用可能',
|
'myCoupon.active': '利用可能',
|
||||||
'myCoupon.showQrHint': 'このQRコードを店舗スタッフに提示してスキャンしてもらってください',
|
'myCoupon.showQrHint': 'このQRコードを店舗スタッフに提示してスキャンしてもらってください',
|
||||||
'myCoupon.switchBarcode': 'バーコードに切替',
|
'myCoupon.switchBarcode': 'バーコードに切替',
|
||||||
|
'myCoupon.switchQr': 'QRコードに切替',
|
||||||
'myCoupon.faceValue': '額面',
|
'myCoupon.faceValue': '額面',
|
||||||
'myCoupon.purchasePrice': '購入価格',
|
'myCoupon.purchasePrice': '購入価格',
|
||||||
'myCoupon.validUntil': '有効期限',
|
'myCoupon.validUntil': '有効期限',
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const Map<String, String> zhCN = {
|
||||||
'common.today': '今日',
|
'common.today': '今日',
|
||||||
'common.thisWeek': '本周',
|
'common.thisWeek': '本周',
|
||||||
'common.thisMonth': '本月',
|
'common.thisMonth': '本月',
|
||||||
|
'common.notFound': '数据不存在',
|
||||||
|
|
||||||
// ============ Navigation ============
|
// ============ Navigation ============
|
||||||
'nav.home': '首页',
|
'nav.home': '首页',
|
||||||
|
|
@ -273,6 +274,7 @@ const Map<String, String> zhCN = {
|
||||||
'myCoupon.active': '可使用',
|
'myCoupon.active': '可使用',
|
||||||
'myCoupon.showQrHint': '出示此二维码给商户扫描核销',
|
'myCoupon.showQrHint': '出示此二维码给商户扫描核销',
|
||||||
'myCoupon.switchBarcode': '切换条形码',
|
'myCoupon.switchBarcode': '切换条形码',
|
||||||
|
'myCoupon.switchQr': '切换二维码',
|
||||||
'myCoupon.faceValue': '面值',
|
'myCoupon.faceValue': '面值',
|
||||||
'myCoupon.purchasePrice': '购买价格',
|
'myCoupon.purchasePrice': '购买价格',
|
||||||
'myCoupon.validUntil': '有效期',
|
'myCoupon.validUntil': '有效期',
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const Map<String, String> zhTW = {
|
||||||
'common.today': '今日',
|
'common.today': '今日',
|
||||||
'common.thisWeek': '本週',
|
'common.thisWeek': '本週',
|
||||||
'common.thisMonth': '本月',
|
'common.thisMonth': '本月',
|
||||||
|
'common.notFound': '資料不存在',
|
||||||
|
|
||||||
// ============ Navigation ============
|
// ============ Navigation ============
|
||||||
'nav.home': '首頁',
|
'nav.home': '首頁',
|
||||||
|
|
@ -273,6 +274,7 @@ const Map<String, String> zhTW = {
|
||||||
'myCoupon.active': '可使用',
|
'myCoupon.active': '可使用',
|
||||||
'myCoupon.showQrHint': '出示此二維碼給商戶掃描核銷',
|
'myCoupon.showQrHint': '出示此二維碼給商戶掃描核銷',
|
||||||
'myCoupon.switchBarcode': '切換條碼',
|
'myCoupon.switchBarcode': '切換條碼',
|
||||||
|
'myCoupon.switchQr': '切換二維碼',
|
||||||
'myCoupon.faceValue': '面值',
|
'myCoupon.faceValue': '面值',
|
||||||
'myCoupon.purchasePrice': '購買價格',
|
'myCoupon.purchasePrice': '購買價格',
|
||||||
'myCoupon.validUntil': '有效期',
|
'myCoupon.validUntil': '有效期',
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,50 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/theme/app_typography.dart';
|
import '../../../../app/theme/app_typography.dart';
|
||||||
import '../../../../app/theme/app_spacing.dart';
|
import '../../../../app/theme/app_spacing.dart';
|
||||||
import '../../../../shared/widgets/genex_button.dart';
|
import '../../../../shared/widgets/genex_button.dart';
|
||||||
import '../../../../shared/widgets/status_tag.dart';
|
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/coupon_service.dart';
|
||||||
|
import '../../data/models/coupon_model.dart';
|
||||||
|
|
||||||
/// A4. 券详情(持有券)- QR码/条形码 + 转赠/出售/提取
|
/// A4. 券详情(持有券)- QR码/条形码 + 转赠/出售
|
||||||
///
|
///
|
||||||
/// 券二维码/条形码(核销用)、券信息、使用说明、
|
/// 接收路由参数:CouponModel(来自 wallet_coupons_page)或 String ID(兼容旧路由)
|
||||||
/// 「转赠」「出售」「使用说明」按钮
|
class MyCouponDetailPage extends StatefulWidget {
|
||||||
class MyCouponDetailPage extends StatelessWidget {
|
|
||||||
const MyCouponDetailPage({super.key});
|
const MyCouponDetailPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyCouponDetailPage> createState() => _MyCouponDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyCouponDetailPageState extends State<MyCouponDetailPage> {
|
||||||
|
CouponModel? _coupon;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _showBarcode = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_coupon != null) return;
|
||||||
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
|
if (args is CouponModel) {
|
||||||
|
_coupon = args;
|
||||||
|
} else if (args is String) {
|
||||||
|
_loadById(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadById(String id) async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final coupon = await CouponApiService().getCouponDetail(id);
|
||||||
|
if (mounted) setState(() { _coupon = coupon; _isLoading = false; });
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -23,186 +55,275 @@ class MyCouponDetailPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
title: Text(context.t('myCoupon.title')),
|
title: Text(context.t('myCoupon.title')),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (_coupon != null)
|
||||||
icon: const Icon(Icons.more_horiz_rounded),
|
IconButton(
|
||||||
onPressed: () => _showMoreOptions(context),
|
icon: const Icon(Icons.more_horiz_rounded),
|
||||||
),
|
onPressed: () => _showMoreOptions(context),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: _isLoading
|
||||||
padding: AppSpacing.pagePadding,
|
? const Center(child: CircularProgressIndicator())
|
||||||
child: Column(
|
: _coupon == null
|
||||||
children: [
|
? Center(child: Text(context.t('common.notFound')))
|
||||||
const SizedBox(height: 16),
|
: _buildBody(context, _coupon!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// QR Code Card
|
Widget _buildBody(BuildContext context, CouponModel coupon) {
|
||||||
Container(
|
final qrData = 'GNX:${coupon.id}';
|
||||||
width: double.infinity,
|
final isActive = coupon.status == 'in_circulation';
|
||||||
padding: const EdgeInsets.all(24),
|
final expiryStr = '${coupon.expiryDate.year}/${coupon.expiryDate.month.toString().padLeft(2, '0')}/${coupon.expiryDate.day.toString().padLeft(2, '0')}';
|
||||||
decoration: BoxDecoration(
|
final orderNo = 'GNX-${coupon.id.replaceAll('-', '').substring(0, 12).toUpperCase()}';
|
||||||
gradient: AppColors.cardGradient,
|
|
||||||
borderRadius: AppSpacing.borderRadiusLg,
|
return SingleChildScrollView(
|
||||||
boxShadow: AppSpacing.shadowPrimary,
|
padding: AppSpacing.pagePadding,
|
||||||
),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
// Brand + Status
|
|
||||||
Row(
|
// QR / Barcode Card
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Container(
|
||||||
children: [
|
width: double.infinity,
|
||||||
Column(
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: coupon.isExpired
|
||||||
|
? const LinearGradient(colors: [Color(0xFF9E9E9E), Color(0xFF757575)])
|
||||||
|
: AppColors.cardGradient,
|
||||||
|
borderRadius: AppSpacing.borderRadiusLg,
|
||||||
|
boxShadow: AppSpacing.shadowPrimary,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Brand + Status
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Starbucks', style: AppTypography.bodySmall.copyWith(
|
Text(coupon.brandName ?? '', style: AppTypography.bodySmall.copyWith(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
)),
|
)),
|
||||||
Text('星巴克 \$25 礼品卡', style: AppTypography.h2.copyWith(
|
Text(coupon.name, style: AppTypography.h2.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
)),
|
), overflow: TextOverflow.ellipsis),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Container(
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
const SizedBox(width: 8),
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: Colors.white24,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
borderRadius: AppSpacing.borderRadiusFull,
|
decoration: BoxDecoration(
|
||||||
),
|
color: Colors.white24,
|
||||||
child: Text(context.t('myCoupon.active'), style: AppTypography.caption.copyWith(
|
borderRadius: AppSpacing.borderRadiusFull,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_statusLabel(context, coupon.status),
|
||||||
|
style: AppTypography.caption.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
)),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// QR Code area
|
|
||||||
Container(
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: AppSpacing.borderRadiusMd,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.qr_code_rounded, size: 140,
|
|
||||||
color: AppColors.textPrimary),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('GNX-STB-A1B2C3D4',
|
|
||||||
style: AppTypography.caption.copyWith(
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// QR Code / Barcode area
|
||||||
|
Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppSpacing.borderRadiusMd,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
child: _showBarcode
|
||||||
|
? _buildBarcode(coupon.id)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: QrImageView(
|
||||||
|
data: qrData,
|
||||||
|
version: QrVersions.auto,
|
||||||
|
size: 176,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Instructions
|
Text(
|
||||||
Text(
|
coupon.id.replaceAll('-', '').substring(0, 16).toUpperCase(),
|
||||||
context.t('myCoupon.showQrHint'),
|
style: AppTypography.caption.copyWith(
|
||||||
style: AppTypography.bodySmall.copyWith(color: Colors.white70),
|
color: Colors.white70,
|
||||||
),
|
letterSpacing: 1.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Barcode toggle
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.view_headline_rounded, size: 18,
|
|
||||||
color: Colors.white70),
|
|
||||||
label: Text(context.t('myCoupon.switchBarcode'), style: AppTypography.labelSmall.copyWith(
|
|
||||||
color: Colors.white70,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Face Value + Expiry
|
|
||||||
Container(
|
|
||||||
padding: AppSpacing.cardPadding,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surface,
|
|
||||||
borderRadius: AppSpacing.borderRadiusMd,
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_infoRow(context.t('myCoupon.faceValue'), '\$25.00'),
|
|
||||||
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
|
||||||
_infoRow(context.t('myCoupon.purchasePrice'), '\$21.25'),
|
|
||||||
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
|
||||||
_infoRow(context.t('myCoupon.validUntil'), '2026/12/31'),
|
|
||||||
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
|
||||||
_infoRow(context.t('myCoupon.orderNo'), 'GNX-20260209-001234'),
|
|
||||||
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
|
||||||
_infoRow(context.t('myCoupon.resellCount'), '3次'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: GenexButton(
|
|
||||||
label: context.t('myCoupon.transfer'),
|
|
||||||
icon: Icons.card_giftcard_rounded,
|
|
||||||
variant: GenexButtonVariant.secondary,
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pushNamed(context, '/transfer');
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
|
||||||
child: GenexButton(
|
Text(
|
||||||
label: context.t('myCoupon.sell'),
|
context.t('myCoupon.showQrHint'),
|
||||||
icon: Icons.sell_rounded,
|
style: AppTypography.bodySmall.copyWith(color: Colors.white70),
|
||||||
variant: GenexButtonVariant.outline,
|
),
|
||||||
onPressed: () {
|
const SizedBox(height: 8),
|
||||||
Navigator.pushNamed(context, '/sell');
|
|
||||||
},
|
TextButton.icon(
|
||||||
|
onPressed: () => setState(() => _showBarcode = !_showBarcode),
|
||||||
|
icon: Icon(
|
||||||
|
_showBarcode ? Icons.qr_code_rounded : Icons.view_headline_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_showBarcode
|
||||||
|
? context.t('myCoupon.switchQr')
|
||||||
|
: context.t('myCoupon.switchBarcode'),
|
||||||
|
style: AppTypography.labelSmall.copyWith(color: Colors.white70),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Usage Rules
|
// Info Card
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
padding: AppSpacing.cardPadding,
|
||||||
padding: AppSpacing.cardPadding,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: AppColors.surface,
|
||||||
color: AppColors.surface,
|
borderRadius: AppSpacing.borderRadiusMd,
|
||||||
borderRadius: AppSpacing.borderRadiusMd,
|
border: Border.all(color: AppColors.borderLight),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
),
|
||||||
),
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
_infoRow(context.t('myCoupon.faceValue'),
|
||||||
children: [
|
'\$${coupon.faceValue.toStringAsFixed(2)}'),
|
||||||
Text(context.t('myCoupon.usageNote'), style: AppTypography.labelMedium),
|
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
||||||
const SizedBox(height: 12),
|
_infoRow(context.t('myCoupon.purchasePrice'),
|
||||||
|
'\$${coupon.currentPrice.toStringAsFixed(2)}'),
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
||||||
|
_infoRow(context.t('myCoupon.validUntil'), expiryStr),
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
||||||
|
_infoRow(context.t('myCoupon.orderNo'), orderNo),
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
|
||||||
|
_infoRow(context.t('myCoupon.resellCount'),
|
||||||
|
'${coupon.resaleCount}/${coupon.maxResaleCount}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Action Buttons (only for active coupons)
|
||||||
|
if (isActive)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (coupon.isTransferable)
|
||||||
|
Expanded(
|
||||||
|
child: GenexButton(
|
||||||
|
label: context.t('myCoupon.transfer'),
|
||||||
|
icon: Icons.card_giftcard_rounded,
|
||||||
|
variant: GenexButtonVariant.secondary,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(context, '/transfer', arguments: coupon.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (coupon.isTransferable) const SizedBox(width: 12),
|
||||||
|
if (coupon.resaleCount < coupon.maxResaleCount)
|
||||||
|
Expanded(
|
||||||
|
child: GenexButton(
|
||||||
|
label: context.t('myCoupon.sell'),
|
||||||
|
icon: Icons.sell_rounded,
|
||||||
|
variant: GenexButtonVariant.outline,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushNamed(context, '/sell', arguments: coupon.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isActive) const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Usage Rules
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: AppSpacing.cardPadding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: AppSpacing.borderRadiusMd,
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.t('myCoupon.usageNote'), style: AppTypography.labelMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (coupon.description != null && coupon.description!.isNotEmpty)
|
||||||
|
_ruleItem(coupon.description!)
|
||||||
|
else ...[
|
||||||
_ruleItem(context.t('myCoupon.useInStore')),
|
_ruleItem(context.t('myCoupon.useInStore')),
|
||||||
_ruleItem(context.t('myCoupon.useInTime')),
|
_ruleItem(context.t('myCoupon.useInTime')),
|
||||||
_ruleItem(context.t('myCoupon.onePerVisit')),
|
_ruleItem(context.t('myCoupon.onePerVisit')),
|
||||||
_ruleItem(context.t('myCoupon.noCash')),
|
_ruleItem(context.t('myCoupon.noCash')),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 80),
|
const SizedBox(height: 80),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple barcode visual using thin/thick bars
|
||||||
|
Widget _buildBarcode(String id) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: List.generate(40, (i) {
|
||||||
|
final val = id.codeUnitAt(i % id.length);
|
||||||
|
final width = (val % 3 == 0) ? 3.0 : (val % 2 == 0) ? 2.0 : 1.5;
|
||||||
|
final isWhite = i % 7 == 0;
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||||
|
color: isWhite ? Colors.white : Colors.black87,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
id.replaceAll('-', '').substring(0, 12).toUpperCase(),
|
||||||
|
style: AppTypography.caption.copyWith(
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(BuildContext context, String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_circulation': return context.t('myCoupon.active');
|
||||||
|
case 'listed': return context.t('walletCoupons.pendingRedeem');
|
||||||
|
case 'expired': return context.t('walletCoupons.expired');
|
||||||
|
case 'redeemed': return context.t('status.used');
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _infoRow(String label, String value) {
|
Widget _infoRow(String label, String value) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -219,16 +340,20 @@ class MyCouponDetailPage extends StatelessWidget {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Padding(
|
||||||
width: 4, height: 4,
|
padding: const EdgeInsets.only(top: 6),
|
||||||
decoration: const BoxDecoration(
|
child: Container(
|
||||||
color: AppColors.textTertiary,
|
width: 4, height: 4,
|
||||||
shape: BoxShape.circle,
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(text, style: AppTypography.bodySmall),
|
Expanded(child: Text(text, style: AppTypography.bodySmall)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -250,7 +375,8 @@ class MyCouponDetailPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_optionTile(Icons.wallet_rounded, context.t('myCoupon.extractToWallet'), context.t('myCoupon.requireKycL2'), () {}),
|
_optionTile(Icons.wallet_rounded, context.t('myCoupon.extractToWallet'),
|
||||||
|
context.t('myCoupon.requireKycL2'), () {}),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_optionTile(Icons.receipt_long_rounded, context.t('myCoupon.viewTrades'), '', () {}),
|
_optionTile(Icons.receipt_long_rounded, context.t('myCoupon.viewTrades'), '', () {}),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ class _WalletCouponsPageState extends State<WalletCouponsPage>
|
||||||
|
|
||||||
// Tab index → backend status filter
|
// Tab index → backend status filter
|
||||||
static const _tabStatusFilters = <int, String?>{
|
static const _tabStatusFilters = <int, String?>{
|
||||||
0: null, // 全部
|
0: null, // 全部
|
||||||
1: 'listed', // 可使用 (listed / in_circulation)
|
1: 'in_circulation', // 可使用(在用户钱包中)
|
||||||
2: 'sold', // 待核销
|
2: 'listed', // 挂售中(已挂出待成交)
|
||||||
3: 'expired', // 已过期
|
3: 'expired', // 已过期
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -259,7 +259,7 @@ class _WalletCouponsPageState extends State<WalletCouponsPage>
|
||||||
final displayStatus = _mapStatus(coupon.status);
|
final displayStatus = _mapStatus(coupon.status);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail', arguments: coupon.id),
|
onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail', arguments: coupon),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: AppSpacing.cardPadding,
|
padding: AppSpacing.cardPadding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,39 @@ import 'package:flutter/material.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/theme/app_typography.dart';
|
import '../../../../app/theme/app_typography.dart';
|
||||||
import '../../../../app/theme/app_spacing.dart';
|
import '../../../../app/theme/app_spacing.dart';
|
||||||
import '../../../../shared/widgets/kyc_badge.dart';
|
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/coupon_service.dart';
|
||||||
|
import '../../data/models/holdings_summary_model.dart';
|
||||||
|
|
||||||
/// A7. 个人中心
|
/// A7. 个人中心
|
||||||
///
|
///
|
||||||
/// 头像、昵称、KYC等级标识、信用积分
|
/// 头像、昵称、KYC等级标识、信用积分
|
||||||
/// KYC认证、支付管理、设置、Pro模式
|
/// KYC认证、支付管理、设置、Pro模式
|
||||||
class ProfilePage extends StatelessWidget {
|
class ProfilePage extends StatefulWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfilePage> createState() => _ProfilePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfilePageState extends State<ProfilePage> {
|
||||||
|
HoldingsSummaryModel? _holdingsSummary;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSummary() async {
|
||||||
|
try {
|
||||||
|
final summary = await CouponApiService().getHoldingsSummary();
|
||||||
|
if (mounted) setState(() => _holdingsSummary = summary);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ProfilePage] loadSummary error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -37,6 +60,9 @@ class ProfilePage extends StatelessWidget {
|
||||||
])),
|
])),
|
||||||
|
|
||||||
SliverToBoxAdapter(child: _buildMenuSection(context.t('profile.trade'), [
|
SliverToBoxAdapter(child: _buildMenuSection(context.t('profile.trade'), [
|
||||||
|
_MenuItem(Icons.confirmation_number_outlined, context.t('profile.holdCoupons'),
|
||||||
|
'${_holdingsSummary?.count ?? '--'}', true,
|
||||||
|
onTap: () => Navigator.pushNamed(context, '/wallet/coupons')),
|
||||||
_MenuItem(Icons.receipt_long_rounded, context.t('wallet.records'), '', true,
|
_MenuItem(Icons.receipt_long_rounded, context.t('wallet.records'), '', true,
|
||||||
onTap: () => Navigator.pushNamed(context, '/trading')),
|
onTap: () => Navigator.pushNamed(context, '/trading')),
|
||||||
_MenuItem(Icons.storefront_rounded, context.t('tradingPage.pendingOrders'), context.t('status.onSale'), true,
|
_MenuItem(Icons.storefront_rounded, context.t('tradingPage.pendingOrders'), context.t('status.onSale'), true,
|
||||||
|
|
@ -144,11 +170,14 @@ class ProfilePage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuickStats(BuildContext context) {
|
Widget _buildQuickStats(BuildContext context) {
|
||||||
|
final holdCount = _holdingsSummary?.count ?? 0;
|
||||||
|
final saved = _holdingsSummary?.totalSaved ?? 0;
|
||||||
|
|
||||||
final stats = [
|
final stats = [
|
||||||
(context.t('profile.holdCoupons'), '12'),
|
(context.t('profile.holdCoupons'), '$holdCount', () => Navigator.pushNamed(context, '/wallet/coupons')),
|
||||||
(context.t('profile.trade'), '28'),
|
(context.t('profile.trade'), '28', null),
|
||||||
(context.t('profile.saved'), '\$156'),
|
(context.t('profile.saved'), '\$${saved.toStringAsFixed(0)}', null),
|
||||||
(context.t('profile.credit'), '750'),
|
(context.t('profile.credit'), '750', null),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -162,12 +191,15 @@ class ProfilePage extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: stats.map((stat) {
|
children: stats.map((stat) {
|
||||||
return Column(
|
return GestureDetector(
|
||||||
children: [
|
onTap: stat.$3,
|
||||||
Text(stat.$2, style: AppTypography.h2.copyWith(color: AppColors.primary)),
|
child: Column(
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
Text(stat.$1, style: AppTypography.caption),
|
Text(stat.$2, style: AppTypography.h2.copyWith(color: AppColors.primary)),
|
||||||
],
|
const SizedBox(height: 4),
|
||||||
|
Text(stat.$1, style: AppTypography.caption),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue