feat(web_v2): Add Buy Pack Button to PackDetailsPage
Task ID: WEB-003 Priority: high Changes: Completed by: AI Agent Duration: 839723ms
This commit is contained in:
parent
108abfd133
commit
d0b939375e
4 changed files with 488 additions and 41 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"component": "web_v2",
|
||||
"current_task_id": "WEB-001",
|
||||
"iteration_count": 2,
|
||||
"current_task_id": "WEB-003",
|
||||
"iteration_count": 3,
|
||||
"max_iterations": 10,
|
||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||
"last_commit": null,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
"id": "WEB-003",
|
||||
"title": "Add Buy Pack Button to PackDetailsPage",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"estimated_hours": 2.0,
|
||||
"description": "Add a 'Buy Pack' button to PackDetailsPage that navigates to PurchasePage when a pack requires purchase. This completes the pack purchase flow integration.",
|
||||
"acceptance_criteria": [
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
double _shuffleAnimationTurns = 0;
|
||||
Map<int, int> _previousCardIndexById = {};
|
||||
int _lastAnimatedShuffleKey = 0;
|
||||
bool _isNavigatingToPurchase = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -78,14 +79,6 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
final packResponse = await appScope.httpRepository.getPack(widget.packId);
|
||||
|
||||
if (mounted) {
|
||||
// Check if pack needs to be purchased
|
||||
if (packResponse.responseType == GetCardPackResponseType.buy) {
|
||||
log('Pack requires purchase, redirecting to purchase page', name: 'PackDetailsPage');
|
||||
// Navigate to purchase page
|
||||
context.replace('/purchase/${widget.packId}');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_packResponse = packResponse;
|
||||
_isLoading = false;
|
||||
|
|
@ -222,7 +215,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
return _buildErrorState();
|
||||
}
|
||||
|
||||
if (_packResponse == null || _packResponse is! CardPackDto) {
|
||||
if (_packResponse == null) {
|
||||
return const Center(child: Text('Pack not found'));
|
||||
}
|
||||
|
||||
|
|
@ -260,10 +253,109 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Checks if the pack requires purchase
|
||||
bool _requiresPurchase() {
|
||||
return _packResponse?.responseType == GetCardPackResponseType.buy;
|
||||
}
|
||||
|
||||
/// Creates a CardPackDto from CardPackBuyDto for display purposes
|
||||
CardPackDto _createDisplayPackFromBuyDto(CardPackBuyDto buyDto) {
|
||||
return CardPackDto(
|
||||
id: buyDto.id,
|
||||
title: buyDto.title,
|
||||
subtitle: buyDto.subtitle,
|
||||
color: buyDto.color,
|
||||
version: buyDto.version,
|
||||
cards: buyDto.cards,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the Buy Pack button
|
||||
Widget _buildBuyPackButton(Color packColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: FilledButton(
|
||||
onPressed: _isNavigatingToPurchase ? null : _navigateToPurchase,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: packColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
),
|
||||
child: _isNavigatingToPurchase
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.shopping_cart, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Buy Pack',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigates to the purchase page
|
||||
Future<void> _navigateToPurchase() async {
|
||||
if (_isNavigatingToPurchase) return;
|
||||
|
||||
setState(() {
|
||||
_isNavigatingToPurchase = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted) {
|
||||
await context.push('/purchase/${widget.packId}');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isNavigatingToPurchase = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPackDetails() {
|
||||
final pack = _packResponse! as CardPackDto;
|
||||
// Handle both CardPackDto and CardPackBuyDto
|
||||
final pack = _packResponse;
|
||||
if (pack == null) {
|
||||
return const Center(child: Text('Pack not found'));
|
||||
}
|
||||
|
||||
// Check if pack requires purchase
|
||||
final requiresPurchase = _requiresPurchase();
|
||||
|
||||
// Get pack data - CardPackDto or CardPackBuyDto both have similar structure
|
||||
CardPackDto? packDto;
|
||||
CardPackBuyDto? buyDto;
|
||||
if (pack is CardPackDto) {
|
||||
packDto = pack;
|
||||
} else if (pack is CardPackBuyDto) {
|
||||
buyDto = pack;
|
||||
}
|
||||
|
||||
// Use packDto if available, otherwise use buyDto
|
||||
final displayPack = packDto ?? _createDisplayPackFromBuyDto(buyDto!);
|
||||
final cards = _getDisplayCards();
|
||||
final packColor = pack.color?.asColor ?? AppColors.borderGray;
|
||||
final packColor = displayPack.color?.asColor ?? AppColors.borderGray;
|
||||
|
||||
// Определяем, нужно ли показывать боковую панель
|
||||
final showSidebar =
|
||||
|
|
@ -273,34 +365,41 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
children: [
|
||||
// Кастомный заголовок
|
||||
PackDetailsHeader(
|
||||
pack: pack,
|
||||
pack: displayPack,
|
||||
progress: _packProgress,
|
||||
totalCards: pack.cards.length,
|
||||
totalCards: displayPack.cards.length,
|
||||
backButtonText: 'к темам',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Панель управления
|
||||
PackDetailsControls(
|
||||
isGridView: _isGridView,
|
||||
onToggleView: () {
|
||||
setState(() {
|
||||
_isGridView = !_isGridView;
|
||||
});
|
||||
},
|
||||
onShuffle: () => _shuffleCards(),
|
||||
onToggleFavorites: () {
|
||||
setState(() {
|
||||
_isFavoritesMode = !_isFavoritesMode;
|
||||
});
|
||||
},
|
||||
isFavoritesMode: _isFavoritesMode,
|
||||
isShuffleActive: _isShuffled,
|
||||
shuffleTurns: _shuffleAnimationTurns,
|
||||
),
|
||||
// Buy Pack button if pack requires purchase
|
||||
if (requiresPurchase) ...[
|
||||
_buildBuyPackButton(packColor),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// Панель управления (only show if pack is purchased)
|
||||
if (!requiresPurchase) ...[
|
||||
PackDetailsControls(
|
||||
isGridView: _isGridView,
|
||||
onToggleView: () {
|
||||
setState(() {
|
||||
_isGridView = !_isGridView;
|
||||
});
|
||||
},
|
||||
onShuffle: () => _shuffleCards(),
|
||||
onToggleFavorites: () {
|
||||
setState(() {
|
||||
_isFavoritesMode = !_isFavoritesMode;
|
||||
});
|
||||
},
|
||||
isFavoritesMode: _isFavoritesMode,
|
||||
isShuffleActive: _isShuffled,
|
||||
shuffleTurns: _shuffleAnimationTurns,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Основное содержимое
|
||||
Expanded(
|
||||
|
|
@ -854,7 +953,16 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
|
||||
/// Shuffles the cards
|
||||
void _shuffleCards() {
|
||||
if (_packResponse == null || _packResponse is! CardPackDto) return;
|
||||
if (_packResponse == null) return;
|
||||
|
||||
List<GameCardDto> cardsToShuffle;
|
||||
if (_packResponse is CardPackDto) {
|
||||
cardsToShuffle = (_packResponse as CardPackDto).cards;
|
||||
} else if (_packResponse is CardPackBuyDto) {
|
||||
cardsToShuffle = (_packResponse as CardPackBuyDto).cards;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_shuffleAnimationKey++;
|
||||
|
|
@ -862,7 +970,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
_isShuffled = !_isShuffled;
|
||||
if (_isShuffled) {
|
||||
// Create a shuffled copy of the cards
|
||||
_shuffledCards = List<GameCardDto>.from((_packResponse as CardPackDto).cards)..shuffle();
|
||||
_shuffledCards = List<GameCardDto>.from(cardsToShuffle)..shuffle();
|
||||
log('Cards shuffled', name: 'PackDetailsPage');
|
||||
} else {
|
||||
// Reset to original order
|
||||
|
|
@ -874,13 +982,27 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
|
||||
/// Gets the cards to display (shuffled or original)
|
||||
List<GameCardDto> _getDisplayCards() {
|
||||
if (_packResponse == null || _packResponse is! CardPackDto) return [];
|
||||
if (_packResponse == null) return [];
|
||||
|
||||
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
||||
return _shuffledCards;
|
||||
// Handle CardPackDto
|
||||
if (_packResponse is CardPackDto) {
|
||||
final packDto = _packResponse as CardPackDto;
|
||||
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
||||
return _shuffledCards;
|
||||
}
|
||||
return packDto.cards;
|
||||
}
|
||||
|
||||
return (_packResponse as CardPackDto).cards;
|
||||
// Handle CardPackBuyDto
|
||||
if (_packResponse is CardPackBuyDto) {
|
||||
final buyDto = _packResponse as CardPackBuyDto;
|
||||
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
||||
return _shuffledCards;
|
||||
}
|
||||
return buyDto.cards;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Проверяет, является ли карточка избранной
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||
import 'package:mnemo_cards_web_v2/presentation/pages/pack_details/pack_details_page.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
|
||||
// Mock classes
|
||||
class MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
||||
|
||||
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
|
||||
|
||||
void main() {
|
||||
late MockAppScopeContainer mockAppScope;
|
||||
late MockHttpRepositoryV2 mockHttpRepository;
|
||||
|
||||
setUp(() {
|
||||
mockAppScope = MockAppScopeContainer();
|
||||
mockHttpRepository = MockHttpRepositoryV2();
|
||||
when(() => mockAppScope.httpRepository).thenReturn(mockHttpRepository);
|
||||
});
|
||||
|
||||
group('PackDetailsPage - Buy Pack Button', () {
|
||||
testWidgets(
|
||||
'should display Buy Pack button when pack requires purchase',
|
||||
(tester) async {
|
||||
const packId = 'test-pack-1';
|
||||
final buyDto = CardPackBuyDto(
|
||||
id: packId,
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
cards: [
|
||||
GameCardDto(
|
||||
id: 1,
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
),
|
||||
],
|
||||
price: '99₽',
|
||||
);
|
||||
|
||||
when(() => mockHttpRepository.getPack(packId))
|
||||
.thenAnswer((_) async => buyDto);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/pack/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return PackDetailsPage(packId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/purchase/:packId',
|
||||
builder: (context, state) {
|
||||
final packId = state.pathParameters['packId']!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Purchase $packId')),
|
||||
body: const Center(child: Text('Purchase Page')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
scope: mockAppScope,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pack to load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify Buy Pack button is displayed
|
||||
expect(find.text('Buy Pack'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.shopping_cart), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should navigate to PurchasePage with correct packId when Buy Pack button is tapped',
|
||||
(tester) async {
|
||||
const packId = 'test-pack-2';
|
||||
final buyDto = CardPackBuyDto(
|
||||
id: packId,
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
cards: [
|
||||
GameCardDto(
|
||||
id: 1,
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
),
|
||||
],
|
||||
price: '99₽',
|
||||
);
|
||||
|
||||
when(() => mockHttpRepository.getPack(packId))
|
||||
.thenAnswer((_) async => buyDto);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/pack/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return PackDetailsPage(packId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/purchase/:packId',
|
||||
builder: (context, state) {
|
||||
final packId = state.pathParameters['packId']!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Purchase $packId')),
|
||||
body: Center(child: Text('Purchase Page for $packId')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
scope: mockAppScope,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pack to load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Buy Pack button
|
||||
await tester.tap(find.text('Buy Pack'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify navigation to PurchasePage
|
||||
expect(find.text('Purchase Page for $packId'), findsOneWidget);
|
||||
expect(find.text('Purchase $packId'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should show loading indicator when navigating to purchase page',
|
||||
(tester) async {
|
||||
const packId = 'test-pack-3';
|
||||
final buyDto = CardPackBuyDto(
|
||||
id: packId,
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
cards: [
|
||||
GameCardDto(
|
||||
id: 1,
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
),
|
||||
],
|
||||
price: '99₽',
|
||||
);
|
||||
|
||||
when(() => mockHttpRepository.getPack(packId))
|
||||
.thenAnswer((_) async => buyDto);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/pack/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return PackDetailsPage(packId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/purchase/:packId',
|
||||
builder: (context, state) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text('Purchase Page')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
scope: mockAppScope,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pack to load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Buy Pack button
|
||||
await tester.tap(find.text('Buy Pack'));
|
||||
|
||||
// Pump once to trigger navigation state
|
||||
await tester.pump();
|
||||
|
||||
// Verify loading indicator appears (CircularProgressIndicator)
|
||||
expect(find.byType(CircularProgressIndicator), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should not display Buy Pack button when pack is already purchased',
|
||||
(tester) async {
|
||||
const packId = 'test-pack-4';
|
||||
final packDto = CardPackDto(
|
||||
id: packId,
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
cards: [
|
||||
GameCardDto(
|
||||
id: 1,
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
when(() => mockHttpRepository.getPack(packId))
|
||||
.thenAnswer((_) async => packDto);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/pack/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return PackDetailsPage(packId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
scope: mockAppScope,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pack to load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify Buy Pack button is NOT displayed
|
||||
expect(find.text('Buy Pack'), findsNothing);
|
||||
expect(find.byIcon(Icons.shopping_cart), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'should style Buy Pack button with pack color',
|
||||
(tester) async {
|
||||
const packId = 'test-pack-5';
|
||||
final buyDto = CardPackBuyDto(
|
||||
id: packId,
|
||||
title: 'Test Pack',
|
||||
subtitle: 'Test Subtitle',
|
||||
color: '#FF5733', // Red color
|
||||
cards: [
|
||||
GameCardDto(
|
||||
id: 1,
|
||||
original: 'test',
|
||||
translation: 'тест',
|
||||
),
|
||||
],
|
||||
price: '99₽',
|
||||
);
|
||||
|
||||
when(() => mockHttpRepository.getPack(packId))
|
||||
.thenAnswer((_) async => buyDto);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/pack/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return PackDetailsPage(packId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScopeProvider<AppScopeContainer>(
|
||||
scope: mockAppScope,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pack to load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find the FilledButton
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.byType(FilledButton),
|
||||
);
|
||||
|
||||
// Verify button styling
|
||||
expect(button.style, isNotNull);
|
||||
expect(button.style?.backgroundColor, isNotNull);
|
||||
expect(button.style?.minimumSize, const Size.fromHeight(56));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue