feat(points): 实现积分流水列表功能
- 后端新增 GET /api/v1/points/ledger 接口 - 前端新增积分流水列表页面 - 积分中心添加「查看流水」入口 - 重命名 AccountDeleteScreen 为 AccountDataScreen - 流水列表支持分页加载和空状态展示
This commit is contained in:
+33
-17
@@ -9,19 +9,20 @@ import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../points/presentation/screens/points_ledger_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class AccountDeleteScreen extends StatefulWidget {
|
||||
const AccountDeleteScreen({super.key, required this.onDeleteAccount});
|
||||
class AccountDataScreen extends StatefulWidget {
|
||||
const AccountDataScreen({super.key, required this.onDeleteAccount});
|
||||
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
|
||||
@override
|
||||
State<AccountDeleteScreen> createState() => _AccountDeleteScreenState();
|
||||
State<AccountDataScreen> createState() => _AccountDataScreenState();
|
||||
}
|
||||
|
||||
class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
|
||||
final Logger _logger = getLogger('features.settings.account_delete');
|
||||
class _AccountDataScreenState extends State<AccountDataScreen> {
|
||||
final Logger _logger = getLogger('features.settings.account_data');
|
||||
bool _isDeleting = false;
|
||||
|
||||
@override
|
||||
@@ -42,6 +43,13 @@ class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
|
||||
children: [
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.receipt_long_rounded,
|
||||
title: l10n.pointsLedgerTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openPointsLedger,
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
title: l10n.settingsDeleteAccountTitle,
|
||||
@@ -58,6 +66,14 @@ class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPointsLedger() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const PointsLedgerScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -194,8 +210,8 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
child: Text(
|
||||
l10n.settingsDeleteAccountDialogTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -204,9 +220,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
Text(
|
||||
l10n.settingsDeleteAccountWarningBody,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
color: colors.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
@@ -220,10 +236,10 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
child: Text(
|
||||
l10n.settingsDeleteAccountReRegisterNotice,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
@@ -232,9 +248,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
? l10n.settingsDeleteAccountWaitAction(_secondsLeft)
|
||||
: l10n.settingsDeleteAccountDialogBody,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
color: colors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
@@ -90,6 +90,32 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadPackages() 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();
|
||||
|
||||
await _iapService?.loadStoreKitProducts(result.packages);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_logger.warning(
|
||||
message: 'Failed to reload packages after purchase',
|
||||
extra: {'error': e.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPurchaseStateChanged() {
|
||||
final service = _iapService;
|
||||
if (service == null || !mounted) return;
|
||||
@@ -100,6 +126,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
case PurchaseFlowState.success:
|
||||
Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
|
||||
_refreshBalance();
|
||||
_reloadPackages();
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.failed:
|
||||
@@ -265,7 +292,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
if (product != null) {
|
||||
return product.price;
|
||||
}
|
||||
return pkg.priceDisplay;
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PackageInfo pkg) async {
|
||||
@@ -284,6 +311,9 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.newUserPack) {
|
||||
return l10n.settingsCoinPackNewUserBadge;
|
||||
}
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
}
|
||||
@@ -293,7 +323,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
|
||||
return switch (pkg.productCode) {
|
||||
ProductCode.newUserPack => l10n.settingsCoinPackStarter,
|
||||
ProductCode.basicPack => l10n.settingsCoinPackBasic,
|
||||
ProductCode.starterPack => l10n.settingsCoinPackBasic,
|
||||
ProductCode.popularPack => l10n.settingsCoinPackPopular,
|
||||
ProductCode.premiumPack => l10n.settingsCoinPackPremium,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../../data/models/profile_settings.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
import '../models/legal_document_type.dart';
|
||||
import '../utils/legal_document_assets.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import 'account_data_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
import 'feedback_screen.dart';
|
||||
@@ -18,6 +18,9 @@ import 'invite_screen.dart';
|
||||
import 'legal_document_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
|
||||
// 临时标志位:我的邀请逻辑施工完毕后删除此标志位,并恢复入口与子页面访问。
|
||||
final bool _showInviteEntry = false;
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({
|
||||
super.key,
|
||||
@@ -48,7 +51,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
onSaveProfile;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
@@ -118,13 +121,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openGeneralSettings,
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
title: l10n.settingsInviteTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openInvite,
|
||||
),
|
||||
if (_showInviteEntry)
|
||||
SettingsMenuTile(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
title: l10n.settingsInviteTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openInvite,
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.feedback_outlined,
|
||||
title: l10n.settingsFeedbackTitle,
|
||||
@@ -142,7 +146,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: _openAccountDelete,
|
||||
onTap: _openAccountData,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -161,7 +165,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: l10n.privacyPolicy,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy),
|
||||
onTap: () =>
|
||||
_openLegalDocument(LegalDocumentType.privacyPolicy),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
@@ -169,7 +174,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.termsOfService),
|
||||
onTap: () =>
|
||||
_openLegalDocument(LegalDocumentType.termsOfService),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -222,6 +228,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openInvite() async {
|
||||
if (!_showInviteEntry) {
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository),
|
||||
@@ -261,11 +270,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAccountDelete() async {
|
||||
Future<void> _openAccountData() async {
|
||||
final deleted = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) =>
|
||||
AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount),
|
||||
AccountDataScreen(onDeleteAccount: widget.onDeleteAccount),
|
||||
),
|
||||
);
|
||||
if (deleted != true) {
|
||||
|
||||
@@ -445,6 +445,74 @@ class CoinPackageCard extends StatelessWidget {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
l10n.settingsCoinAmount(amount),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (badge != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.historyGoldBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: palette.historyGoldText,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
if (!isAvailable && unavailableMessage != null)
|
||||
Text(
|
||||
unavailableMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.error,
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
price,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: colors.primary),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
|
||||
Reference in New Issue
Block a user