feat(web_v2): Create SubscriptionPlansWidget

Task ID: WEB-003
Priority: high

Changes:

Completed by: AI Agent
Duration: 3429369ms
This commit is contained in:
AI Agent 2025-11-21 13:02:06 +00:00
parent 8802344197
commit 9dcba6d9a9
5 changed files with 908 additions and 4 deletions

View file

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

View file

@ -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"
}
]
}
}

View file

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

View file

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

View file

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