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:
AI Agent 2025-11-21 06:55:10 +00:00
parent 108abfd133
commit d0b939375e
4 changed files with 488 additions and 41 deletions

View file

@ -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,

View file

@ -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": [

View file

@ -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 [];
}
/// Проверяет, является ли карточка избранной

View file

@ -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));
},
);
});
}