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:
hailin 2026-03-07 20:09:31 -08:00
parent c00c48c8bd
commit 4065d586a9
7 changed files with 341 additions and 175 deletions

View File

@ -21,6 +21,7 @@ const Map<String, String> en = {
'common.today': 'Today',
'common.thisWeek': 'This Week',
'common.thisMonth': 'This Month',
'common.notFound': 'Not found',
// ============ Navigation ============
'nav.home': 'Home',
@ -272,6 +273,7 @@ const Map<String, String> en = {
'myCoupon.active': 'Active',
'myCoupon.showQrHint': 'Show this QR code to the merchant to redeem',
'myCoupon.switchBarcode': 'Switch to Barcode',
'myCoupon.switchQr': 'Switch to QR Code',
'myCoupon.faceValue': 'Face Value',
'myCoupon.purchasePrice': 'Purchase Price',
'myCoupon.validUntil': 'Valid Until',

View File

@ -21,6 +21,7 @@ const Map<String, String> ja = {
'common.today': '今日',
'common.thisWeek': '今週',
'common.thisMonth': '今月',
'common.notFound': 'データが見つかりません',
// ============ Navigation ============
'nav.home': 'ホーム',
@ -273,6 +274,7 @@ const Map<String, String> ja = {
'myCoupon.active': '利用可能',
'myCoupon.showQrHint': 'このQRコードを店舗スタッフに提示してスキャンしてもらってください',
'myCoupon.switchBarcode': 'バーコードに切替',
'myCoupon.switchQr': 'QRコードに切替',
'myCoupon.faceValue': '額面',
'myCoupon.purchasePrice': '購入価格',
'myCoupon.validUntil': '有効期限',

View File

@ -21,6 +21,7 @@ const Map<String, String> zhCN = {
'common.today': '今日',
'common.thisWeek': '本周',
'common.thisMonth': '本月',
'common.notFound': '数据不存在',
// ============ Navigation ============
'nav.home': '首页',
@ -273,6 +274,7 @@ const Map<String, String> zhCN = {
'myCoupon.active': '可使用',
'myCoupon.showQrHint': '出示此二维码给商户扫描核销',
'myCoupon.switchBarcode': '切换条形码',
'myCoupon.switchQr': '切换二维码',
'myCoupon.faceValue': '面值',
'myCoupon.purchasePrice': '购买价格',
'myCoupon.validUntil': '有效期',

View File

@ -21,6 +21,7 @@ const Map<String, String> zhTW = {
'common.today': '今日',
'common.thisWeek': '本週',
'common.thisMonth': '本月',
'common.notFound': '資料不存在',
// ============ Navigation ============
'nav.home': '首頁',
@ -273,6 +274,7 @@ const Map<String, String> zhTW = {
'myCoupon.active': '可使用',
'myCoupon.showQrHint': '出示此二維碼給商戶掃描核銷',
'myCoupon.switchBarcode': '切換條碼',
'myCoupon.switchQr': '切換二維碼',
'myCoupon.faceValue': '面值',
'myCoupon.purchasePrice': '購買價格',
'myCoupon.validUntil': '有效期',

View File

@ -1,18 +1,50 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/genex_button.dart';
import '../../../../shared/widgets/status_tag.dart';
import '../../../../app/i18n/app_localizations.dart';
import '../../../../core/services/coupon_service.dart';
import '../../data/models/coupon_model.dart';
/// A4. - QR码/ + //
/// A4. - QR码/ + /
///
/// /使
/// 使
class MyCouponDetailPage extends StatelessWidget {
/// CouponModel wallet_coupons_page String ID
class MyCouponDetailPage extends StatefulWidget {
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
Widget build(BuildContext context) {
return Scaffold(
@ -23,186 +55,275 @@ class MyCouponDetailPage extends StatelessWidget {
),
title: Text(context.t('myCoupon.title')),
actions: [
IconButton(
icon: const Icon(Icons.more_horiz_rounded),
onPressed: () => _showMoreOptions(context),
),
if (_coupon != null)
IconButton(
icon: const Icon(Icons.more_horiz_rounded),
onPressed: () => _showMoreOptions(context),
),
],
),
body: SingleChildScrollView(
padding: AppSpacing.pagePadding,
child: Column(
children: [
const SizedBox(height: 16),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _coupon == null
? Center(child: Text(context.t('common.notFound')))
: _buildBody(context, _coupon!),
);
}
// QR Code Card
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: AppColors.cardGradient,
borderRadius: AppSpacing.borderRadiusLg,
boxShadow: AppSpacing.shadowPrimary,
),
child: Column(
children: [
// Brand + Status
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
Widget _buildBody(BuildContext context, CouponModel coupon) {
final qrData = 'GNX:${coupon.id}';
final isActive = coupon.status == 'in_circulation';
final expiryStr = '${coupon.expiryDate.year}/${coupon.expiryDate.month.toString().padLeft(2, '0')}/${coupon.expiryDate.day.toString().padLeft(2, '0')}';
final orderNo = 'GNX-${coupon.id.replaceAll('-', '').substring(0, 12).toUpperCase()}';
return SingleChildScrollView(
padding: AppSpacing.pagePadding,
child: Column(
children: [
const SizedBox(height: 16),
// QR / Barcode Card
Container(
width: double.infinity,
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,
children: [
Text('Starbucks', style: AppTypography.bodySmall.copyWith(
Text(coupon.brandName ?? '', style: AppTypography.bodySmall.copyWith(
color: Colors.white70,
)),
Text('星巴克 \$25 礼品卡', style: AppTypography.h2.copyWith(
Text(coupon.name, style: AppTypography.h2.copyWith(
color: Colors.white,
)),
), overflow: TextOverflow.ellipsis),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: AppSpacing.borderRadiusFull,
),
child: Text(context.t('myCoupon.active'), style: AppTypography.caption.copyWith(
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: AppSpacing.borderRadiusFull,
),
child: Text(
_statusLabel(context, coupon.status),
style: AppTypography.caption.copyWith(
color: Colors.white,
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(
context.t('myCoupon.showQrHint'),
style: AppTypography.bodySmall.copyWith(color: Colors.white70),
),
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');
},
Text(
coupon.id.replaceAll('-', '').substring(0, 16).toUpperCase(),
style: AppTypography.caption.copyWith(
color: Colors.white70,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
Expanded(
child: GenexButton(
label: context.t('myCoupon.sell'),
icon: Icons.sell_rounded,
variant: GenexButtonVariant.outline,
onPressed: () {
Navigator.pushNamed(context, '/sell');
},
const SizedBox(height: 8),
Text(
context.t('myCoupon.showQrHint'),
style: AppTypography.bodySmall.copyWith(color: Colors.white70),
),
const SizedBox(height: 8),
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
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),
// Info Card
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'),
'\$${coupon.faceValue.toStringAsFixed(2)}'),
const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Divider()),
_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.useInTime')),
_ruleItem(context.t('myCoupon.onePerVisit')),
_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) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -219,16 +340,20 @@ class MyCouponDetailPage extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 4, height: 4,
decoration: const BoxDecoration(
color: AppColors.textTertiary,
shape: BoxShape.circle,
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
width: 4, height: 4,
decoration: const BoxDecoration(
color: AppColors.textTertiary,
shape: BoxShape.circle,
),
),
),
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),
_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(),
_optionTile(Icons.receipt_long_rounded, context.t('myCoupon.viewTrades'), '', () {}),
const Divider(),

View File

@ -35,10 +35,10 @@ class _WalletCouponsPageState extends State<WalletCouponsPage>
// Tab index backend status filter
static const _tabStatusFilters = <int, String?>{
0: null, //
1: 'listed', // 使 (listed / in_circulation)
2: 'sold', //
3: 'expired', //
0: null, //
1: 'in_circulation', // 使
2: 'listed', //
3: 'expired', //
};
@override
@ -259,7 +259,7 @@ class _WalletCouponsPageState extends State<WalletCouponsPage>
final displayStatus = _mapStatus(coupon.status);
return GestureDetector(
onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail', arguments: coupon.id),
onTap: () => Navigator.pushNamed(context, '/coupon/mine/detail', arguments: coupon),
child: Container(
padding: AppSpacing.cardPadding,
decoration: BoxDecoration(

View File

@ -2,16 +2,39 @@ import 'package:flutter/material.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/kyc_badge.dart';
import '../../../../app/i18n/app_localizations.dart';
import '../../../../core/services/coupon_service.dart';
import '../../data/models/holdings_summary_model.dart';
/// A7.
///
/// KYC等级标识
/// KYC认证Pro模式
class ProfilePage extends StatelessWidget {
class ProfilePage extends StatefulWidget {
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
Widget build(BuildContext context) {
return Scaffold(
@ -37,6 +60,9 @@ class ProfilePage extends StatelessWidget {
])),
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,
onTap: () => Navigator.pushNamed(context, '/trading')),
_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) {
final holdCount = _holdingsSummary?.count ?? 0;
final saved = _holdingsSummary?.totalSaved ?? 0;
final stats = [
(context.t('profile.holdCoupons'), '12'),
(context.t('profile.trade'), '28'),
(context.t('profile.saved'), '\$156'),
(context.t('profile.credit'), '750'),
(context.t('profile.holdCoupons'), '$holdCount', () => Navigator.pushNamed(context, '/wallet/coupons')),
(context.t('profile.trade'), '28', null),
(context.t('profile.saved'), '\$${saved.toStringAsFixed(0)}', null),
(context.t('profile.credit'), '750', null),
];
return Container(
@ -162,12 +191,15 @@ class ProfilePage extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: stats.map((stat) {
return Column(
children: [
Text(stat.$2, style: AppTypography.h2.copyWith(color: AppColors.primary)),
const SizedBox(height: 4),
Text(stat.$1, style: AppTypography.caption),
],
return GestureDetector(
onTap: stat.$3,
child: Column(
children: [
Text(stat.$2, style: AppTypography.h2.copyWith(color: AppColors.primary)),
const SizedBox(height: 4),
Text(stat.$1, style: AppTypography.caption),
],
),
);
}).toList(),
),