feat(points): 实现积分流水列表功能
- 后端新增 GET /api/v1/points/ledger 接口 - 前端新增积分流水列表页面 - 积分中心添加「查看流水」入口 - 重命名 AccountDeleteScreen 为 AccountDataScreen - 流水列表支持分页加载和空状态展示
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-17
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user