feat(points): 实现积分流水列表功能

- 后端新增 GET /api/v1/points/ledger 接口
- 前端新增积分流水列表页面
- 积分中心添加「查看流水」入口
- 重命名 AccountDeleteScreen 为 AccountDataScreen
- 流水列表支持分页加载和空状态展示
This commit is contained in:
ZL-Q
2026-04-28 17:19:08 +08:00
parent a83001de0d
commit 940c67e642
12 changed files with 794 additions and 70 deletions
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../models/ledger_item.dart';
import '../models/package_info.dart'; import '../models/package_info.dart';
class PointsApi { class PointsApi {
@@ -11,4 +12,19 @@ class PointsApi {
final response = await _dio.get('/api/v1/points/packages'); final response = await _dio.get('/api/v1/points/packages');
return PackagesResult.fromJson(response.data as Map<String, dynamic>); return PackagesResult.fromJson(response.data as Map<String, dynamic>);
} }
Future<LedgerListResult> getLedger({
int limit = 20,
String? cursor,
}) async {
final query = <String, dynamic>{'limit': limit};
if (cursor != null) {
query['cursor'] = cursor;
}
final response = await _dio.get(
'/api/v1/points/ledger',
queryParameters: query,
);
return LedgerListResult.fromJson(response.data as Map<String, dynamic>);
}
} }
@@ -0,0 +1,50 @@
class LedgerItem {
const LedgerItem({
required this.id,
required this.direction,
required this.amount,
required this.balanceAfter,
required this.changeType,
required this.createdAt,
});
final String id;
final int direction;
final int amount;
final int balanceAfter;
final String changeType;
final String createdAt;
factory LedgerItem.fromJson(Map<String, dynamic> json) {
return LedgerItem(
id: json['id'] as String,
direction: json['direction'] as int,
amount: json['amount'] as int,
balanceAfter: json['balanceAfter'] as int,
changeType: json['changeType'] as String,
createdAt: json['createdAt'] as String,
);
}
}
class LedgerListResult {
const LedgerListResult({
required this.items,
this.nextCursor,
required this.hasMore,
});
final List<LedgerItem> items;
final String? nextCursor;
final bool hasMore;
factory LedgerListResult.fromJson(Map<String, dynamic> json) {
return LedgerListResult(
items: (json['items'] as List<dynamic>)
.map((e) => LedgerItem.fromJson(e as Map<String, dynamic>))
.toList(),
nextCursor: json['nextCursor'] as String?,
hasMore: json['hasMore'] as bool,
);
}
}
@@ -0,0 +1,346 @@
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 '../../../../shared/utils/time_format.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../data/apis/points_api.dart';
import '../../data/models/ledger_item.dart';
class PointsLedgerScreen extends StatefulWidget {
const PointsLedgerScreen({super.key});
@override
State<PointsLedgerScreen> createState() => _PointsLedgerScreenState();
}
class _PointsLedgerScreenState extends State<PointsLedgerScreen> {
final Logger _logger = getLogger('features.points.ledger_screen');
late final ScrollController _scrollController;
late final PointsApi _api;
final List<LedgerItem> _items = [];
String? _nextCursor;
bool _hasMore = false;
bool _isLoading = false;
bool _isLoadingMore = false;
Object? _error;
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
final sessionStore = SessionStore(LocalKvStore());
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: sessionStore.getToken,
);
_api = PointsApi(apiClient.rawDio);
_loadInitial();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
_loadMore();
}
}
Future<void> _loadInitial() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final result = await _api.getLedger();
if (mounted) {
setState(() {
_items.clear();
_items.addAll(result.items);
_nextCursor = result.nextCursor;
_hasMore = result.hasMore;
_isLoading = false;
});
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load ledger',
error: e,
stackTrace: stackTrace,
);
if (mounted) {
setState(() {
_error = e;
_isLoading = false;
});
}
}
}
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore || _nextCursor == null) return;
setState(() {
_isLoadingMore = true;
});
try {
final result = await _api.getLedger(cursor: _nextCursor);
if (mounted) {
setState(() {
_items.addAll(result.items);
_nextCursor = result.nextCursor;
_hasMore = result.hasMore;
_isLoadingMore = false;
});
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load more ledger items',
error: e,
stackTrace: stackTrace,
);
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
}
}
String _getDisplayText(AppLocalizations l10n, String changeType) {
return switch (changeType) {
'register' => l10n.pointsLedgerTypeRegister,
'purchase' => l10n.pointsLedgerTypePurchase,
'consume' => l10n.pointsLedgerTypeConsume,
'adjust' => l10n.pointsLedgerTypeAdjust,
'refund' => l10n.pointsLedgerTypeRefund,
_ => changeType,
};
}
IconData _getIcon(String changeType) {
return switch (changeType) {
'register' => Icons.card_giftcard_rounded,
'purchase' => Icons.shopping_bag_rounded,
'consume' => Icons.chat_bubble_outline_rounded,
'adjust' => Icons.tune_rounded,
'refund' => Icons.replay_rounded,
_ => Icons.swap_vert_rounded,
};
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.pointsLedgerTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: _buildBody(l10n, colors, palette),
);
}
Widget _buildBody(
AppLocalizations l10n,
ColorScheme colors,
AppColorPalette palette,
) {
if (_isLoading) {
return const Center(child: AppLoadingIndicator());
}
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colors.error),
const SizedBox(height: AppSpacing.md),
Text(
l10n.errorRequestGeneric,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.lg),
FilledButton(onPressed: _loadInitial, child: Text(l10n.retry)),
],
),
);
}
if (_items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.receipt_long_outlined,
size: 64,
color: colors.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: AppSpacing.md),
Text(
l10n.pointsLedgerEmpty,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.lg),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _items.length) {
if (_isLoadingMore) {
return const Padding(
padding: EdgeInsets.all(AppSpacing.lg),
child: Center(child: AppLoadingIndicator()),
);
}
return const SizedBox.shrink();
}
final item = _items[index];
final isIncome = item.direction == 1;
final amountText = isIncome ? '+${item.amount}' : '-${item.amount}';
return _LedgerItemCard(
icon: _getIcon(item.changeType),
displayText: _getDisplayText(l10n, item.changeType),
amountText: amountText,
isIncome: isIncome,
balanceAfter: item.balanceAfter,
balanceText: l10n.pointsLedgerBalance(item.balanceAfter),
dateTime: formatCompactLocalDateTime(item.createdAt),
colors: colors,
palette: palette,
);
},
);
}
}
class _LedgerItemCard extends StatelessWidget {
const _LedgerItemCard({
required this.icon,
required this.displayText,
required this.amountText,
required this.isIncome,
required this.balanceAfter,
required this.balanceText,
required this.dateTime,
required this.colors,
required this.palette,
});
final IconData icon;
final String displayText;
final String amountText;
final bool isIncome;
final int balanceAfter;
final String balanceText;
final String dateTime;
final ColorScheme colors;
final AppColorPalette palette;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isIncome
? palette.incomeGreenBg.withValues(alpha: 0.15)
: colors.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
icon,
size: 22,
color: isIncome ? palette.incomeGreenText : colors.error,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
dateTime,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
amountText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: isIncome ? palette.incomeGreenText : colors.error,
),
),
const SizedBox(height: 2),
Text(
balanceText,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
),
],
),
],
),
);
}
}
@@ -9,19 +9,20 @@ import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../points/presentation/screens/points_ledger_screen.dart';
import '../widgets/settings_section_widgets.dart'; import '../widgets/settings_section_widgets.dart';
class AccountDeleteScreen extends StatefulWidget { class AccountDataScreen extends StatefulWidget {
const AccountDeleteScreen({super.key, required this.onDeleteAccount}); const AccountDataScreen({super.key, required this.onDeleteAccount});
final Future<void> Function() onDeleteAccount; final Future<void> Function() onDeleteAccount;
@override @override
State<AccountDeleteScreen> createState() => _AccountDeleteScreenState(); State<AccountDataScreen> createState() => _AccountDataScreenState();
} }
class _AccountDeleteScreenState extends State<AccountDeleteScreen> { class _AccountDataScreenState extends State<AccountDataScreen> {
final Logger _logger = getLogger('features.settings.account_delete'); final Logger _logger = getLogger('features.settings.account_data');
bool _isDeleting = false; bool _isDeleting = false;
@override @override
@@ -42,6 +43,13 @@ class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
children: [ children: [
SettingsGroupCard( SettingsGroupCard(
children: [ children: [
SettingsMenuTile(
icon: Icons.receipt_long_rounded,
title: l10n.pointsLedgerTitle,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: _openPointsLedger,
),
SettingsMenuTile( SettingsMenuTile(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
title: l10n.settingsDeleteAccountTitle, 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 { Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -194,8 +210,8 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
child: Text( child: Text(
l10n.settingsDeleteAccountDialogTitle, l10n.settingsDeleteAccountDialogTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
], ],
@@ -204,9 +220,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
Text( Text(
l10n.settingsDeleteAccountWarningBody, l10n.settingsDeleteAccountWarningBody,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
height: 1.45, height: 1.45,
), ),
), ),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
Container( Container(
@@ -220,10 +236,10 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
child: Text( child: Text(
l10n.settingsDeleteAccountReRegisterNotice, l10n.settingsDeleteAccountReRegisterNotice,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer, color: colors.onErrorContainer,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
height: 1.35, height: 1.35,
), ),
), ),
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -232,9 +248,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
? l10n.settingsDeleteAccountWaitAction(_secondsLeft) ? l10n.settingsDeleteAccountWaitAction(_secondsLeft)
: l10n.settingsDeleteAccountDialogBody, : l10n.settingsDeleteAccountDialogBody,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.error, color: colors.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Row( 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() { void _onPurchaseStateChanged() {
final service = _iapService; final service = _iapService;
if (service == null || !mounted) return; if (service == null || !mounted) return;
@@ -100,6 +126,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
case PurchaseFlowState.success: case PurchaseFlowState.success:
Toast.show(context, l10n.paymentSuccess, type: ToastType.success); Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
_refreshBalance(); _refreshBalance();
_reloadPackages();
service.resetState(); service.resetState();
break; break;
case PurchaseFlowState.failed: case PurchaseFlowState.failed:
@@ -265,7 +292,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
if (product != null) { if (product != null) {
return product.price; return product.price;
} }
return pkg.priceDisplay; return '';
} }
Future<void> _handlePurchase(PackageInfo pkg) async { Future<void> _handlePurchase(PackageInfo pkg) async {
@@ -284,6 +311,9 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
} }
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) { String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
if (pkg.productCode == ProductCode.newUserPack) {
return l10n.settingsCoinPackNewUserBadge;
}
if (pkg.productCode == ProductCode.popularPack) { if (pkg.productCode == ProductCode.popularPack) {
return l10n.settingsCoinPackPopularBadge; return l10n.settingsCoinPackPopularBadge;
} }
@@ -293,7 +323,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) { String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
return switch (pkg.productCode) { return switch (pkg.productCode) {
ProductCode.newUserPack => l10n.settingsCoinPackStarter, ProductCode.newUserPack => l10n.settingsCoinPackStarter,
ProductCode.basicPack => l10n.settingsCoinPackBasic, ProductCode.starterPack => l10n.settingsCoinPackBasic,
ProductCode.popularPack => l10n.settingsCoinPackPopular, ProductCode.popularPack => l10n.settingsCoinPackPopular,
ProductCode.premiumPack => l10n.settingsCoinPackPremium, ProductCode.premiumPack => l10n.settingsCoinPackPremium,
}; };
@@ -9,7 +9,7 @@ import '../../data/models/profile_settings.dart';
import '../../data/repositories/invite_repository.dart'; import '../../data/repositories/invite_repository.dart';
import '../models/legal_document_type.dart'; import '../models/legal_document_type.dart';
import '../utils/legal_document_assets.dart'; import '../utils/legal_document_assets.dart';
import 'account_delete_screen.dart'; import 'account_data_screen.dart';
import '../widgets/settings_section_widgets.dart'; import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart'; import 'coin_center_screen.dart';
import 'feedback_screen.dart'; import 'feedback_screen.dart';
@@ -18,6 +18,9 @@ import 'invite_screen.dart';
import 'legal_document_screen.dart'; import 'legal_document_screen.dart';
import 'profile_edit_screen.dart'; import 'profile_edit_screen.dart';
// 临时标志位:我的邀请逻辑施工完毕后删除此标志位,并恢复入口与子页面访问。
final bool _showInviteEntry = false;
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({ const SettingsScreen({
super.key, super.key,
@@ -48,7 +51,7 @@ class SettingsScreen extends StatefulWidget {
final Future<void> Function() onLogout; final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount; final Future<void> Function() onDeleteAccount;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated) final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile; onSaveProfile;
final void Function(int newBalance) onBalanceChanged; final void Function(int newBalance) onBalanceChanged;
@override @override
@@ -118,13 +121,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
onTap: _openGeneralSettings, onTap: _openGeneralSettings,
), ),
SettingsMenuTile( if (_showInviteEntry)
icon: Icons.card_giftcard_rounded, SettingsMenuTile(
title: l10n.settingsInviteTitle, icon: Icons.card_giftcard_rounded,
tint: colors.primary, title: l10n.settingsInviteTitle,
background: colors.surfaceContainerHighest, tint: colors.primary,
onTap: _openInvite, background: colors.surfaceContainerHighest,
), onTap: _openInvite,
),
SettingsMenuTile( SettingsMenuTile(
icon: Icons.feedback_outlined, icon: Icons.feedback_outlined,
title: l10n.settingsFeedbackTitle, title: l10n.settingsFeedbackTitle,
@@ -142,7 +146,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
tint: colors.primary, tint: colors.primary,
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
showDivider: false, showDivider: false,
onTap: _openAccountDelete, onTap: _openAccountData,
), ),
], ],
), ),
@@ -161,7 +165,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: l10n.privacyPolicy, title: l10n.privacyPolicy,
tint: colors.secondary, tint: colors.secondary,
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy), onTap: () =>
_openLegalDocument(LegalDocumentType.privacyPolicy),
), ),
SettingsMenuTile( SettingsMenuTile(
icon: Icons.description_outlined, icon: Icons.description_outlined,
@@ -169,7 +174,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
tint: colors.secondary, tint: colors.secondary,
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
showDivider: false, showDivider: false,
onTap: () => _openLegalDocument(LegalDocumentType.termsOfService), onTap: () =>
_openLegalDocument(LegalDocumentType.termsOfService),
), ),
], ],
), ),
@@ -222,6 +228,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Future<void> _openInvite() async { Future<void> _openInvite() async {
if (!_showInviteEntry) {
return;
}
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository), 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>( final deleted = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>( MaterialPageRoute<bool>(
builder: (_) => builder: (_) =>
AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount), AccountDataScreen(onDeleteAccount: widget.onDeleteAccount),
), ),
); );
if (deleted != true) { if (deleted != true) {
@@ -445,6 +445,74 @@ class CoinPackageCard extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl), 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( child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
child: Column( child: Column(
+17
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@@ -209,3 +210,19 @@ class PointsRepository:
stmt = select(Profile.settings).where(Profile.id == user_id).limit(1) stmt = select(Profile.settings).where(Profile.id == user_id).limit(1)
row = (await self._session.execute(stmt)).scalar_one_or_none() row = (await self._session.execute(stmt)).scalar_one_or_none()
return row return row
async def list_ledger(
self,
*,
user_id: UUID,
limit: int,
cursor: datetime | None = None,
) -> tuple[list[PointsLedger], bool]:
stmt = select(PointsLedger).where(PointsLedger.user_id == user_id)
if cursor is not None:
stmt = stmt.where(PointsLedger.created_at < cursor)
stmt = stmt.order_by(PointsLedger.created_at.desc()).limit(limit + 1)
rows = list((await self._session.execute(stmt)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
return (items, has_more)
+55 -2
View File
@@ -1,18 +1,42 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload
from v1.points.dependencies import get_points_service from v1.points.dependencies import get_points_service
from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse from v1.points.schemas import (
PackagesResponse,
PackageInfo,
PointsBalanceResponse,
LedgerListResponse,
LedgerItem,
)
from v1.points.service import PointsService from v1.points.service import PointsService
from v1.users.dependencies import get_current_user from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/points", tags=["points"]) router = APIRouter(prefix="/points", tags=["points"])
def _parse_cursor(cursor: str | None) -> datetime | None:
if cursor is None:
return None
try:
return datetime.fromisoformat(cursor.replace("Z", "+00:00"))
except ValueError as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="POINTS_INVALID_CURSOR",
detail="Points ledger cursor must be an ISO 8601 datetime",
params={"cursor": cursor},
),
) from exc
@router.get("/balance", response_model=PointsBalanceResponse) @router.get("/balance", response_model=PointsBalanceResponse)
async def get_points_balance( async def get_points_balance(
service: Annotated[PointsService, Depends(get_points_service)], service: Annotated[PointsService, Depends(get_points_service)],
@@ -55,3 +79,32 @@ async def get_available_packages(
for pkg in result.packages for pkg in result.packages
], ],
) )
@router.get("/ledger", response_model=LedgerListResponse)
async def get_points_ledger(
service: Annotated[PointsService, Depends(get_points_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: str | None = None,
) -> LedgerListResponse:
items, next_cursor, has_more = await service.get_ledger_list(
user_id=current_user.id,
limit=limit,
cursor=_parse_cursor(cursor),
)
return LedgerListResponse(
items=[
LedgerItem(
id=item.id,
direction=item.direction,
amount=item.amount,
balanceAfter=item.balance_after,
changeType=item.change_type,
createdAt=item.created_at,
)
for item in items
],
nextCursor=next_cursor,
hasMore=has_more,
)
+19 -3
View File
@@ -23,7 +23,6 @@ class PackageInfo(BaseModel):
alias="appStoreProductId", min_length=1, max_length=256 alias="appStoreProductId", min_length=1, max_length=256
) )
type: Literal["starter", "regular"] type: Literal["starter", "regular"]
price: float = Field(ge=0)
credits: int = Field(ge=1) credits: int = Field(ge=1)
is_starter: bool = Field(alias="isStarter") is_starter: bool = Field(alias="isStarter")
starter_eligible: bool = Field(alias="starterEligible") starter_eligible: bool = Field(alias="starterEligible")
@@ -33,6 +32,23 @@ class PackageInfo(BaseModel):
class PackagesResponse(BaseModel): class PackagesResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
region: str = Field(min_length=1, max_length=8)
currency: str = Field(min_length=1, max_length=8)
packages: list[PackageInfo] packages: list[PackageInfo]
class LedgerItem(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
id: str
direction: int
amount: int = Field(ge=1)
balance_after: int = Field(alias="balanceAfter", ge=0)
change_type: str = Field(alias="changeType")
created_at: str = Field(alias="createdAt")
class LedgerListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
items: list[LedgerItem]
next_cursor: str | None = Field(alias="nextCursor", default=None)
has_more: bool = Field(alias="hasMore")
+48 -32
View File
@@ -1,16 +1,13 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal from decimal import Decimal
import hashlib import hashlib
import hmac import hmac
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from core.config.packages import (
PackageType,
get_packages_config_for_region,
)
from core.config.settings import config from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload from core.http.errors import ApiProblemError, problem_payload
from schemas.domain.points import ( from schemas.domain.points import (
@@ -22,9 +19,9 @@ from schemas.domain.points import (
) )
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from schemas.domain.points import ApplyPointsChangeCommand from schemas.domain.points import ApplyPointsChangeCommand
from schemas.shared.user import parse_profile_settings
from v1.payments.service import _load_product_mappings from v1.payments.service import _load_product_mappings
from v1.points.repository import PointsRepository from v1.points.repository import PointsRepository
from v1.points.schemas import LedgerItem
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -69,8 +66,7 @@ class RegisterBonusResult:
class PackageInfoResult: class PackageInfoResult:
product_code: str product_code: str
app_store_product_id: str app_store_product_id: str
type: PackageType type: Literal["starter", "regular"]
price: float
credits: int credits: int
sort_order: int sort_order: int
is_starter: bool is_starter: bool
@@ -79,8 +75,6 @@ class PackageInfoResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class PackagesResult: class PackagesResult:
region: str
currency: str
packages: list[PackageInfoResult] packages: list[PackageInfoResult]
@@ -449,11 +443,6 @@ class PointsService:
user_id: UUID, user_id: UUID,
user_email: str, user_email: str,
) -> PackagesResult: ) -> PackagesResult:
settings_raw = await self._repository.get_profile_settings(user_id=user_id)
settings = parse_profile_settings(settings_raw)
country = settings.preferences.country
pkg_config = get_packages_config_for_region(country)
normalized_email = self._normalize_email(user_email) normalized_email = self._normalize_email(user_email)
has_starter = False has_starter = False
@@ -466,32 +455,59 @@ class PointsService:
product_mappings = _load_product_mappings() product_mappings = _load_product_mappings()
packages: list[PackageInfoResult] = [] packages: list[PackageInfoResult] = []
for pkg in pkg_config.packages: for product_code, mapping in product_mappings.items():
if not pkg.enabled: if not mapping.enabled:
continue continue
if pkg.type == PackageType.STARTER and has_starter: pkg_type: Literal["starter", "regular"] = (
"starter" if mapping.type == "starter" else "regular"
)
if pkg_type == "starter" and has_starter:
continue continue
mapping = product_mappings.get(pkg.product_code)
app_store_product_id = mapping.app_store_product_id if mapping else ""
packages.append( packages.append(
PackageInfoResult( PackageInfoResult(
product_code=pkg.product_code, product_code=product_code,
app_store_product_id=app_store_product_id, app_store_product_id=mapping.app_store_product_id,
type=pkg.type, type=pkg_type,
price=pkg.price, credits=mapping.credits,
credits=pkg.credits, sort_order=mapping.sort_order,
sort_order=pkg.sort_order, is_starter=pkg_type == "starter",
is_starter=pkg.type == PackageType.STARTER, starter_eligible=(pkg_type == "starter" and not has_starter),
starter_eligible=(
pkg.type == PackageType.STARTER and not has_starter
),
) )
) )
return PackagesResult( return PackagesResult(
region=pkg_config.region,
currency=pkg_config.currency,
packages=sorted(packages, key=lambda p: p.sort_order), packages=sorted(packages, key=lambda p: p.sort_order),
) )
async def get_ledger_list(
self,
*,
user_id: UUID,
limit: int = 20,
cursor: datetime | None = None,
) -> tuple[list[LedgerItem], str | None, bool]:
rows, has_more = await self._repository.list_ledger(
user_id=user_id,
limit=limit,
cursor=cursor,
)
items: list[LedgerItem] = []
for row in rows:
items.append(
LedgerItem(
id=str(row.id),
direction=row.direction,
amount=row.amount,
balance_after=row.balance_after,
change_type=row.change_type,
created_at=row.created_at.isoformat(),
)
)
next_cursor: str | None = None
if has_more and items:
next_cursor = items[-1].created_at
return (items, next_cursor, has_more)
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -22,12 +23,23 @@ class _FakeAccount:
version: int = 0 version: int = 0
@dataclass
class _FakeLedgerRow:
id: UUID
direction: int
amount: int
balance_after: int
change_type: str
created_at: datetime
class _FakePointsRepository: class _FakePointsRepository:
def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None: def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None:
self.account = _FakeAccount() self.account = _FakeAccount()
self.usage_snapshot = usage_snapshot self.usage_snapshot = usage_snapshot
self.appended_ledger: list[ApplyPointsChangeCommand] = [] self.appended_ledger: list[ApplyPointsChangeCommand] = []
self.appended_audit: list[AppendAuditLedgerCommand] = [] self.appended_audit: list[AppendAuditLedgerCommand] = []
self.ledger_rows: list[_FakeLedgerRow] = []
self.claimed: bool = False self.claimed: bool = False
self.claim: RegisterBonusClaims | None = None self.claim: RegisterBonusClaims | None = None
@@ -88,6 +100,16 @@ class _FakePointsRepository:
del email_hash del email_hash
return self.claim return self.claim
async def list_ledger(
self,
*,
user_id: UUID,
limit: int,
cursor: datetime | None = None,
) -> tuple[list[_FakeLedgerRow], bool]:
del user_id, cursor
return (self.ledger_rows[:limit], len(self.ledger_rows) > limit)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None: async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None:
@@ -225,3 +247,68 @@ async def test_grant_register_bonus_if_eligible_restores_balance_snapshot() -> N
assert repository.claimed is False assert repository.claimed is False
assert len(repository.appended_ledger) == 0 assert len(repository.appended_ledger) == 0
assert len(repository.appended_audit) == 0 assert len(repository.appended_audit) == 0
@pytest.mark.asyncio
async def test_get_ledger_list_returns_items_and_next_cursor() -> None:
created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc)
repository = _FakePointsRepository(usage_snapshot=None)
repository.ledger_rows = [
_FakeLedgerRow(
id=uuid4(),
direction=1,
amount=60,
balance_after=160,
change_type="purchase",
created_at=created_at,
),
]
service = PointsService(repository=repository) # type: ignore[arg-type]
items, next_cursor, has_more = await service.get_ledger_list(
user_id=uuid4(),
limit=1,
)
assert has_more is False
assert next_cursor is None
assert len(items) == 1
assert items[0].amount == 60
assert items[0].balance_after == 160
assert items[0].change_type == "purchase"
assert items[0].created_at == created_at.isoformat()
@pytest.mark.asyncio
async def test_get_ledger_list_sets_next_cursor_when_more_rows_exist() -> None:
first_created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc)
second_created_at = datetime(2026, 4, 27, 8, 30, tzinfo=timezone.utc)
repository = _FakePointsRepository(usage_snapshot=None)
repository.ledger_rows = [
_FakeLedgerRow(
id=uuid4(),
direction=1,
amount=60,
balance_after=160,
change_type="purchase",
created_at=first_created_at,
),
_FakeLedgerRow(
id=uuid4(),
direction=-1,
amount=20,
balance_after=140,
change_type="consume",
created_at=second_created_at,
),
]
service = PointsService(repository=repository) # type: ignore[arg-type]
items, next_cursor, has_more = await service.get_ledger_list(
user_id=uuid4(),
limit=1,
)
assert has_more is True
assert len(items) == 1
assert next_cursor == first_created_at.isoformat()