payment
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
118f0e2ede
commit
b95a3f048d
13 changed files with 563 additions and 19 deletions
|
|
@ -75,6 +75,19 @@ class PaymentManager {
|
|||
return payment?.toDto();
|
||||
}
|
||||
|
||||
/// Получить последний платеж для пользователя и пака
|
||||
/// Используется при возврате с ЮКассы без paymentId
|
||||
Future<PaymentDto?> getLatestPaymentForUserAndPack({
|
||||
required String userId,
|
||||
required String packId,
|
||||
}) async {
|
||||
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack(
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
);
|
||||
return payment?.toDto();
|
||||
}
|
||||
|
||||
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
|
||||
Future<void> grantProductToUser(
|
||||
String userId,
|
||||
|
|
@ -349,6 +362,7 @@ class PaymentManager {
|
|||
required String description,
|
||||
required String userId,
|
||||
List<MnemoCardsProductDto> products = const [],
|
||||
String? packId,
|
||||
}) async {
|
||||
print('🔍 PaymentManager.createYookassaUrl: Starting');
|
||||
print(
|
||||
|
|
@ -362,6 +376,7 @@ class PaymentManager {
|
|||
amount: amount,
|
||||
description: description,
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
);
|
||||
print(
|
||||
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class YooMoneyHandler {
|
|||
required String amount,
|
||||
required String description,
|
||||
required String userId,
|
||||
String? packId,
|
||||
}) async {
|
||||
if (!isConfigured) {
|
||||
throw Exception(
|
||||
|
|
@ -101,7 +102,7 @@ class YooMoneyHandler {
|
|||
// Build return URL for payment confirmation
|
||||
// According to spec: ReturnUrl - URL where user returns after payment
|
||||
// Max 2048 characters per spec
|
||||
final returnUrl = _buildReturnUrl(userId);
|
||||
final returnUrl = _buildReturnUrl(userId, packId: packId);
|
||||
|
||||
// Generate idempotence key for request
|
||||
// According to spec: Idempotence-Key header (required)
|
||||
|
|
@ -117,6 +118,7 @@ class YooMoneyHandler {
|
|||
'capture': true, // Auto-capture payment when succeeded
|
||||
'metadata': {
|
||||
'userId': userId,
|
||||
if (packId != null) 'packId': packId,
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
},
|
||||
};
|
||||
|
|
@ -152,6 +154,11 @@ class YooMoneyHandler {
|
|||
|
||||
final paymentStatus = paymentData['status'] as String? ?? 'pending';
|
||||
|
||||
// Update return URL with paymentId (if needed)
|
||||
// Note: YooKassa doesn't allow updating return_url after creation,
|
||||
// so we include paymentId in the initial return URL via metadata or query params
|
||||
// For now, we'll pass paymentId through the return URL query params
|
||||
|
||||
// Extract confirmation URL from payment response
|
||||
// According to spec: confirmation.confirmation_url for redirect type
|
||||
String? confirmationUrl;
|
||||
|
|
@ -252,15 +259,31 @@ class YooMoneyHandler {
|
|||
|
||||
/// Build return URL for payment confirmation
|
||||
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
|
||||
String _buildReturnUrl(String userId) {
|
||||
String _buildReturnUrl(
|
||||
String userId, {
|
||||
String? packId,
|
||||
String? paymentId,
|
||||
}) {
|
||||
// Construct query parameters
|
||||
final params = <String, String>{
|
||||
'userId': userId,
|
||||
if (packId != null) 'packId': packId,
|
||||
if (paymentId != null) 'paymentId': paymentId,
|
||||
};
|
||||
final query = params.entries
|
||||
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
// Use configured return URL base if available
|
||||
final returnUrlBase = _returnUrlBase;
|
||||
if (returnUrlBase != null && returnUrlBase.isNotEmpty) {
|
||||
return '$returnUrlBase?userId=$userId';
|
||||
// Remove trailing slash and query params from base
|
||||
final base = returnUrlBase.split('?').first.replaceAll(RegExp(r'/$'), '');
|
||||
return '$base?$query';
|
||||
}
|
||||
|
||||
// Default to web app URL
|
||||
// This should be configured via environment variable in production
|
||||
return 'https://mnemo-cards.online/payment/return?userId=$userId';
|
||||
return 'https://mnemo-cards.online/payment/return?$query';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class PurchasesApiV2 {
|
|||
description: 'Покупка пакета: ${pack.title}',
|
||||
userId: user.id!,
|
||||
products: products,
|
||||
packId: packId,
|
||||
);
|
||||
print(
|
||||
'✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}',
|
||||
|
|
@ -291,6 +292,7 @@ class PurchasesApiV2 {
|
|||
description: description,
|
||||
userId: user.id!,
|
||||
products: products,
|
||||
packId: productType == MnemoCardsProductType.pack ? productId : null,
|
||||
);
|
||||
|
||||
// Build return URL for payment verification
|
||||
|
|
@ -328,8 +330,23 @@ class PurchasesApiV2 {
|
|||
return _badRequest('productId query parameter is required');
|
||||
}
|
||||
|
||||
// If paymentId is "latest", find the latest payment for this user and product
|
||||
String actualPaymentId = paymentId;
|
||||
if (paymentId == 'latest' && user.id != null) {
|
||||
final latestPayment = await _paymentManager
|
||||
.getLatestPaymentForUserAndPack(
|
||||
userId: user.id!,
|
||||
packId: productId,
|
||||
);
|
||||
if (latestPayment == null || latestPayment.externalToken == null) {
|
||||
return _notFound('No payment found for this pack');
|
||||
}
|
||||
actualPaymentId = latestPayment.externalToken!;
|
||||
}
|
||||
|
||||
// Check payment status
|
||||
final isSuccess = await _paymentManager.checkYookassaPayment(paymentId);
|
||||
final isSuccess =
|
||||
await _paymentManager.checkYookassaPayment(actualPaymentId);
|
||||
|
||||
// Get product information
|
||||
MnemoCardsProductDto? product;
|
||||
|
|
@ -346,7 +363,7 @@ class PurchasesApiV2 {
|
|||
}
|
||||
|
||||
return _ok({
|
||||
'paymentId': paymentId,
|
||||
'paymentId': actualPaymentId,
|
||||
'status': isSuccess ? 'verified' : 'pending',
|
||||
'result': isSuccess,
|
||||
if (product != null) 'product': product.toJson(),
|
||||
|
|
|
|||
|
|
@ -206,6 +206,21 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
|
|||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
/// Получить последний платеж пользователя для продукта (пака)
|
||||
/// Используется для получения paymentId при возврате с ЮКассы
|
||||
Future<Payment?> getLatestPaymentForUserAndPack({
|
||||
required String userId,
|
||||
required String packId,
|
||||
}) async {
|
||||
final query = selectActive()
|
||||
..where((p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%'))
|
||||
..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
|
||||
..limit(1);
|
||||
|
||||
final results = await query.get();
|
||||
return results.isNotEmpty ? results.first : null;
|
||||
}
|
||||
|
||||
/// Подсчитать платежи пользователя (только активные)
|
||||
Future<int> countPaymentsByUserId(String userId) async {
|
||||
final countExpr = payments.id.count();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../domain/services/image_cache_service.dart';
|
|||
import '../../domain/services/pack_progress_service.dart';
|
||||
import '../../domain/services/statistics_service.dart';
|
||||
import '../../domain/services/purchases_service.dart';
|
||||
import '../../domain/events/purchase_event_broadcaster.dart';
|
||||
import '../../domain/state/ads_reward_state_manager.dart';
|
||||
import '../../domain/state/favorites_state_manager.dart';
|
||||
import '../../domain/state/games_state_manager.dart';
|
||||
|
|
@ -82,6 +83,9 @@ abstract class UserScope implements Scope {
|
|||
|
||||
/// Statistics state manager
|
||||
StatisticsStateManager get statisticsStateManager;
|
||||
|
||||
/// Purchase event broadcaster for purchase notifications
|
||||
PurchaseEventBroadcaster get purchaseEventBroadcaster;
|
||||
}
|
||||
|
||||
/// Interface for parent scope (AppScope)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import '../../domain/services/purchases_service.dart';
|
|||
import '../../domain/services/pack_progress_service.dart';
|
||||
import '../../domain/services/statistics_service.dart';
|
||||
import '../../domain/services/ads_reward_service.dart';
|
||||
import '../../domain/events/purchase_event_broadcaster.dart';
|
||||
import '../../domain/state/ads_reward_state_manager.dart';
|
||||
import '../../domain/state/favorites_state_manager.dart';
|
||||
import '../../domain/state/games_state_manager.dart';
|
||||
|
|
@ -80,6 +81,13 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
|
|||
// Statistics Module
|
||||
late final statisticsModuleDep = dep(() => StatisticsModule(this));
|
||||
|
||||
// Purchase Event Broadcaster
|
||||
late final purchaseEventBroadcasterDep = rawAsyncDep(
|
||||
() => PurchaseEventBroadcaster(),
|
||||
init: (_) async {},
|
||||
dispose: (broadcaster) async => broadcaster.dispose(),
|
||||
);
|
||||
|
||||
@override
|
||||
UserStateManager get userStateManager => userStateManagerDep.get;
|
||||
|
||||
|
|
@ -146,6 +154,10 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
|
|||
StatisticsStateManager get statisticsStateManager =>
|
||||
statisticsModuleDep.get.statisticsStateManager;
|
||||
|
||||
@override
|
||||
PurchaseEventBroadcaster get purchaseEventBroadcaster =>
|
||||
purchaseEventBroadcasterDep.get;
|
||||
|
||||
// Provide httpRepository from parent
|
||||
HttpRepositoryV2 get httpRepository => parent.httpRepository;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
/// Типы событий покупки
|
||||
enum PurchaseEventType {
|
||||
/// Покупка начата (платеж создан)
|
||||
started,
|
||||
|
||||
/// Покупка завершена успешно
|
||||
completed,
|
||||
|
||||
/// Покупка отменена или ошибка
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Событие покупки
|
||||
class PurchaseEvent {
|
||||
final PurchaseEventType type;
|
||||
final String? packId;
|
||||
final String? paymentId;
|
||||
final String? message;
|
||||
final DateTime timestamp;
|
||||
|
||||
const PurchaseEvent({
|
||||
required this.type,
|
||||
this.packId,
|
||||
this.paymentId,
|
||||
this.message,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PurchaseEvent(${type.name}, pack: $packId, payment: $paymentId)';
|
||||
}
|
||||
|
||||
/// Broadcaster для событий покупки
|
||||
///
|
||||
/// Используется для уведомления различных частей приложения
|
||||
/// о событиях покупки (начало, завершение, ошибка)
|
||||
class PurchaseEventBroadcaster {
|
||||
final _controller = StreamController<PurchaseEvent>.broadcast();
|
||||
|
||||
/// Stream событий покупки
|
||||
Stream<PurchaseEvent> get stream => _controller.stream;
|
||||
|
||||
/// Отправить событие о начале покупки
|
||||
void onPurchaseStarted({
|
||||
required String packId,
|
||||
required String paymentId,
|
||||
}) {
|
||||
log(
|
||||
'Purchase started: packId=$packId, paymentId=$paymentId',
|
||||
name: 'PurchaseEventBroadcaster',
|
||||
);
|
||||
_controller.add(
|
||||
PurchaseEvent(
|
||||
type: PurchaseEventType.started,
|
||||
packId: packId,
|
||||
paymentId: paymentId,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправить событие о завершении покупки
|
||||
void onPurchaseCompleted({
|
||||
required String packId,
|
||||
required String paymentId,
|
||||
}) {
|
||||
log(
|
||||
'Purchase completed: packId=$packId, paymentId=$paymentId',
|
||||
name: 'PurchaseEventBroadcaster',
|
||||
);
|
||||
_controller.add(
|
||||
PurchaseEvent(
|
||||
type: PurchaseEventType.completed,
|
||||
packId: packId,
|
||||
paymentId: paymentId,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Отправить событие об ошибке покупки
|
||||
void onPurchaseFailed({
|
||||
required String packId,
|
||||
String? paymentId,
|
||||
String? message,
|
||||
}) {
|
||||
log(
|
||||
'Purchase failed: packId=$packId, paymentId=$paymentId, error=$message',
|
||||
name: 'PurchaseEventBroadcaster',
|
||||
);
|
||||
_controller.add(
|
||||
PurchaseEvent(
|
||||
type: PurchaseEventType.failed,
|
||||
packId: packId,
|
||||
paymentId: paymentId,
|
||||
message: message,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Закрыть broadcaster (вызывать в dispose)
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
|
||||
import '../../../di/app_scope/app_scope_container.dart';
|
||||
import '../../../domain/events/purchase_event_broadcaster.dart';
|
||||
import '../../../presentation/theme/app_colors.dart';
|
||||
import '../../../presentation/widgets/pack_card_item.dart';
|
||||
import '../../../presentation/widgets/pack_details_header.dart';
|
||||
|
|
@ -51,6 +53,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
Map<String, int> _previousCardIndexById = {};
|
||||
int _lastAnimatedShuffleKey = 0;
|
||||
bool _isNavigatingToPurchase = false;
|
||||
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -58,6 +61,53 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
_loadPack();
|
||||
_loadTests();
|
||||
_loadPackProgress();
|
||||
_subscribeToPurchaseEvents();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_purchaseEventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _subscribeToPurchaseEvents() {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
|
||||
if (userScope == null) {
|
||||
log(
|
||||
'User scope not available, skipping purchase event subscription',
|
||||
name: 'PackDetailsPage',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_purchaseEventSubscription =
|
||||
userScope.purchaseEventBroadcaster.stream.listen(
|
||||
(event) {
|
||||
// Перезагрузить данные только если событие относится к текущему паку
|
||||
if (event.packId == widget.packId &&
|
||||
event.type == PurchaseEventType.completed) {
|
||||
log(
|
||||
'Purchase completed for pack ${widget.packId}, reloading data',
|
||||
name: 'PackDetailsPage',
|
||||
);
|
||||
_loadPack();
|
||||
_loadPackProgress();
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
log(
|
||||
'Error in purchase event stream',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
name: 'PackDetailsPage',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadPack() async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
|
||||
import '../../../di/app_scope/app_scope_container.dart';
|
||||
|
||||
/// Страница обработки возврата с ЮКассы
|
||||
///
|
||||
/// Автоматически верифицирует платеж и перенаправляет на страницу пака
|
||||
/// Query parameters:
|
||||
/// - userId: ID пользователя
|
||||
/// - paymentId: ID платежа в ЮКассе
|
||||
/// - packId: ID купленного пака
|
||||
class PaymentReturnPage extends StatefulWidget {
|
||||
const PaymentReturnPage({
|
||||
required this.queryParams,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Map<String, String> queryParams;
|
||||
|
||||
@override
|
||||
State<PaymentReturnPage> createState() => _PaymentReturnPageState();
|
||||
}
|
||||
|
||||
class _PaymentReturnPageState extends State<PaymentReturnPage> {
|
||||
String? _errorMessage;
|
||||
bool _isVerifying = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_verifyAndRedirect();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _verifyAndRedirect() async {
|
||||
var paymentId = widget.queryParams['paymentId'];
|
||||
final packId = widget.queryParams['packId'];
|
||||
final userId = widget.queryParams['userId'];
|
||||
|
||||
log(
|
||||
'Processing payment return: paymentId=$paymentId, packId=$packId, userId=$userId',
|
||||
name: 'PaymentReturnPage',
|
||||
);
|
||||
|
||||
// Валидация параметров
|
||||
if (packId == null) {
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'Некорректные параметры возврата. Пожалуйста, проверьте свои покупки в профиле.';
|
||||
_isVerifying = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Если paymentId отсутствует, используем "latest" для поиска последнего платежа
|
||||
if (paymentId == null) {
|
||||
log(
|
||||
'PaymentId not provided, will use latest payment for pack',
|
||||
name: 'PaymentReturnPage',
|
||||
);
|
||||
paymentId = 'latest';
|
||||
}
|
||||
|
||||
try {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
|
||||
if (userScope == null) {
|
||||
throw Exception('User scope не доступен');
|
||||
}
|
||||
|
||||
// Верифицировать платеж
|
||||
log('Verifying payment: $paymentId', name: 'PaymentReturnPage');
|
||||
final result = await userScope.purchasesService.verifyPayment(
|
||||
paymentId: paymentId,
|
||||
productId: packId,
|
||||
productType: MnemoCardsProductType.pack,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.isSuccess) {
|
||||
log(
|
||||
'Payment verified successfully, emitting event',
|
||||
name: 'PaymentReturnPage',
|
||||
);
|
||||
|
||||
// Отправить событие о завершении покупки
|
||||
userScope.purchaseEventBroadcaster.onPurchaseCompleted(
|
||||
packId: packId,
|
||||
paymentId: paymentId,
|
||||
);
|
||||
|
||||
// Показать успешное сообщение
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Покупка успешно завершена! 🎉'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Небольшая задержка для показа сообщения
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Перенаправить на страницу пака
|
||||
log('Redirecting to pack: $packId', name: 'PaymentReturnPage');
|
||||
context.go('/pack/$packId');
|
||||
} else {
|
||||
// Платеж не подтвержден
|
||||
log(
|
||||
'Payment verification failed: ${result.status}',
|
||||
name: 'PaymentReturnPage',
|
||||
);
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'Платеж еще не подтвержден (${result.status}). Пожалуйста, подождите несколько минут и проверьте доступ к паку позже.';
|
||||
_isVerifying = false;
|
||||
});
|
||||
}
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'Error verifying payment',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
name: 'PaymentReturnPage',
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'Ошибка проверки платежа: ${e.toString()}. Попробуйте проверить доступ к паку позже или обратитесь в поддержку.';
|
||||
_isVerifying = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Обработка платежа'),
|
||||
),
|
||||
body: Center(
|
||||
child: _isVerifying
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Проверяем платеж...',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Это может занять несколько секунд',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Не удалось подтвердить платеж',
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? 'Произошла ошибка',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/home'),
|
||||
child: const Text('На главную'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isVerifying = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_verifyAndRedirect();
|
||||
},
|
||||
child: const Text('Попробовать снова'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +96,21 @@ class _PurchasePageState extends State<PurchasePage> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Emit purchase started event
|
||||
if (mounted) {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
if (userScope != null) {
|
||||
userScope.purchaseEventBroadcaster.onPurchaseStarted(
|
||||
packId: widget.packId,
|
||||
paymentId: payment.paymentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Open payment URL
|
||||
final uri = Uri.parse(payment.purchaseUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../pages/game/game_page.dart';
|
|||
import '../pages/games/games_page.dart';
|
||||
import '../pages/home/home_page.dart';
|
||||
import '../pages/pack_details/pack_details_page.dart';
|
||||
import '../pages/payment_return/payment_return_page.dart';
|
||||
import '../pages/playground/playground_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/purchase/purchase_page.dart';
|
||||
|
|
@ -133,6 +134,18 @@ GoRouter createAppRouter({required UserScopeHolder userScopeHolder}) {
|
|||
},
|
||||
),
|
||||
|
||||
// Payment Return Page (from YooKassa)
|
||||
GoRoute(
|
||||
path: '/payment/return',
|
||||
name: 'payment-return',
|
||||
pageBuilder: (context, state) {
|
||||
final queryParams = state.uri.queryParameters;
|
||||
return MaterialPage(
|
||||
child: PaymentReturnPage(queryParams: queryParams),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Test Page
|
||||
GoRoute(
|
||||
path: '/test/:testId',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../../utils/card_image_utils.dart';
|
||||
import '../../utils/color_extension.dart';
|
||||
|
||||
/// Кастомный заголовок для страницы деталей пака
|
||||
|
|
@ -94,19 +96,7 @@ class PackDetailsHeader extends StatelessWidget {
|
|||
const SizedBox(width: 16),
|
||||
|
||||
// Pack icon or image
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
color: packColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
_buildPackImage(packColor),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
|
|
@ -150,6 +140,53 @@ class PackDetailsHeader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildPackImage(Color packColor) {
|
||||
final imageUrl = CardImageUtils.getPackCoverUrlFromDto(pack);
|
||||
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: packColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: 48,
|
||||
height: 48,
|
||||
progressIndicatorBuilder: (context, url, progress) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: progress.progress,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
color: packColor,
|
||||
size: 28,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark,
|
||||
color: packColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressSection(
|
||||
BuildContext context,
|
||||
Color packColor,
|
||||
|
|
|
|||
|
|
@ -80,4 +80,8 @@ class CardImageUtils {
|
|||
// Fallback: use helper method
|
||||
return ApiConfigV2.getPackCoverUrl(pack.id);
|
||||
}
|
||||
|
||||
static String? getPackCoverUrlFromDto(CardPackDto pack) {
|
||||
return ApiConfigV2.getPackCoverUrl(pack.id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue