feat(web_v2): Create SubscriptionPlansWidget
Task ID: WEB-003 Priority: high Changes: Completed by: AI Agent Duration: 3429369ms
This commit is contained in:
parent
8802344197
commit
9dcba6d9a9
5 changed files with 908 additions and 4 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"component": "web_v2",
|
||||
"current_task_id": "WEB-001",
|
||||
"iteration_count": 4,
|
||||
"current_task_id": "WEB-003",
|
||||
"iteration_count": 5,
|
||||
"max_iterations": 10,
|
||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||
"last_commit": null,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
"id": "WEB-003",
|
||||
"title": "Create SubscriptionPlansWidget",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"estimated_hours": 2.5,
|
||||
"description": "Create reusable SubscriptionPlansWidget that displays available subscription plans in card layout. Widget should fetch plans from SubscriptionService, show features comparison, highlight current plan, and emit selection events. Backend endpoint GET /api/v2/subscriptions/plans is ready.",
|
||||
"acceptance_criteria": [
|
||||
|
|
@ -374,4 +374,4 @@
|
|||
"component": "web_v2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:mnemo_cards_web_v2/domain/models/subscription_models.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/subscription_service.dart';
|
||||
import 'package:yx_state/yx_state.dart';
|
||||
|
||||
/// State for subscription plans page.
|
||||
class SubscriptionPlansState {
|
||||
const SubscriptionPlansState({
|
||||
required this.isLoading,
|
||||
required this.data,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
const SubscriptionPlansState.initial()
|
||||
: isLoading = false,
|
||||
data = null,
|
||||
error = null;
|
||||
|
||||
const SubscriptionPlansState.loading()
|
||||
: isLoading = true,
|
||||
data = null,
|
||||
error = null;
|
||||
|
||||
const SubscriptionPlansState.loaded(SubscriptionPageData this.data)
|
||||
: isLoading = false,
|
||||
error = null;
|
||||
|
||||
const SubscriptionPlansState.error(String this.error, [SubscriptionPageData? this.data])
|
||||
: isLoading = false;
|
||||
|
||||
final bool isLoading;
|
||||
final SubscriptionPageData? data;
|
||||
final String? error;
|
||||
|
||||
bool get isInitial => !isLoading && data == null && error == null;
|
||||
bool get isLoaded => !isLoading && data != null && error == null;
|
||||
bool get hasError => error != null;
|
||||
}
|
||||
|
||||
/// State manager for subscription plans.
|
||||
class SubscriptionPlansStateManager extends StateManager<SubscriptionPlansState> {
|
||||
SubscriptionPlansStateManager({
|
||||
required SubscriptionService subscriptionService,
|
||||
}) : _subscriptionService = subscriptionService,
|
||||
super(const SubscriptionPlansState.initial());
|
||||
|
||||
final SubscriptionService _subscriptionService;
|
||||
|
||||
/// Load subscription plans page data.
|
||||
Future<void> loadPlans() => handle((emit) async {
|
||||
log('Loading subscription plans', name: 'SubscriptionPlansStateManager');
|
||||
emit(const SubscriptionPlansState.loading());
|
||||
|
||||
try {
|
||||
final data = await _subscriptionService.getSubscriptionPage();
|
||||
log(
|
||||
'Subscription plans loaded: ${data.plans.length} plans',
|
||||
name: 'SubscriptionPlansStateManager',
|
||||
);
|
||||
emit(SubscriptionPlansState.loaded(data));
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'Failed to load subscription plans',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
name: 'SubscriptionPlansStateManager',
|
||||
);
|
||||
final previousData = state.data;
|
||||
emit(SubscriptionPlansState.error(
|
||||
'Не удалось загрузить планы подписки: $e',
|
||||
previousData,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
/// Retry loading plans.
|
||||
Future<void> retry() => loadPlans();
|
||||
}
|
||||
|
|
@ -0,0 +1,556 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope.dart';
|
||||
import 'package:mnemo_cards_web_v2/di/user_scope/user_scope_container.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/models/subscription_models.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/subscription_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/state/subscription_plans_state_manager.dart';
|
||||
import 'package:mnemo_cards_web_v2/presentation/widgets/error_view.dart';
|
||||
import 'package:mnemo_cards_web_v2/presentation/widgets/loading_view.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||
import 'package:yx_state_flutter/yx_state_flutter.dart';
|
||||
|
||||
/// Widget that displays available subscription plans in a card layout.
|
||||
///
|
||||
/// Features:
|
||||
/// - Fetches plans from SubscriptionService
|
||||
/// - Shows features comparison
|
||||
/// - Highlights current plan
|
||||
/// - Emits selection events via onPlanSelected callback
|
||||
class SubscriptionPlansWidget extends StatefulWidget {
|
||||
const SubscriptionPlansWidget({
|
||||
this.onPlanSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Callback fired when a plan is selected
|
||||
final void Function(WebSubscriptionPlanDto plan)? onPlanSelected;
|
||||
|
||||
@override
|
||||
State<SubscriptionPlansWidget> createState() => _SubscriptionPlansWidgetState();
|
||||
}
|
||||
|
||||
class _SubscriptionPlansWidgetState extends State<SubscriptionPlansWidget> {
|
||||
SubscriptionPlansStateManager? _stateManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeStateManager();
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeStateManager() {
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userScope = appScope?.userScopeHolder.scope;
|
||||
if (userScope == null) return;
|
||||
|
||||
// Get httpRepository from UserScopeContainer
|
||||
// It's provided from parent scope but not exposed in UserScope interface
|
||||
HttpRepositoryV2? httpRepository;
|
||||
if (userScope is UserScopeContainer) {
|
||||
httpRepository = userScope.httpRepository;
|
||||
}
|
||||
if (httpRepository == null) return;
|
||||
|
||||
final subscriptionService = SubscriptionService(
|
||||
httpRepository: httpRepository,
|
||||
);
|
||||
|
||||
_stateManager = SubscriptionPlansStateManager(
|
||||
subscriptionService: subscriptionService,
|
||||
);
|
||||
|
||||
// Load plans on initialization
|
||||
_stateManager!.loadPlans();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_stateManager == null) {
|
||||
return const LoadingView(message: 'Initializing...');
|
||||
}
|
||||
|
||||
return StateBuilder(
|
||||
stateReadable: _stateManager!,
|
||||
builder: (context, state, _) {
|
||||
if (state.isLoading && state.data == null) {
|
||||
return const _SubscriptionPlansLoadingView();
|
||||
}
|
||||
|
||||
if (state.hasError && state.data == null) {
|
||||
return ErrorView(
|
||||
title: 'Ошибка загрузки',
|
||||
message: state.error ?? 'Неизвестная ошибка',
|
||||
onRetry: _stateManager?.retry,
|
||||
);
|
||||
}
|
||||
|
||||
final data = state.data;
|
||||
if (data == null) {
|
||||
return const ErrorView(
|
||||
title: 'Нет данных',
|
||||
message: 'Планы подписки недоступны',
|
||||
);
|
||||
}
|
||||
|
||||
return _SubscriptionPlansGridView(
|
||||
plans: data.plans,
|
||||
currentPlan: data.currentPlan,
|
||||
onPlanSelected: widget.onPlanSelected,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateManager = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view displaying subscription plans
|
||||
class _SubscriptionPlansGridView extends StatelessWidget {
|
||||
const _SubscriptionPlansGridView({
|
||||
required this.plans,
|
||||
this.currentPlan,
|
||||
this.onPlanSelected,
|
||||
});
|
||||
|
||||
final List<WebSubscriptionPlanDto> plans;
|
||||
final WebSubscriptionPlanDto? currentPlan;
|
||||
final void Function(WebSubscriptionPlanDto plan)? onPlanSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (plans.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Нет доступных планов подписки'),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive grid: 1 column on mobile, 2-3 on tablet, 3-4 on desktop
|
||||
final crossAxisCount = _getCrossAxisCount(constraints.maxWidth);
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.75, // Cards are taller than wide
|
||||
),
|
||||
itemCount: plans.length,
|
||||
itemBuilder: (context, index) {
|
||||
final plan = plans[index];
|
||||
final isCurrentPlan = currentPlan?.id == plan.id;
|
||||
|
||||
return _SubscriptionPlanCard(
|
||||
plan: plan,
|
||||
isCurrentPlan: isCurrentPlan,
|
||||
onSelected: () => onPlanSelected?.call(plan),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _getCrossAxisCount(double width) {
|
||||
if (width < 600) {
|
||||
return 1; // Mobile
|
||||
} else if (width < 1200) {
|
||||
return 2; // Tablet
|
||||
} else {
|
||||
return 3; // Desktop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual subscription plan card
|
||||
class _SubscriptionPlanCard extends StatelessWidget {
|
||||
const _SubscriptionPlanCard({
|
||||
required this.plan,
|
||||
required this.isCurrentPlan,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final WebSubscriptionPlanDto plan;
|
||||
final bool isCurrentPlan;
|
||||
final VoidCallback? onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: isCurrentPlan ? 8 : 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isCurrentPlan
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withOpacity(0.2),
|
||||
width: isCurrentPlan ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onSelected,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with badge
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
plan.name,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCurrentPlan)
|
||||
_CurrentPlanBadge()
|
||||
else if (plan.isPopular)
|
||||
_PopularBadge(),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Description
|
||||
if (plan.description.isNotEmpty) ...[
|
||||
Text(
|
||||
plan.description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Price
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_formatPrice(plan.price, plan.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'/ ${_formatPeriod(plan.period)}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Features list
|
||||
if (plan.features.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
...plan.features.take(5).map((feature) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
if (plan.features.length > 5)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'+${plan.features.length - 5} ещё',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// CTA Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: isCurrentPlan ? null : onSelected,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isCurrentPlan ? 'Текущий план' : 'Выбрать план',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPrice(double price, String currency) {
|
||||
// Format price based on currency
|
||||
if (currency == 'RUB' || currency == '₽') {
|
||||
return '${price.toStringAsFixed(0)} ₽';
|
||||
} else if (currency == 'USD' || currency == '\$') {
|
||||
return '\$${price.toStringAsFixed(2)}';
|
||||
} else if (currency == 'EUR' || currency == '€') {
|
||||
return '€${price.toStringAsFixed(2)}';
|
||||
}
|
||||
return '${price.toStringAsFixed(2)} $currency';
|
||||
}
|
||||
|
||||
String _formatPeriod(String period) {
|
||||
final periodLower = period.toLowerCase();
|
||||
if (periodLower.contains('month')) {
|
||||
return 'мес';
|
||||
} else if (periodLower.contains('year')) {
|
||||
return 'год';
|
||||
} else if (periodLower.contains('week')) {
|
||||
return 'нед';
|
||||
} else if (periodLower.contains('day')) {
|
||||
return 'день';
|
||||
}
|
||||
return period;
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge indicating current plan
|
||||
class _CurrentPlanBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Текущий',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge indicating popular plan
|
||||
class _PopularBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Популярный',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading view with skeleton loaders for subscription plans
|
||||
class _SubscriptionPlansLoadingView extends StatelessWidget {
|
||||
const _SubscriptionPlansLoadingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = _getCrossAxisCount(constraints.maxWidth);
|
||||
final itemCount = crossAxisCount * 2; // Show 2 rows of skeletons
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => const _SubscriptionPlanCardShimmer(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _getCrossAxisCount(double width) {
|
||||
if (width < 600) {
|
||||
return 1;
|
||||
} else if (width < 1200) {
|
||||
return 2;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shimmer loading placeholder for subscription plan card
|
||||
class _SubscriptionPlanCardShimmer extends StatelessWidget {
|
||||
const _SubscriptionPlanCardShimmer();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isDark ? Colors.grey[700]! : Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: isDark ? Colors.grey[800]! : Colors.grey[300]!,
|
||||
highlightColor: isDark ? Colors.grey[700]! : Colors.grey[100]!,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title placeholder
|
||||
Container(
|
||||
height: 24,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Description placeholder
|
||||
Container(
|
||||
height: 16,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 16,
|
||||
width: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Price placeholder
|
||||
Container(
|
||||
height: 32,
|
||||
width: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// Features placeholders
|
||||
...List.generate(4, (index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
// Button placeholder
|
||||
Container(
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/models/subscription_models.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/services/subscription_service.dart';
|
||||
import 'package:mnemo_cards_web_v2/domain/state/subscription_plans_state_manager.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class _MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
|
||||
|
||||
class _MockSubscriptionService extends Mock implements SubscriptionService {}
|
||||
|
||||
/// Note: Full integration tests for SubscriptionPlansWidget require web platform
|
||||
/// due to scope dependencies. These tests focus on unit-testable components.
|
||||
void main() {
|
||||
late _MockHttpRepositoryV2 mockHttpRepository;
|
||||
late _MockSubscriptionService mockSubscriptionService;
|
||||
late SubscriptionPlansStateManager stateManager;
|
||||
|
||||
setUp(() {
|
||||
mockHttpRepository = _MockHttpRepositoryV2();
|
||||
mockSubscriptionService = _MockSubscriptionService();
|
||||
stateManager = SubscriptionPlansStateManager(
|
||||
subscriptionService: mockSubscriptionService,
|
||||
);
|
||||
});
|
||||
|
||||
group('SubscriptionPlansWidget', () {
|
||||
final samplePlans = [
|
||||
const WebSubscriptionPlanDto(
|
||||
id: 'plan1',
|
||||
name: 'Basic Plan',
|
||||
description: 'Basic features for beginners',
|
||||
price: 99.0,
|
||||
currency: 'RUB',
|
||||
period: 'monthly',
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
isPopular: false,
|
||||
),
|
||||
const WebSubscriptionPlanDto(
|
||||
id: 'plan2',
|
||||
name: 'Premium Plan',
|
||||
description: 'Advanced features for professionals',
|
||||
price: 299.0,
|
||||
currency: 'RUB',
|
||||
period: 'monthly',
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3', 'Feature 4', 'Feature 5'],
|
||||
isPopular: true,
|
||||
),
|
||||
const WebSubscriptionPlanDto(
|
||||
id: 'plan3',
|
||||
name: 'Enterprise Plan',
|
||||
description: 'All features for teams',
|
||||
price: 599.0,
|
||||
currency: 'RUB',
|
||||
period: 'monthly',
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3', 'Feature 4', 'Feature 5', 'Feature 6'],
|
||||
isPopular: false,
|
||||
),
|
||||
];
|
||||
|
||||
final sampleData = SubscriptionPageData(
|
||||
plans: samplePlans,
|
||||
hasActiveSubscription: true,
|
||||
currentPlan: samplePlans[1], // Premium is current
|
||||
);
|
||||
|
||||
// Note: Widget tests require web platform due to scope dependencies
|
||||
// Testing state manager directly instead
|
||||
test('state manager starts in initial state', () {
|
||||
expect(stateManager.state.isInitial, isTrue);
|
||||
});
|
||||
|
||||
// Test state manager loading functionality
|
||||
test('state manager loads plans successfully', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
expect(stateManager.state.isLoaded, isTrue);
|
||||
expect(stateManager.state.data, isNotNull);
|
||||
expect(stateManager.state.data!.plans.length, equals(3));
|
||||
expect(stateManager.state.data!.currentPlan?.id, equals('plan2'));
|
||||
});
|
||||
|
||||
test('state manager identifies current plan correctly', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
expect(stateManager.state.data?.currentPlan?.id, equals('plan2'));
|
||||
expect(stateManager.state.data?.hasActiveSubscription, isTrue);
|
||||
});
|
||||
|
||||
test('identifies popular plans correctly', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
final popularPlans = stateManager.state.data?.plans
|
||||
.where((p) => p.isPopular)
|
||||
.toList();
|
||||
expect(popularPlans?.length, equals(1));
|
||||
expect(popularPlans?.first.id, equals('plan2'));
|
||||
});
|
||||
|
||||
test('plans contain correct features', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
final plans = stateManager.state.data?.plans ?? [];
|
||||
expect(plans[0].features.length, equals(3));
|
||||
expect(plans[1].features.length, equals(5));
|
||||
expect(plans[2].features.length, equals(6));
|
||||
});
|
||||
|
||||
test('plans with more than 5 features are handled correctly', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
final enterprisePlan = stateManager.state.data?.plans
|
||||
.firstWhere((p) => p.id == 'plan3');
|
||||
expect(enterprisePlan?.features.length, greaterThan(5));
|
||||
});
|
||||
|
||||
test('plan selection data is available', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
final plans = stateManager.state.data?.plans ?? [];
|
||||
expect(plans.isNotEmpty, isTrue);
|
||||
// All plans should be selectable (except current one in UI)
|
||||
expect(plans.length, greaterThan(0));
|
||||
});
|
||||
|
||||
test('state manager handles errors correctly', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenThrow(Exception('Network error'));
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
expect(stateManager.state.hasError, isTrue);
|
||||
expect(stateManager.state.error, isNotNull);
|
||||
expect(stateManager.state.error, contains('Не удалось загрузить планы подписки'));
|
||||
});
|
||||
|
||||
test('retry reloads plans after error', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenThrow(Exception('Network error'));
|
||||
|
||||
// First attempt fails
|
||||
await stateManager.loadPlans();
|
||||
expect(stateManager.state.hasError, isTrue);
|
||||
|
||||
// Setup success for retry
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
// Retry succeeds
|
||||
await stateManager.retry();
|
||||
expect(stateManager.state.isLoaded, isTrue);
|
||||
expect(stateManager.state.data, isNotNull);
|
||||
|
||||
verify(() => mockSubscriptionService.getSubscriptionPage()).called(2);
|
||||
});
|
||||
|
||||
test('handles empty plans list correctly', () async {
|
||||
final emptyData = const SubscriptionPageData(
|
||||
plans: [],
|
||||
hasActiveSubscription: false,
|
||||
currentPlan: null,
|
||||
);
|
||||
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => emptyData);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
expect(stateManager.state.isLoaded, isTrue);
|
||||
expect(stateManager.state.data?.plans.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('state manager shows loading state', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage()).thenAnswer(
|
||||
(_) async {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
return sampleData;
|
||||
},
|
||||
);
|
||||
|
||||
// Start loading
|
||||
final future = stateManager.loadPlans();
|
||||
|
||||
// Check loading state
|
||||
expect(stateManager.state.isLoading, isTrue);
|
||||
expect(stateManager.state.data, isNull);
|
||||
|
||||
// Wait for completion
|
||||
await future;
|
||||
expect(stateManager.state.isLoaded, isTrue);
|
||||
});
|
||||
|
||||
test('plans support different currencies', () async {
|
||||
final usdPlan = const WebSubscriptionPlanDto(
|
||||
id: 'usd',
|
||||
name: 'USD Plan',
|
||||
description: '',
|
||||
price: 9.99,
|
||||
currency: 'USD',
|
||||
period: 'monthly',
|
||||
features: [],
|
||||
isPopular: false,
|
||||
);
|
||||
|
||||
final eurPlan = const WebSubscriptionPlanDto(
|
||||
id: 'eur',
|
||||
name: 'EUR Plan',
|
||||
description: '',
|
||||
price: 8.50,
|
||||
currency: 'EUR',
|
||||
period: 'monthly',
|
||||
features: [],
|
||||
isPopular: false,
|
||||
);
|
||||
|
||||
final data = SubscriptionPageData(
|
||||
plans: [usdPlan, eurPlan],
|
||||
hasActiveSubscription: false,
|
||||
currentPlan: null,
|
||||
);
|
||||
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => data);
|
||||
|
||||
await stateManager.loadPlans();
|
||||
|
||||
final plans = stateManager.state.data?.plans ?? [];
|
||||
expect(plans.any((p) => p.currency == 'USD'), isTrue);
|
||||
expect(plans.any((p) => p.currency == 'EUR'), isTrue);
|
||||
});
|
||||
|
||||
test('state manager preserves data on error retry', () async {
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenAnswer((_) async => sampleData);
|
||||
|
||||
// First load succeeds
|
||||
await stateManager.loadPlans();
|
||||
expect(stateManager.state.isLoaded, isTrue);
|
||||
final firstData = stateManager.state.data;
|
||||
|
||||
// Setup error for second load
|
||||
when(() => mockSubscriptionService.getSubscriptionPage())
|
||||
.thenThrow(Exception('Network error'));
|
||||
|
||||
// Second load fails but preserves previous data
|
||||
await stateManager.loadPlans();
|
||||
expect(stateManager.state.hasError, isTrue);
|
||||
expect(stateManager.state.data, equals(firstData));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue