feat: 新人初始礼包购买追踪功能
- 数据库:添加 has_purchased_starter_pack 字段到 register_bonus_claims - 后端:创建静态配置管理套餐信息,支持按国家/地区区分 - 后端:新增 GET /api/v1/points/packages API 返回可用套餐 - 后端:创建 utils/paths.py 统一路径管理 - 前端:动态获取套餐信息,移除硬编码 - 前端:添加 ProductCode 枚举约束,前后端类型安全 - 配置:Profile 默认国家改为 US(ISO 3166-1 alpha-2) - 文档:更新协议文档说明新 API 和字段
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/package_info.dart';
|
||||
|
||||
class PointsApi {
|
||||
const PointsApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<PackagesResult> getPackages() async {
|
||||
final response = await _dio.get('/api/v1/points/packages');
|
||||
return PackagesResult.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
enum ProductCode { newUserPack, basicPack, popularPack, premiumPack }
|
||||
|
||||
enum PackageType { starter, regular }
|
||||
|
||||
class PackageInfo {
|
||||
const PackageInfo({
|
||||
required this.productCode,
|
||||
required this.type,
|
||||
required this.price,
|
||||
required this.credits,
|
||||
required this.isStarter,
|
||||
required this.starterEligible,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
final ProductCode productCode;
|
||||
final PackageType type;
|
||||
final double price;
|
||||
final int credits;
|
||||
final bool isStarter;
|
||||
final bool starterEligible;
|
||||
final int sortOrder;
|
||||
|
||||
factory PackageInfo.fromJson(Map<String, dynamic> json) {
|
||||
return PackageInfo(
|
||||
productCode: _parseProductCode(json['productCode'] as String),
|
||||
type: json['type'] == 'starter'
|
||||
? PackageType.starter
|
||||
: PackageType.regular,
|
||||
price: (json['price'] as num).toDouble(),
|
||||
credits: json['credits'] as int,
|
||||
isStarter: json['isStarter'] as bool,
|
||||
starterEligible: json['starterEligible'] as bool,
|
||||
sortOrder: json['sortOrder'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
static ProductCode _parseProductCode(String code) {
|
||||
return switch (code) {
|
||||
'new_user_pack' => ProductCode.newUserPack,
|
||||
'basic_pack' => ProductCode.basicPack,
|
||||
'popular_pack' => ProductCode.popularPack,
|
||||
'premium_pack' => ProductCode.premiumPack,
|
||||
_ => throw ArgumentError('Unknown product code: $code'),
|
||||
};
|
||||
}
|
||||
|
||||
String get priceDisplay => '\$${price.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
class PackagesResult {
|
||||
const PackagesResult({
|
||||
required this.region,
|
||||
required this.currency,
|
||||
required this.packages,
|
||||
});
|
||||
|
||||
final String region;
|
||||
final String currency;
|
||||
final List<PackageInfo> packages;
|
||||
|
||||
factory PackagesResult.fromJson(Map<String, dynamic> json) {
|
||||
return PackagesResult(
|
||||
region: json['region'] as String,
|
||||
currency: json['currency'] as String,
|
||||
packages: (json['packages'] as List<dynamic>)
|
||||
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class ProfileApi {
|
||||
aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN',
|
||||
timezone:
|
||||
(preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai',
|
||||
country: (preferencesRaw['country'] as String?) ?? 'CN',
|
||||
country: (preferencesRaw['country'] as String?) ?? 'US',
|
||||
)
|
||||
: const PreferenceSettings();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class PreferenceSettings {
|
||||
this.interfaceLanguage = 'zh-CN',
|
||||
this.aiLanguage = 'zh-CN',
|
||||
this.timezone = 'Asia/Shanghai',
|
||||
this.country = 'CN',
|
||||
this.country = 'US',
|
||||
});
|
||||
|
||||
final String interfaceLanguage;
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../../data/storage/local_kv_store.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../points/data/apis/points_api.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class CoinCenterScreen extends StatelessWidget {
|
||||
class CoinCenterScreen extends StatefulWidget {
|
||||
const CoinCenterScreen({super.key, required this.balance});
|
||||
|
||||
final int balance;
|
||||
|
||||
@override
|
||||
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
|
||||
}
|
||||
|
||||
class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
final Logger _logger = getLogger('features.settings.coin_center_screen');
|
||||
List<PackageInfo>? _packages;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPackages();
|
||||
}
|
||||
|
||||
Future<void> _loadPackages() async {
|
||||
try {
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
final api = PointsApi(apiClient.rawDio);
|
||||
final result = await api.getPackages();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Failed to load packages',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -52,7 +103,7 @@ class CoinCenterScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
l10n.settingsCoinBalanceValue(balance),
|
||||
l10n.settingsCoinBalanceValue(widget.balance),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
|
||||
@@ -69,26 +120,55 @@ class CoinCenterScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SectionLabel(text: l10n.settingsCoinRechargeSection),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackBasic,
|
||||
price: '\$4.99',
|
||||
amount: 100,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackPopular,
|
||||
price: '\$7.99',
|
||||
amount: 210,
|
||||
badge: l10n.settingsCoinPackPopularBadge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackPremium,
|
||||
price: '\$12.99',
|
||||
amount: 415,
|
||||
),
|
||||
..._buildPackageCards(l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n) {
|
||||
if (_isLoading) {
|
||||
return [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xl),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_packages == null || _packages!.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return List.generate(_packages!.length, (index) {
|
||||
final pkg = _packages![index];
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: _getPackageTitle(pkg, l10n),
|
||||
price: pkg.priceDisplay,
|
||||
amount: pkg.credits,
|
||||
badge: _getPackageBadge(pkg, l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
|
||||
return switch (pkg.productCode) {
|
||||
ProductCode.newUserPack => l10n.settingsCoinPackStarter,
|
||||
ProductCode.basicPack => l10n.settingsCoinPackBasic,
|
||||
ProductCode.popularPack => l10n.settingsCoinPackPopular,
|
||||
ProductCode.premiumPack => l10n.settingsCoinPackPremium,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "Recharge Packages",
|
||||
"settingsCoinPackStarter": "New User Pack",
|
||||
"settingsCoinPackBasic": "Starter Pack",
|
||||
"settingsCoinPackPopular": "Popular Pack",
|
||||
"settingsCoinPackPremium": "Premium Pack",
|
||||
|
||||
@@ -939,6 +939,12 @@ abstract class AppLocalizations {
|
||||
/// **'充值套餐'**
|
||||
String get settingsCoinRechargeSection;
|
||||
|
||||
/// No description provided for @settingsCoinPackStarter.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新人专享包'**
|
||||
String get settingsCoinPackStarter;
|
||||
|
||||
/// No description provided for @settingsCoinPackBasic.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -469,6 +469,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => 'Recharge Packages';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => 'New User Pack';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => 'Starter Pack';
|
||||
|
||||
|
||||
@@ -454,6 +454,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => '充值套餐';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => '新人专享包';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => '入门补充包';
|
||||
|
||||
@@ -1460,6 +1463,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => '儲值套餐';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => '新人專享包';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => '入門補充包';
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "充值套餐",
|
||||
"settingsCoinPackStarter": "新人专享包",
|
||||
"settingsCoinPackBasic": "入门补充包",
|
||||
"settingsCoinPackPopular": "常用加量包",
|
||||
"settingsCoinPackPremium": "高频进阶包",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "儲值套餐",
|
||||
"settingsCoinPackStarter": "新人專享包",
|
||||
"settingsCoinPackBasic": "入門補充包",
|
||||
"settingsCoinPackPopular": "常用加量包",
|
||||
"settingsCoinPackPremium": "高頻進階包",
|
||||
|
||||
Reference in New Issue
Block a user