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",
|
"component": "web_v2",
|
||||||
"current_task_id": "WEB-001",
|
"current_task_id": "WEB-003",
|
||||||
"iteration_count": 4,
|
"iteration_count": 5,
|
||||||
"max_iterations": 10,
|
"max_iterations": 10,
|
||||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||||
"last_commit": null,
|
"last_commit": null,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
"id": "WEB-003",
|
"id": "WEB-003",
|
||||||
"title": "Create SubscriptionPlansWidget",
|
"title": "Create SubscriptionPlansWidget",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"estimated_hours": 2.5,
|
"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.",
|
"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": [
|
"acceptance_criteria": [
|
||||||
|
|
@ -374,4 +374,4 @@
|
||||||
"component": "web_v2"
|
"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