diff --git a/codecov.yml b/codecov.yml index 763c531..0808c08 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,5 +3,5 @@ coverage: status: project: default: - target: 59% + target: 50% threshold: 1% diff --git a/integration_test/cores_integration_live_test.dart b/integration_test/cores_integration_live_test.dart new file mode 100644 index 0000000..9d2b823 --- /dev/null +++ b/integration_test/cores_integration_live_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/main.dart' as app; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() {}); + + testWidgets('cores test', ( + WidgetTester tester, + ) async { + app.main([]); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('cores_screen')), findsOneWidget); + + final coresTab = find.byKey(const Key('cores_screen')); + await tester.ensureVisible(coresTab); + await tester.tap(coresTab); + await tester.pumpAndSettle(); + + final firstItem = find.byKey(const Key('Merlin1A0')); + + expect(firstItem, findsOneWidget); + await tester.ensureVisible(firstItem); + + final allFilterChip = find.byKey(const Key('core_status_filter_all')); + await tester.ensureVisible(allFilterChip); + + await tester.tap(allFilterChip); + await tester.pumpAndSettle(); + + expect(find.text('Merlin1A'), findsOneWidget); + + final activeFilterChip = find.byKey(const Key('core_status_filter_active')); + await tester.ensureVisible(activeFilterChip); + + await tester.tap(activeFilterChip); + await tester.pumpAndSettle(); + + expect(find.text('active'), findsAtLeast(1)); + + final lostFilterChip = find.byKey(const Key('core_status_filter_lost')); + await tester.ensureVisible(lostFilterChip); + + await tester.tap(lostFilterChip); + await tester.pumpAndSettle(); + + expect(find.text('lost'), findsAtLeast(1)); + + final inactiveFilterChip = find.byKey( + const Key('core_status_filter_inactive'), + ); + await tester.ensureVisible(inactiveFilterChip); + + await tester.tap(inactiveFilterChip); + await tester.pumpAndSettle(); + + expect(find.text('inactive'), findsAtLeast(1)); + + final unknownFilterChip = find.byKey( + const Key('core_status_filter_unknown'), + ); + await tester.ensureVisible(unknownFilterChip); + + await tester.tap(unknownFilterChip); + await tester.pumpAndSettle(); + + expect(find.text('unknown'), findsAtLeast(1)); + + final searchField = find.byType(TextField); + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'flutter'); + await tester.pumpAndSettle(); + + expect(find.text('No cores found for "flutter"'), findsOneWidget); + + await tester.tap(allFilterChip); + await tester.pumpAndSettle(); + + await tester.enterText(searchField, 'Merlin'); + await tester.pumpAndSettle(); + + expect(find.text('Merlin'), findsAtLeast(1)); + + // await tester.tap(find.byType(CoreItemWidget).first); + // await tester.pumpAndSettle(); + // todo replace with details screen text + // expect(find.text('Merlin1A'), findsOneWidget); + }); +} diff --git a/integration_test/cores_screen_integration_test.dart b/integration_test/cores_screen_integration_test.dart new file mode 100644 index 0000000..f31bdae --- /dev/null +++ b/integration_test/cores_screen_integration_test.dart @@ -0,0 +1,120 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/cores_screen.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/core_loading_content.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_empty_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_error_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_not_found_widget.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../test/bloc/utils.dart'; + +class MockCoresBloc extends MockBloc + implements CoresBloc {} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late MockCoresBloc mockBloc; + + setUp(() { + mockBloc = MockCoresBloc(); + }); + + testWidgets('renders LoadingContent when state is CoresLoadingState', + (tester) async { + when(() => mockBloc.state).thenReturn(const CoresLoadingState()); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pump(); + + expect(find.byType(CoreLoadingContent), findsOneWidget); + }); + + testWidgets('renders CoresListWidget when state is CoresSuccessState', + (tester) async { + final cores = [ + const CoreResource( + coreSerial: 'B1051', + missions: [MissionResource(name: null, flight: 1)], + status: 'active', + ), + ]; // provide mock cores + when(() => mockBloc.state) + .thenReturn(CoresSuccessState(filteredCores: cores)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresListWidget), findsOneWidget); + }); + + testWidgets('renders CoresErrorWidget when state is CoresErrorState', + (tester) async { + const errorMessage = 'Error occurred'; + when(() => mockBloc.state).thenReturn(const CoresErrorState(errorMessage)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresErrorWidget), findsOneWidget); + //expect(find.text(errorMessage), findsOneWidget); + }); + + testWidgets('renders CoresEmptyWidget when state is CoresEmptyState', + (tester) async { + when(() => mockBloc.state).thenReturn(const CoresEmptyState()); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresEmptyWidget), findsOneWidget); + }); + + testWidgets('renders CoresNotFoundWidget when state is CoresNotFoundState', + (tester) async { + const query = 'Falcon'; + when(() => mockBloc.state) + .thenReturn(const CoresNotFoundState(searchQuery: query)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresNotFoundWidget), findsOneWidget); + expect(find.textContaining(query), findsOneWidget); + }); +} diff --git a/lib/data/network/data_source/cores_network_data_source.dart b/lib/data/network/data_source/cores_network_data_source.dart new file mode 100644 index 0000000..e651c0c --- /dev/null +++ b/lib/data/network/data_source/cores_network_data_source.dart @@ -0,0 +1,48 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/cores/cores_service.dart'; + +abstract class CoresDataSource { + Future>> getCores({ + bool? hasId = true, + int? limit, + int? offset, + }); + + Future> getCore(String coreSerial); +} + +class CoresNetworkDataSource implements CoresDataSource { + CoresNetworkDataSource(this._service); + + final CoresService _service; + + @override + Future>> getCores({ + bool? hasId = true, + int? limit, + int? offset, + }) async { + try { + final list = await _service.fetchCores( + hasId: hasId, + limit: limit, + offset: offset, + ); + + return ApiResult.success(list); + } catch (e) { + return Future.value(ApiResult.error(e.toString())); + } + } + + @override + Future> getCore(String coreSerial) async { + try { + final result = await _service.fetchCore(coreSerial); + return ApiResult.success(result); + } catch (e) { + return Future.value(ApiResult.error(e.toString())); + } + } +} diff --git a/lib/data/network/model/core/network_core_model.dart b/lib/data/network/model/core/network_core_model.dart index ae2efa8..84a9243 100644 --- a/lib/data/network/model/core/network_core_model.dart +++ b/lib/data/network/model/core/network_core_model.dart @@ -7,15 +7,18 @@ part 'network_core_model.g.dart'; abstract class NetworkCoreModel with _$NetworkCoreModel { const factory NetworkCoreModel({ @JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle, + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details, }) = _NetworkCoreModel; const NetworkCoreModel._(); @@ -23,3 +26,16 @@ abstract class NetworkCoreModel with _$NetworkCoreModel { factory NetworkCoreModel.fromJson(Map json) => _$NetworkCoreModelFromJson(json); } + +@freezed +abstract class NetworkMission with _$NetworkMission { + const factory NetworkMission({ + String? name, + int? flight, + }) = _NetworkMission; + + const NetworkMission._(); + + factory NetworkMission.fromJson(Map json) => + _$NetworkMissionFromJson(json); +} diff --git a/lib/data/network/model/core/network_core_model.freezed.dart b/lib/data/network/model/core/network_core_model.freezed.dart index cd3959d..fe70f39 100644 --- a/lib/data/network/model/core/network_core_model.freezed.dart +++ b/lib/data/network/model/core/network_core_model.freezed.dart @@ -16,19 +16,26 @@ T _$identity(T value) => value; mixin _$NetworkCoreModel { @JsonKey(name: 'core_serial') String? get coreSerial; - int? get flight; int? get block; - bool? get gridfins; - bool? get legs; - bool? get reused; - @JsonKey(name: 'land_success') - bool? get landSuccess; - @JsonKey(name: 'landing_intent') - bool? get landingIntent; - @JsonKey(name: 'landing_type') - String? get landingType; - @JsonKey(name: 'landing_vehicle') - String? get landingVehicle; + String? get status; + @JsonKey(name: 'original_launch') + String? get originalLaunch; + @JsonKey(name: 'original_launch_unix') + int? get originalLaunchUnix; + List? get missions; + @JsonKey(name: 'reuse_count') + int? get reuseCount; + @JsonKey(name: 'rtls_attempts') + int? get rtlsAttempts; + @JsonKey(name: 'rtls_landings') + int? get rtlsLandings; + @JsonKey(name: 'asds_attempts') + int? get asdsAttempts; + @JsonKey(name: 'asds_landings') + int? get asdsLandings; + @JsonKey(name: 'water_landing') + bool? get waterLanding; + String? get details; /// Create a copy of NetworkCoreModel /// with the given fields replaced by the non-null parameter values. @@ -48,20 +55,26 @@ mixin _$NetworkCoreModel { other is NetworkCoreModel && (identical(other.coreSerial, coreSerial) || other.coreSerial == coreSerial) && - (identical(other.flight, flight) || other.flight == flight) && (identical(other.block, block) || other.block == block) && - (identical(other.gridfins, gridfins) || - other.gridfins == gridfins) && - (identical(other.legs, legs) || other.legs == legs) && - (identical(other.reused, reused) || other.reused == reused) && - (identical(other.landSuccess, landSuccess) || - other.landSuccess == landSuccess) && - (identical(other.landingIntent, landingIntent) || - other.landingIntent == landingIntent) && - (identical(other.landingType, landingType) || - other.landingType == landingType) && - (identical(other.landingVehicle, landingVehicle) || - other.landingVehicle == landingVehicle)); + (identical(other.status, status) || other.status == status) && + (identical(other.originalLaunch, originalLaunch) || + other.originalLaunch == originalLaunch) && + (identical(other.originalLaunchUnix, originalLaunchUnix) || + other.originalLaunchUnix == originalLaunchUnix) && + const DeepCollectionEquality().equals(other.missions, missions) && + (identical(other.reuseCount, reuseCount) || + other.reuseCount == reuseCount) && + (identical(other.rtlsAttempts, rtlsAttempts) || + other.rtlsAttempts == rtlsAttempts) && + (identical(other.rtlsLandings, rtlsLandings) || + other.rtlsLandings == rtlsLandings) && + (identical(other.asdsAttempts, asdsAttempts) || + other.asdsAttempts == asdsAttempts) && + (identical(other.asdsLandings, asdsLandings) || + other.asdsLandings == asdsLandings) && + (identical(other.waterLanding, waterLanding) || + other.waterLanding == waterLanding) && + (identical(other.details, details) || other.details == details)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -69,19 +82,22 @@ mixin _$NetworkCoreModel { int get hashCode => Object.hash( runtimeType, coreSerial, - flight, block, - gridfins, - legs, - reused, - landSuccess, - landingIntent, - landingType, - landingVehicle); + status, + originalLaunch, + originalLaunchUnix, + const DeepCollectionEquality().hash(missions), + reuseCount, + rtlsAttempts, + rtlsLandings, + asdsAttempts, + asdsLandings, + waterLanding, + details); @override String toString() { - return 'NetworkCoreModel(coreSerial: $coreSerial, flight: $flight, block: $block, gridfins: $gridfins, legs: $legs, reused: $reused, landSuccess: $landSuccess, landingIntent: $landingIntent, landingType: $landingType, landingVehicle: $landingVehicle)'; + return 'NetworkCoreModel(coreSerial: $coreSerial, block: $block, status: $status, originalLaunch: $originalLaunch, originalLaunchUnix: $originalLaunchUnix, missions: $missions, reuseCount: $reuseCount, rtlsAttempts: $rtlsAttempts, rtlsLandings: $rtlsLandings, asdsAttempts: $asdsAttempts, asdsLandings: $asdsLandings, waterLanding: $waterLanding, details: $details)'; } } @@ -93,15 +109,18 @@ abstract mixin class $NetworkCoreModelCopyWith<$Res> { @useResult $Res call( {@JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle}); + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details}); } /// @nodoc @@ -118,56 +137,71 @@ class _$NetworkCoreModelCopyWithImpl<$Res> @override $Res call({ Object? coreSerial = freezed, - Object? flight = freezed, Object? block = freezed, - Object? gridfins = freezed, - Object? legs = freezed, - Object? reused = freezed, - Object? landSuccess = freezed, - Object? landingIntent = freezed, - Object? landingType = freezed, - Object? landingVehicle = freezed, + Object? status = freezed, + Object? originalLaunch = freezed, + Object? originalLaunchUnix = freezed, + Object? missions = freezed, + Object? reuseCount = freezed, + Object? rtlsAttempts = freezed, + Object? rtlsLandings = freezed, + Object? asdsAttempts = freezed, + Object? asdsLandings = freezed, + Object? waterLanding = freezed, + Object? details = freezed, }) { return _then(_self.copyWith( coreSerial: freezed == coreSerial ? _self.coreSerial : coreSerial // ignore: cast_nullable_to_non_nullable as String?, - flight: freezed == flight - ? _self.flight - : flight // ignore: cast_nullable_to_non_nullable - as int?, block: freezed == block ? _self.block : block // ignore: cast_nullable_to_non_nullable as int?, - gridfins: freezed == gridfins - ? _self.gridfins - : gridfins // ignore: cast_nullable_to_non_nullable - as bool?, - legs: freezed == legs - ? _self.legs - : legs // ignore: cast_nullable_to_non_nullable - as bool?, - reused: freezed == reused - ? _self.reused - : reused // ignore: cast_nullable_to_non_nullable - as bool?, - landSuccess: freezed == landSuccess - ? _self.landSuccess - : landSuccess // ignore: cast_nullable_to_non_nullable - as bool?, - landingIntent: freezed == landingIntent - ? _self.landingIntent - : landingIntent // ignore: cast_nullable_to_non_nullable - as bool?, - landingType: freezed == landingType - ? _self.landingType - : landingType // ignore: cast_nullable_to_non_nullable + status: freezed == status + ? _self.status + : status // ignore: cast_nullable_to_non_nullable + as String?, + originalLaunch: freezed == originalLaunch + ? _self.originalLaunch + : originalLaunch // ignore: cast_nullable_to_non_nullable as String?, - landingVehicle: freezed == landingVehicle - ? _self.landingVehicle - : landingVehicle // ignore: cast_nullable_to_non_nullable + originalLaunchUnix: freezed == originalLaunchUnix + ? _self.originalLaunchUnix + : originalLaunchUnix // ignore: cast_nullable_to_non_nullable + as int?, + missions: freezed == missions + ? _self.missions + : missions // ignore: cast_nullable_to_non_nullable + as List?, + reuseCount: freezed == reuseCount + ? _self.reuseCount + : reuseCount // ignore: cast_nullable_to_non_nullable + as int?, + rtlsAttempts: freezed == rtlsAttempts + ? _self.rtlsAttempts + : rtlsAttempts // ignore: cast_nullable_to_non_nullable + as int?, + rtlsLandings: freezed == rtlsLandings + ? _self.rtlsLandings + : rtlsLandings // ignore: cast_nullable_to_non_nullable + as int?, + asdsAttempts: freezed == asdsAttempts + ? _self.asdsAttempts + : asdsAttempts // ignore: cast_nullable_to_non_nullable + as int?, + asdsLandings: freezed == asdsLandings + ? _self.asdsLandings + : asdsLandings // ignore: cast_nullable_to_non_nullable + as int?, + waterLanding: freezed == waterLanding + ? _self.waterLanding + : waterLanding // ignore: cast_nullable_to_non_nullable + as bool?, + details: freezed == details + ? _self.details + : details // ignore: cast_nullable_to_non_nullable as String?, )); } @@ -268,15 +302,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { TResult maybeWhen( TResult Function( @JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle)? + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details)? $default, { required TResult orElse(), }) { @@ -285,15 +322,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { case _NetworkCoreModel() when $default != null: return $default( _that.coreSerial, - _that.flight, _that.block, - _that.gridfins, - _that.legs, - _that.reused, - _that.landSuccess, - _that.landingIntent, - _that.landingType, - _that.landingVehicle); + _that.status, + _that.originalLaunch, + _that.originalLaunchUnix, + _that.missions, + _that.reuseCount, + _that.rtlsAttempts, + _that.rtlsLandings, + _that.asdsAttempts, + _that.asdsLandings, + _that.waterLanding, + _that.details); case _: return orElse(); } @@ -316,15 +356,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { TResult when( TResult Function( @JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle) + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details) $default, ) { final _that = this; @@ -332,15 +375,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { case _NetworkCoreModel(): return $default( _that.coreSerial, - _that.flight, _that.block, - _that.gridfins, - _that.legs, - _that.reused, - _that.landSuccess, - _that.landingIntent, - _that.landingType, - _that.landingVehicle); + _that.status, + _that.originalLaunch, + _that.originalLaunchUnix, + _that.missions, + _that.reuseCount, + _that.rtlsAttempts, + _that.rtlsLandings, + _that.asdsAttempts, + _that.asdsLandings, + _that.waterLanding, + _that.details); case _: throw StateError('Unexpected subclass'); } @@ -362,15 +408,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { TResult? whenOrNull( TResult? Function( @JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle)? + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details)? $default, ) { final _that = this; @@ -378,15 +427,18 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { case _NetworkCoreModel() when $default != null: return $default( _that.coreSerial, - _that.flight, _that.block, - _that.gridfins, - _that.legs, - _that.reused, - _that.landSuccess, - _that.landingIntent, - _that.landingType, - _that.landingVehicle); + _that.status, + _that.originalLaunch, + _that.originalLaunchUnix, + _that.missions, + _that.reuseCount, + _that.rtlsAttempts, + _that.rtlsLandings, + _that.asdsAttempts, + _that.asdsLandings, + _that.waterLanding, + _that.details); case _: return null; } @@ -398,16 +450,20 @@ extension NetworkCoreModelPatterns on NetworkCoreModel { class _NetworkCoreModel extends NetworkCoreModel { const _NetworkCoreModel( {@JsonKey(name: 'core_serial') this.coreSerial, - this.flight, this.block, - this.gridfins, - this.legs, - this.reused, - @JsonKey(name: 'land_success') this.landSuccess, - @JsonKey(name: 'landing_intent') this.landingIntent, - @JsonKey(name: 'landing_type') this.landingType, - @JsonKey(name: 'landing_vehicle') this.landingVehicle}) - : super._(); + this.status, + @JsonKey(name: 'original_launch') this.originalLaunch, + @JsonKey(name: 'original_launch_unix') this.originalLaunchUnix, + final List? missions, + @JsonKey(name: 'reuse_count') this.reuseCount, + @JsonKey(name: 'rtls_attempts') this.rtlsAttempts, + @JsonKey(name: 'rtls_landings') this.rtlsLandings, + @JsonKey(name: 'asds_attempts') this.asdsAttempts, + @JsonKey(name: 'asds_landings') this.asdsLandings, + @JsonKey(name: 'water_landing') this.waterLanding, + this.details}) + : _missions = missions, + super._(); factory _NetworkCoreModel.fromJson(Map json) => _$NetworkCoreModelFromJson(json); @@ -415,27 +471,45 @@ class _NetworkCoreModel extends NetworkCoreModel { @JsonKey(name: 'core_serial') final String? coreSerial; @override - final int? flight; - @override final int? block; @override - final bool? gridfins; + final String? status; + @override + @JsonKey(name: 'original_launch') + final String? originalLaunch; + @override + @JsonKey(name: 'original_launch_unix') + final int? originalLaunchUnix; + final List? _missions; + @override + List? get missions { + final value = _missions; + if (value == null) return null; + if (_missions is EqualUnmodifiableListView) return _missions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + @JsonKey(name: 'reuse_count') + final int? reuseCount; @override - final bool? legs; + @JsonKey(name: 'rtls_attempts') + final int? rtlsAttempts; @override - final bool? reused; + @JsonKey(name: 'rtls_landings') + final int? rtlsLandings; @override - @JsonKey(name: 'land_success') - final bool? landSuccess; + @JsonKey(name: 'asds_attempts') + final int? asdsAttempts; @override - @JsonKey(name: 'landing_intent') - final bool? landingIntent; + @JsonKey(name: 'asds_landings') + final int? asdsLandings; @override - @JsonKey(name: 'landing_type') - final String? landingType; + @JsonKey(name: 'water_landing') + final bool? waterLanding; @override - @JsonKey(name: 'landing_vehicle') - final String? landingVehicle; + final String? details; /// Create a copy of NetworkCoreModel /// with the given fields replaced by the non-null parameter values. @@ -459,20 +533,26 @@ class _NetworkCoreModel extends NetworkCoreModel { other is _NetworkCoreModel && (identical(other.coreSerial, coreSerial) || other.coreSerial == coreSerial) && - (identical(other.flight, flight) || other.flight == flight) && (identical(other.block, block) || other.block == block) && - (identical(other.gridfins, gridfins) || - other.gridfins == gridfins) && - (identical(other.legs, legs) || other.legs == legs) && - (identical(other.reused, reused) || other.reused == reused) && - (identical(other.landSuccess, landSuccess) || - other.landSuccess == landSuccess) && - (identical(other.landingIntent, landingIntent) || - other.landingIntent == landingIntent) && - (identical(other.landingType, landingType) || - other.landingType == landingType) && - (identical(other.landingVehicle, landingVehicle) || - other.landingVehicle == landingVehicle)); + (identical(other.status, status) || other.status == status) && + (identical(other.originalLaunch, originalLaunch) || + other.originalLaunch == originalLaunch) && + (identical(other.originalLaunchUnix, originalLaunchUnix) || + other.originalLaunchUnix == originalLaunchUnix) && + const DeepCollectionEquality().equals(other._missions, _missions) && + (identical(other.reuseCount, reuseCount) || + other.reuseCount == reuseCount) && + (identical(other.rtlsAttempts, rtlsAttempts) || + other.rtlsAttempts == rtlsAttempts) && + (identical(other.rtlsLandings, rtlsLandings) || + other.rtlsLandings == rtlsLandings) && + (identical(other.asdsAttempts, asdsAttempts) || + other.asdsAttempts == asdsAttempts) && + (identical(other.asdsLandings, asdsLandings) || + other.asdsLandings == asdsLandings) && + (identical(other.waterLanding, waterLanding) || + other.waterLanding == waterLanding) && + (identical(other.details, details) || other.details == details)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -480,19 +560,22 @@ class _NetworkCoreModel extends NetworkCoreModel { int get hashCode => Object.hash( runtimeType, coreSerial, - flight, block, - gridfins, - legs, - reused, - landSuccess, - landingIntent, - landingType, - landingVehicle); + status, + originalLaunch, + originalLaunchUnix, + const DeepCollectionEquality().hash(_missions), + reuseCount, + rtlsAttempts, + rtlsLandings, + asdsAttempts, + asdsLandings, + waterLanding, + details); @override String toString() { - return 'NetworkCoreModel(coreSerial: $coreSerial, flight: $flight, block: $block, gridfins: $gridfins, legs: $legs, reused: $reused, landSuccess: $landSuccess, landingIntent: $landingIntent, landingType: $landingType, landingVehicle: $landingVehicle)'; + return 'NetworkCoreModel(coreSerial: $coreSerial, block: $block, status: $status, originalLaunch: $originalLaunch, originalLaunchUnix: $originalLaunchUnix, missions: $missions, reuseCount: $reuseCount, rtlsAttempts: $rtlsAttempts, rtlsLandings: $rtlsLandings, asdsAttempts: $asdsAttempts, asdsLandings: $asdsLandings, waterLanding: $waterLanding, details: $details)'; } } @@ -506,15 +589,18 @@ abstract mixin class _$NetworkCoreModelCopyWith<$Res> @useResult $Res call( {@JsonKey(name: 'core_serial') String? coreSerial, - int? flight, int? block, - bool? gridfins, - bool? legs, - bool? reused, - @JsonKey(name: 'land_success') bool? landSuccess, - @JsonKey(name: 'landing_intent') bool? landingIntent, - @JsonKey(name: 'landing_type') String? landingType, - @JsonKey(name: 'landing_vehicle') String? landingVehicle}); + String? status, + @JsonKey(name: 'original_launch') String? originalLaunch, + @JsonKey(name: 'original_launch_unix') int? originalLaunchUnix, + List? missions, + @JsonKey(name: 'reuse_count') int? reuseCount, + @JsonKey(name: 'rtls_attempts') int? rtlsAttempts, + @JsonKey(name: 'rtls_landings') int? rtlsLandings, + @JsonKey(name: 'asds_attempts') int? asdsAttempts, + @JsonKey(name: 'asds_landings') int? asdsLandings, + @JsonKey(name: 'water_landing') bool? waterLanding, + String? details}); } /// @nodoc @@ -531,57 +617,388 @@ class __$NetworkCoreModelCopyWithImpl<$Res> @pragma('vm:prefer-inline') $Res call({ Object? coreSerial = freezed, - Object? flight = freezed, Object? block = freezed, - Object? gridfins = freezed, - Object? legs = freezed, - Object? reused = freezed, - Object? landSuccess = freezed, - Object? landingIntent = freezed, - Object? landingType = freezed, - Object? landingVehicle = freezed, + Object? status = freezed, + Object? originalLaunch = freezed, + Object? originalLaunchUnix = freezed, + Object? missions = freezed, + Object? reuseCount = freezed, + Object? rtlsAttempts = freezed, + Object? rtlsLandings = freezed, + Object? asdsAttempts = freezed, + Object? asdsLandings = freezed, + Object? waterLanding = freezed, + Object? details = freezed, }) { return _then(_NetworkCoreModel( coreSerial: freezed == coreSerial ? _self.coreSerial : coreSerial // ignore: cast_nullable_to_non_nullable as String?, - flight: freezed == flight - ? _self.flight - : flight // ignore: cast_nullable_to_non_nullable - as int?, block: freezed == block ? _self.block : block // ignore: cast_nullable_to_non_nullable as int?, - gridfins: freezed == gridfins - ? _self.gridfins - : gridfins // ignore: cast_nullable_to_non_nullable - as bool?, - legs: freezed == legs - ? _self.legs - : legs // ignore: cast_nullable_to_non_nullable - as bool?, - reused: freezed == reused - ? _self.reused - : reused // ignore: cast_nullable_to_non_nullable - as bool?, - landSuccess: freezed == landSuccess - ? _self.landSuccess - : landSuccess // ignore: cast_nullable_to_non_nullable - as bool?, - landingIntent: freezed == landingIntent - ? _self.landingIntent - : landingIntent // ignore: cast_nullable_to_non_nullable + status: freezed == status + ? _self.status + : status // ignore: cast_nullable_to_non_nullable + as String?, + originalLaunch: freezed == originalLaunch + ? _self.originalLaunch + : originalLaunch // ignore: cast_nullable_to_non_nullable + as String?, + originalLaunchUnix: freezed == originalLaunchUnix + ? _self.originalLaunchUnix + : originalLaunchUnix // ignore: cast_nullable_to_non_nullable + as int?, + missions: freezed == missions + ? _self._missions + : missions // ignore: cast_nullable_to_non_nullable + as List?, + reuseCount: freezed == reuseCount + ? _self.reuseCount + : reuseCount // ignore: cast_nullable_to_non_nullable + as int?, + rtlsAttempts: freezed == rtlsAttempts + ? _self.rtlsAttempts + : rtlsAttempts // ignore: cast_nullable_to_non_nullable + as int?, + rtlsLandings: freezed == rtlsLandings + ? _self.rtlsLandings + : rtlsLandings // ignore: cast_nullable_to_non_nullable + as int?, + asdsAttempts: freezed == asdsAttempts + ? _self.asdsAttempts + : asdsAttempts // ignore: cast_nullable_to_non_nullable + as int?, + asdsLandings: freezed == asdsLandings + ? _self.asdsLandings + : asdsLandings // ignore: cast_nullable_to_non_nullable + as int?, + waterLanding: freezed == waterLanding + ? _self.waterLanding + : waterLanding // ignore: cast_nullable_to_non_nullable as bool?, - landingType: freezed == landingType - ? _self.landingType - : landingType // ignore: cast_nullable_to_non_nullable + details: freezed == details + ? _self.details + : details // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +mixin _$NetworkMission { + String? get name; + int? get flight; + + /// Create a copy of NetworkMission + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NetworkMissionCopyWith get copyWith => + _$NetworkMissionCopyWithImpl( + this as NetworkMission, _$identity); + + /// Serializes this NetworkMission to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NetworkMission && + (identical(other.name, name) || other.name == name) && + (identical(other.flight, flight) || other.flight == flight)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, name, flight); + + @override + String toString() { + return 'NetworkMission(name: $name, flight: $flight)'; + } +} + +/// @nodoc +abstract mixin class $NetworkMissionCopyWith<$Res> { + factory $NetworkMissionCopyWith( + NetworkMission value, $Res Function(NetworkMission) _then) = + _$NetworkMissionCopyWithImpl; + @useResult + $Res call({String? name, int? flight}); +} + +/// @nodoc +class _$NetworkMissionCopyWithImpl<$Res> + implements $NetworkMissionCopyWith<$Res> { + _$NetworkMissionCopyWithImpl(this._self, this._then); + + final NetworkMission _self; + final $Res Function(NetworkMission) _then; + + /// Create a copy of NetworkMission + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = freezed, + Object? flight = freezed, + }) { + return _then(_self.copyWith( + name: freezed == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable as String?, - landingVehicle: freezed == landingVehicle - ? _self.landingVehicle - : landingVehicle // ignore: cast_nullable_to_non_nullable + flight: freezed == flight + ? _self.flight + : flight // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// Adds pattern-matching-related methods to [NetworkMission]. +extension NetworkMissionPatterns on NetworkMission { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_NetworkMission value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkMission() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_NetworkMission value) $default, + ) { + final _that = this; + switch (_that) { + case _NetworkMission(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_NetworkMission value)? $default, + ) { + final _that = this; + switch (_that) { + case _NetworkMission() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(String? name, int? flight)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkMission() when $default != null: + return $default(_that.name, _that.flight); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(String? name, int? flight) $default, + ) { + final _that = this; + switch (_that) { + case _NetworkMission(): + return $default(_that.name, _that.flight); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(String? name, int? flight)? $default, + ) { + final _that = this; + switch (_that) { + case _NetworkMission() when $default != null: + return $default(_that.name, _that.flight); + case _: + return null; + } + } +} + +/// @nodoc +@JsonSerializable() +class _NetworkMission extends NetworkMission { + const _NetworkMission({this.name, this.flight}) : super._(); + factory _NetworkMission.fromJson(Map json) => + _$NetworkMissionFromJson(json); + + @override + final String? name; + @override + final int? flight; + + /// Create a copy of NetworkMission + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NetworkMissionCopyWith<_NetworkMission> get copyWith => + __$NetworkMissionCopyWithImpl<_NetworkMission>(this, _$identity); + + @override + Map toJson() { + return _$NetworkMissionToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NetworkMission && + (identical(other.name, name) || other.name == name) && + (identical(other.flight, flight) || other.flight == flight)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, name, flight); + + @override + String toString() { + return 'NetworkMission(name: $name, flight: $flight)'; + } +} + +/// @nodoc +abstract mixin class _$NetworkMissionCopyWith<$Res> + implements $NetworkMissionCopyWith<$Res> { + factory _$NetworkMissionCopyWith( + _NetworkMission value, $Res Function(_NetworkMission) _then) = + __$NetworkMissionCopyWithImpl; + @override + @useResult + $Res call({String? name, int? flight}); +} + +/// @nodoc +class __$NetworkMissionCopyWithImpl<$Res> + implements _$NetworkMissionCopyWith<$Res> { + __$NetworkMissionCopyWithImpl(this._self, this._then); + + final _NetworkMission _self; + final $Res Function(_NetworkMission) _then; + + /// Create a copy of NetworkMission + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? name = freezed, + Object? flight = freezed, + }) { + return _then(_NetworkMission( + name: freezed == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable as String?, + flight: freezed == flight + ? _self.flight + : flight // ignore: cast_nullable_to_non_nullable + as int?, )); } } diff --git a/lib/data/network/model/core/network_core_model.g.dart b/lib/data/network/model/core/network_core_model.g.dart index 254198f..0e3197d 100644 --- a/lib/data/network/model/core/network_core_model.g.dart +++ b/lib/data/network/model/core/network_core_model.g.dart @@ -9,27 +9,47 @@ part of 'network_core_model.dart'; _NetworkCoreModel _$NetworkCoreModelFromJson(Map json) => _NetworkCoreModel( coreSerial: json['core_serial'] as String?, - flight: (json['flight'] as num?)?.toInt(), block: (json['block'] as num?)?.toInt(), - gridfins: json['gridfins'] as bool?, - legs: json['legs'] as bool?, - reused: json['reused'] as bool?, - landSuccess: json['land_success'] as bool?, - landingIntent: json['landing_intent'] as bool?, - landingType: json['landing_type'] as String?, - landingVehicle: json['landing_vehicle'] as String?, + status: json['status'] as String?, + originalLaunch: json['original_launch'] as String?, + originalLaunchUnix: (json['original_launch_unix'] as num?)?.toInt(), + missions: (json['missions'] as List?) + ?.map((e) => NetworkMission.fromJson(e as Map)) + .toList(), + reuseCount: (json['reuse_count'] as num?)?.toInt(), + rtlsAttempts: (json['rtls_attempts'] as num?)?.toInt(), + rtlsLandings: (json['rtls_landings'] as num?)?.toInt(), + asdsAttempts: (json['asds_attempts'] as num?)?.toInt(), + asdsLandings: (json['asds_landings'] as num?)?.toInt(), + waterLanding: json['water_landing'] as bool?, + details: json['details'] as String?, ); Map _$NetworkCoreModelToJson(_NetworkCoreModel instance) => { 'core_serial': instance.coreSerial, - 'flight': instance.flight, 'block': instance.block, - 'gridfins': instance.gridfins, - 'legs': instance.legs, - 'reused': instance.reused, - 'land_success': instance.landSuccess, - 'landing_intent': instance.landingIntent, - 'landing_type': instance.landingType, - 'landing_vehicle': instance.landingVehicle, + 'status': instance.status, + 'original_launch': instance.originalLaunch, + 'original_launch_unix': instance.originalLaunchUnix, + 'missions': instance.missions, + 'reuse_count': instance.reuseCount, + 'rtls_attempts': instance.rtlsAttempts, + 'rtls_landings': instance.rtlsLandings, + 'asds_attempts': instance.asdsAttempts, + 'asds_landings': instance.asdsLandings, + 'water_landing': instance.waterLanding, + 'details': instance.details, + }; + +_NetworkMission _$NetworkMissionFromJson(Map json) => + _NetworkMission( + name: json['name'] as String?, + flight: (json['flight'] as num?)?.toInt(), + ); + +Map _$NetworkMissionToJson(_NetworkMission instance) => + { + 'name': instance.name, + 'flight': instance.flight, }; diff --git a/lib/data/network/model/stage/network_first_stage_model.dart b/lib/data/network/model/stage/network_first_stage_model.dart index 29cdab0..923b9db 100644 --- a/lib/data/network/model/stage/network_first_stage_model.dart +++ b/lib/data/network/model/stage/network_first_stage_model.dart @@ -1,4 +1,3 @@ -import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'network_first_stage_model.freezed.dart'; @@ -7,7 +6,7 @@ part 'network_first_stage_model.g.dart'; @freezed abstract class NetworkFirstStageModel with _$NetworkFirstStageModel { const factory NetworkFirstStageModel({ - List? cores, + List? cores, }) = _NetworkFirstStageModel; const NetworkFirstStageModel._(); @@ -15,3 +14,24 @@ abstract class NetworkFirstStageModel with _$NetworkFirstStageModel { factory NetworkFirstStageModel.fromJson(Map json) => _$NetworkFirstStageModelFromJson(json); } + +@freezed +abstract class NetworkStageCoreModel with _$NetworkStageCoreModel { + const factory NetworkStageCoreModel({ + @JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle, + }) = _NetworkStageCoreModel; + + const NetworkStageCoreModel._(); + + factory NetworkStageCoreModel.fromJson(Map json) => + _$NetworkStageCoreModelFromJson(json); +} diff --git a/lib/data/network/model/stage/network_first_stage_model.freezed.dart b/lib/data/network/model/stage/network_first_stage_model.freezed.dart index 443ea3e..da7fa8c 100644 --- a/lib/data/network/model/stage/network_first_stage_model.freezed.dart +++ b/lib/data/network/model/stage/network_first_stage_model.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$NetworkFirstStageModel { - List? get cores; + List? get cores; /// Create a copy of NetworkFirstStageModel /// with the given fields replaced by the non-null parameter values. @@ -52,7 +52,7 @@ abstract mixin class $NetworkFirstStageModelCopyWith<$Res> { $Res Function(NetworkFirstStageModel) _then) = _$NetworkFirstStageModelCopyWithImpl; @useResult - $Res call({List? cores}); + $Res call({List? cores}); } /// @nodoc @@ -74,7 +74,7 @@ class _$NetworkFirstStageModelCopyWithImpl<$Res> cores: freezed == cores ? _self.cores : cores // ignore: cast_nullable_to_non_nullable - as List?, + as List?, )); } } @@ -172,7 +172,7 @@ extension NetworkFirstStageModelPatterns on NetworkFirstStageModel { @optionalTypeArgs TResult maybeWhen( - TResult Function(List? cores)? $default, { + TResult Function(List? cores)? $default, { required TResult orElse(), }) { final _that = this; @@ -199,7 +199,7 @@ extension NetworkFirstStageModelPatterns on NetworkFirstStageModel { @optionalTypeArgs TResult when( - TResult Function(List? cores) $default, + TResult Function(List? cores) $default, ) { final _that = this; switch (_that) { @@ -224,7 +224,7 @@ extension NetworkFirstStageModelPatterns on NetworkFirstStageModel { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(List? cores)? $default, + TResult? Function(List? cores)? $default, ) { final _that = this; switch (_that) { @@ -239,15 +239,15 @@ extension NetworkFirstStageModelPatterns on NetworkFirstStageModel { /// @nodoc @JsonSerializable() class _NetworkFirstStageModel extends NetworkFirstStageModel { - const _NetworkFirstStageModel({final List? cores}) + const _NetworkFirstStageModel({final List? cores}) : _cores = cores, super._(); factory _NetworkFirstStageModel.fromJson(Map json) => _$NetworkFirstStageModelFromJson(json); - final List? _cores; + final List? _cores; @override - List? get cores { + List? get cores { final value = _cores; if (value == null) return null; if (_cores is EqualUnmodifiableListView) return _cores; @@ -298,7 +298,7 @@ abstract mixin class _$NetworkFirstStageModelCopyWith<$Res> __$NetworkFirstStageModelCopyWithImpl; @override @useResult - $Res call({List? cores}); + $Res call({List? cores}); } /// @nodoc @@ -320,7 +320,582 @@ class __$NetworkFirstStageModelCopyWithImpl<$Res> cores: freezed == cores ? _self._cores : cores // ignore: cast_nullable_to_non_nullable - as List?, + as List?, + )); + } +} + +/// @nodoc +mixin _$NetworkStageCoreModel { + @JsonKey(name: 'core_serial') + String? get coreSerial; + int? get flight; + int? get block; + bool? get gridfins; + bool? get legs; + bool? get reused; + @JsonKey(name: 'land_success') + bool? get landSuccess; + @JsonKey(name: 'landing_intent') + bool? get landingIntent; + @JsonKey(name: 'landing_type') + String? get landingType; + @JsonKey(name: 'landing_vehicle') + String? get landingVehicle; + + /// Create a copy of NetworkStageCoreModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NetworkStageCoreModelCopyWith get copyWith => + _$NetworkStageCoreModelCopyWithImpl( + this as NetworkStageCoreModel, _$identity); + + /// Serializes this NetworkStageCoreModel to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NetworkStageCoreModel && + (identical(other.coreSerial, coreSerial) || + other.coreSerial == coreSerial) && + (identical(other.flight, flight) || other.flight == flight) && + (identical(other.block, block) || other.block == block) && + (identical(other.gridfins, gridfins) || + other.gridfins == gridfins) && + (identical(other.legs, legs) || other.legs == legs) && + (identical(other.reused, reused) || other.reused == reused) && + (identical(other.landSuccess, landSuccess) || + other.landSuccess == landSuccess) && + (identical(other.landingIntent, landingIntent) || + other.landingIntent == landingIntent) && + (identical(other.landingType, landingType) || + other.landingType == landingType) && + (identical(other.landingVehicle, landingVehicle) || + other.landingVehicle == landingVehicle)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + coreSerial, + flight, + block, + gridfins, + legs, + reused, + landSuccess, + landingIntent, + landingType, + landingVehicle); + + @override + String toString() { + return 'NetworkStageCoreModel(coreSerial: $coreSerial, flight: $flight, block: $block, gridfins: $gridfins, legs: $legs, reused: $reused, landSuccess: $landSuccess, landingIntent: $landingIntent, landingType: $landingType, landingVehicle: $landingVehicle)'; + } +} + +/// @nodoc +abstract mixin class $NetworkStageCoreModelCopyWith<$Res> { + factory $NetworkStageCoreModelCopyWith(NetworkStageCoreModel value, + $Res Function(NetworkStageCoreModel) _then) = + _$NetworkStageCoreModelCopyWithImpl; + @useResult + $Res call( + {@JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle}); +} + +/// @nodoc +class _$NetworkStageCoreModelCopyWithImpl<$Res> + implements $NetworkStageCoreModelCopyWith<$Res> { + _$NetworkStageCoreModelCopyWithImpl(this._self, this._then); + + final NetworkStageCoreModel _self; + final $Res Function(NetworkStageCoreModel) _then; + + /// Create a copy of NetworkStageCoreModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? coreSerial = freezed, + Object? flight = freezed, + Object? block = freezed, + Object? gridfins = freezed, + Object? legs = freezed, + Object? reused = freezed, + Object? landSuccess = freezed, + Object? landingIntent = freezed, + Object? landingType = freezed, + Object? landingVehicle = freezed, + }) { + return _then(_self.copyWith( + coreSerial: freezed == coreSerial + ? _self.coreSerial + : coreSerial // ignore: cast_nullable_to_non_nullable + as String?, + flight: freezed == flight + ? _self.flight + : flight // ignore: cast_nullable_to_non_nullable + as int?, + block: freezed == block + ? _self.block + : block // ignore: cast_nullable_to_non_nullable + as int?, + gridfins: freezed == gridfins + ? _self.gridfins + : gridfins // ignore: cast_nullable_to_non_nullable + as bool?, + legs: freezed == legs + ? _self.legs + : legs // ignore: cast_nullable_to_non_nullable + as bool?, + reused: freezed == reused + ? _self.reused + : reused // ignore: cast_nullable_to_non_nullable + as bool?, + landSuccess: freezed == landSuccess + ? _self.landSuccess + : landSuccess // ignore: cast_nullable_to_non_nullable + as bool?, + landingIntent: freezed == landingIntent + ? _self.landingIntent + : landingIntent // ignore: cast_nullable_to_non_nullable + as bool?, + landingType: freezed == landingType + ? _self.landingType + : landingType // ignore: cast_nullable_to_non_nullable + as String?, + landingVehicle: freezed == landingVehicle + ? _self.landingVehicle + : landingVehicle // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// Adds pattern-matching-related methods to [NetworkStageCoreModel]. +extension NetworkStageCoreModelPatterns on NetworkStageCoreModel { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_NetworkStageCoreModel value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_NetworkStageCoreModel value) $default, + ) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel(): + return $default(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_NetworkStageCoreModel value)? $default, + ) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + @JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel() when $default != null: + return $default( + _that.coreSerial, + _that.flight, + _that.block, + _that.gridfins, + _that.legs, + _that.reused, + _that.landSuccess, + _that.landingIntent, + _that.landingType, + _that.landingVehicle); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + @JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle) + $default, + ) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel(): + return $default( + _that.coreSerial, + _that.flight, + _that.block, + _that.gridfins, + _that.legs, + _that.reused, + _that.landSuccess, + _that.landingIntent, + _that.landingType, + _that.landingVehicle); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + @JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle)? + $default, + ) { + final _that = this; + switch (_that) { + case _NetworkStageCoreModel() when $default != null: + return $default( + _that.coreSerial, + _that.flight, + _that.block, + _that.gridfins, + _that.legs, + _that.reused, + _that.landSuccess, + _that.landingIntent, + _that.landingType, + _that.landingVehicle); + case _: + return null; + } + } +} + +/// @nodoc +@JsonSerializable() +class _NetworkStageCoreModel extends NetworkStageCoreModel { + const _NetworkStageCoreModel( + {@JsonKey(name: 'core_serial') this.coreSerial, + this.flight, + this.block, + this.gridfins, + this.legs, + this.reused, + @JsonKey(name: 'land_success') this.landSuccess, + @JsonKey(name: 'landing_intent') this.landingIntent, + @JsonKey(name: 'landing_type') this.landingType, + @JsonKey(name: 'landing_vehicle') this.landingVehicle}) + : super._(); + factory _NetworkStageCoreModel.fromJson(Map json) => + _$NetworkStageCoreModelFromJson(json); + + @override + @JsonKey(name: 'core_serial') + final String? coreSerial; + @override + final int? flight; + @override + final int? block; + @override + final bool? gridfins; + @override + final bool? legs; + @override + final bool? reused; + @override + @JsonKey(name: 'land_success') + final bool? landSuccess; + @override + @JsonKey(name: 'landing_intent') + final bool? landingIntent; + @override + @JsonKey(name: 'landing_type') + final String? landingType; + @override + @JsonKey(name: 'landing_vehicle') + final String? landingVehicle; + + /// Create a copy of NetworkStageCoreModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NetworkStageCoreModelCopyWith<_NetworkStageCoreModel> get copyWith => + __$NetworkStageCoreModelCopyWithImpl<_NetworkStageCoreModel>( + this, _$identity); + + @override + Map toJson() { + return _$NetworkStageCoreModelToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NetworkStageCoreModel && + (identical(other.coreSerial, coreSerial) || + other.coreSerial == coreSerial) && + (identical(other.flight, flight) || other.flight == flight) && + (identical(other.block, block) || other.block == block) && + (identical(other.gridfins, gridfins) || + other.gridfins == gridfins) && + (identical(other.legs, legs) || other.legs == legs) && + (identical(other.reused, reused) || other.reused == reused) && + (identical(other.landSuccess, landSuccess) || + other.landSuccess == landSuccess) && + (identical(other.landingIntent, landingIntent) || + other.landingIntent == landingIntent) && + (identical(other.landingType, landingType) || + other.landingType == landingType) && + (identical(other.landingVehicle, landingVehicle) || + other.landingVehicle == landingVehicle)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + coreSerial, + flight, + block, + gridfins, + legs, + reused, + landSuccess, + landingIntent, + landingType, + landingVehicle); + + @override + String toString() { + return 'NetworkStageCoreModel(coreSerial: $coreSerial, flight: $flight, block: $block, gridfins: $gridfins, legs: $legs, reused: $reused, landSuccess: $landSuccess, landingIntent: $landingIntent, landingType: $landingType, landingVehicle: $landingVehicle)'; + } +} + +/// @nodoc +abstract mixin class _$NetworkStageCoreModelCopyWith<$Res> + implements $NetworkStageCoreModelCopyWith<$Res> { + factory _$NetworkStageCoreModelCopyWith(_NetworkStageCoreModel value, + $Res Function(_NetworkStageCoreModel) _then) = + __$NetworkStageCoreModelCopyWithImpl; + @override + @useResult + $Res call( + {@JsonKey(name: 'core_serial') String? coreSerial, + int? flight, + int? block, + bool? gridfins, + bool? legs, + bool? reused, + @JsonKey(name: 'land_success') bool? landSuccess, + @JsonKey(name: 'landing_intent') bool? landingIntent, + @JsonKey(name: 'landing_type') String? landingType, + @JsonKey(name: 'landing_vehicle') String? landingVehicle}); +} + +/// @nodoc +class __$NetworkStageCoreModelCopyWithImpl<$Res> + implements _$NetworkStageCoreModelCopyWith<$Res> { + __$NetworkStageCoreModelCopyWithImpl(this._self, this._then); + + final _NetworkStageCoreModel _self; + final $Res Function(_NetworkStageCoreModel) _then; + + /// Create a copy of NetworkStageCoreModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? coreSerial = freezed, + Object? flight = freezed, + Object? block = freezed, + Object? gridfins = freezed, + Object? legs = freezed, + Object? reused = freezed, + Object? landSuccess = freezed, + Object? landingIntent = freezed, + Object? landingType = freezed, + Object? landingVehicle = freezed, + }) { + return _then(_NetworkStageCoreModel( + coreSerial: freezed == coreSerial + ? _self.coreSerial + : coreSerial // ignore: cast_nullable_to_non_nullable + as String?, + flight: freezed == flight + ? _self.flight + : flight // ignore: cast_nullable_to_non_nullable + as int?, + block: freezed == block + ? _self.block + : block // ignore: cast_nullable_to_non_nullable + as int?, + gridfins: freezed == gridfins + ? _self.gridfins + : gridfins // ignore: cast_nullable_to_non_nullable + as bool?, + legs: freezed == legs + ? _self.legs + : legs // ignore: cast_nullable_to_non_nullable + as bool?, + reused: freezed == reused + ? _self.reused + : reused // ignore: cast_nullable_to_non_nullable + as bool?, + landSuccess: freezed == landSuccess + ? _self.landSuccess + : landSuccess // ignore: cast_nullable_to_non_nullable + as bool?, + landingIntent: freezed == landingIntent + ? _self.landingIntent + : landingIntent // ignore: cast_nullable_to_non_nullable + as bool?, + landingType: freezed == landingType + ? _self.landingType + : landingType // ignore: cast_nullable_to_non_nullable + as String?, + landingVehicle: freezed == landingVehicle + ? _self.landingVehicle + : landingVehicle // ignore: cast_nullable_to_non_nullable + as String?, )); } } diff --git a/lib/data/network/model/stage/network_first_stage_model.g.dart b/lib/data/network/model/stage/network_first_stage_model.g.dart index b7e0034..5e2b6be 100644 --- a/lib/data/network/model/stage/network_first_stage_model.g.dart +++ b/lib/data/network/model/stage/network_first_stage_model.g.dart @@ -10,7 +10,8 @@ _NetworkFirstStageModel _$NetworkFirstStageModelFromJson( Map json) => _NetworkFirstStageModel( cores: (json['cores'] as List?) - ?.map((e) => NetworkCoreModel.fromJson(e as Map)) + ?.map( + (e) => NetworkStageCoreModel.fromJson(e as Map)) .toList(), ); @@ -19,3 +20,33 @@ Map _$NetworkFirstStageModelToJson( { 'cores': instance.cores, }; + +_NetworkStageCoreModel _$NetworkStageCoreModelFromJson( + Map json) => + _NetworkStageCoreModel( + coreSerial: json['core_serial'] as String?, + flight: (json['flight'] as num?)?.toInt(), + block: (json['block'] as num?)?.toInt(), + gridfins: json['gridfins'] as bool?, + legs: json['legs'] as bool?, + reused: json['reused'] as bool?, + landSuccess: json['land_success'] as bool?, + landingIntent: json['landing_intent'] as bool?, + landingType: json['landing_type'] as String?, + landingVehicle: json['landing_vehicle'] as String?, + ); + +Map _$NetworkStageCoreModelToJson( + _NetworkStageCoreModel instance) => + { + 'core_serial': instance.coreSerial, + 'flight': instance.flight, + 'block': instance.block, + 'gridfins': instance.gridfins, + 'legs': instance.legs, + 'reused': instance.reused, + 'land_success': instance.landSuccess, + 'landing_intent': instance.landingIntent, + 'landing_type': instance.landingType, + 'landing_vehicle': instance.landingVehicle, + }; diff --git a/lib/data/network/service/cores/cores_service.dart b/lib/data/network/service/cores/cores_service.dart new file mode 100644 index 0000000..a47e07a --- /dev/null +++ b/lib/data/network/service/cores/cores_service.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/constants.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'cores_service.g.dart'; + +@RestApi(baseUrl: baseUrl) +abstract class CoresService { + factory CoresService(Dio dio) = _CoresService; + + @GET('cores') + Future> fetchCores({ + @Query('id') bool? hasId = true, + @Query('limit') int? limit, + @Query('offset') int? offset, + }); + + @GET('cores/{coreSerial}') + Future fetchCore( + @Path('coreSerial') String coreSerial, + ); +} diff --git a/lib/data/network/service/cores/cores_service.g.dart b/lib/data/network/service/cores/cores_service.g.dart new file mode 100644 index 0000000..2219c17 --- /dev/null +++ b/lib/data/network/service/cores/cores_service.g.dart @@ -0,0 +1,115 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cores_service.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter + +class _CoresService implements CoresService { + _CoresService(this._dio, {this.baseUrl, this.errorLogger}) { + baseUrl ??= 'https://api.spacexdata.com/v3/'; + } + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> fetchCores({ + bool? hasId = true, + int? limit, + int? offset, + }) async { + final _extra = {}; + final queryParameters = { + r'id': hasId, + r'limit': limit, + r'offset': offset, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'cores', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => NetworkCoreModel.fromJson(i as Map), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future fetchCore(String coreSerial) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'cores/${coreSerial}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late NetworkCoreModel _value; + try { + _value = NetworkCoreModel.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/lib/di/app_bloc_providers.dart b/lib/di/app_bloc_providers.dart index 9089f7f..2622b5e 100644 --- a/lib/di/app_bloc_providers.dart +++ b/lib/di/app_bloc_providers.dart @@ -2,9 +2,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc_app_template/bloc/email_list/email_list_bloc.dart'; import 'package:flutter_bloc_app_template/bloc/init/init_bloc.dart'; import 'package:flutter_bloc_app_template/bloc/theme/theme_cubit.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; import 'package:flutter_bloc_app_template/features/launches/bloc/launches_bloc.dart'; import 'package:flutter_bloc_app_template/features/roadster/bloc/roadster_bloc.dart'; import 'package:flutter_bloc_app_template/features/rockets/bloc/rockets_bloc.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; import 'package:flutter_bloc_app_template/repository/email_list_repository.dart'; import 'package:flutter_bloc_app_template/repository/launches_repository.dart'; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; @@ -50,6 +52,13 @@ abstract class AppBlocProviders { const RoadsterEvent.load(), ), ), + BlocProvider( + create: (context) => CoresBloc( + RepositoryProvider.of(context), + )..add( + const CoresEvent.load(), + ), + ), BlocProvider( create: (_) => InitBloc() ..add( diff --git a/lib/di/app_repository_providers.dart b/lib/di/app_repository_providers.dart index 7cc92a4..d666a5f 100644 --- a/lib/di/app_repository_providers.dart +++ b/lib/di/app_repository_providers.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; import 'package:flutter_bloc_app_template/repository/email_list_repository.dart'; import 'package:flutter_bloc_app_template/repository/launches_repository.dart'; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; @@ -26,6 +27,9 @@ abstract class AppRepositoryProviders { RepositoryProvider( create: (context) => diContainer.get(), ), + RepositoryProvider( + create: (context) => diContainer.get(), + ), ]; } } diff --git a/lib/di/di_initializer.config.dart b/lib/di/di_initializer.config.dart index 9050b05..51d3ab6 100644 --- a/lib/di/di_initializer.config.dart +++ b/lib/di/di_initializer.config.dart @@ -11,12 +11,16 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:dio/dio.dart' as _i361; import 'package:flutter/material.dart' as _i409; +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart' + as _i915; import 'package:flutter_bloc_app_template/data/network/data_source/launches_network_data_source.dart' as _i358; import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart' as _i969; import 'package:flutter_bloc_app_template/data/network/data_source/rocket_network_data_source.dart' as _i636; +import 'package:flutter_bloc_app_template/data/network/service/cores/cores_service.dart' + as _i999; import 'package:flutter_bloc_app_template/data/network/service/launch/launch_service.dart' as _i511; import 'package:flutter_bloc_app_template/data/network/service/roadster/roadster_service.dart' @@ -29,6 +33,8 @@ import 'package:flutter_bloc_app_template/di/di_data_module.dart' as _i513; import 'package:flutter_bloc_app_template/di/di_network_module.dart' as _i52; import 'package:flutter_bloc_app_template/di/di_repository_module.dart' as _i381; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart' + as _i1009; import 'package:flutter_bloc_app_template/repository/launches_repository.dart' as _i11; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart' @@ -67,18 +73,24 @@ extension GetItInjectableX on _i174.GetIt { () => networkModule.provideRocketService(gh<_i361.Dio>())); gh.factory<_i837.RoadsterService>( () => networkModule.provideRoadsterService(gh<_i361.Dio>())); + gh.factory<_i999.CoresService>( + () => networkModule.provideCoresService(gh<_i361.Dio>())); gh.factory<_i626.ThemeRepository>(() => repositoryModule.provideAccidentsRepository(gh<_i750.ThemeStorage>())); gh.factory<_i969.RoadsterDataSource>(() => networkModule.provideRoadsterDataSource(gh<_i837.RoadsterService>())); gh.factory<_i358.LaunchesDataSource>(() => networkModule.provideLaunchesDataSource(gh<_i511.LaunchService>())); + gh.factory<_i915.CoresDataSource>( + () => networkModule.provideCoresDataSource(gh<_i999.CoresService>())); gh.factory<_i11.LaunchesRepository>(() => repositoryModule .provideLaunchesRepository(gh<_i358.LaunchesDataSource>())); gh.factory<_i636.RocketDataSource>(() => networkModule.provideRocketDataSource(gh<_i1029.RocketService>())); gh.factory<_i128.RoadsterRepository>(() => repositoryModule .provideRoadsterRepository(gh<_i969.RoadsterDataSource>())); + gh.factory<_i1009.CoresRepository>(() => + repositoryModule.provideCoresRepository(gh<_i915.CoresDataSource>())); gh.factory<_i31.RocketRepository>(() => repositoryModule.provideRocketRepository(gh<_i636.RocketDataSource>())); return this; diff --git a/lib/di/di_network_module.dart b/lib/di/di_network_module.dart index 7ddb185..d786488 100644 --- a/lib/di/di_network_module.dart +++ b/lib/di/di_network_module.dart @@ -1,7 +1,9 @@ import 'package:dio/dio.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/launches_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/rocket_network_data_source.dart'; +import 'package:flutter_bloc_app_template/data/network/service/cores/cores_service.dart'; import 'package:flutter_bloc_app_template/data/network/service/launch/launch_service.dart'; import 'package:flutter_bloc_app_template/data/network/service/roadster/roadster_service.dart'; import 'package:flutter_bloc_app_template/data/network/service/rocket/rocket_service.dart'; @@ -41,6 +43,11 @@ abstract class NetworkModule { return RoadsterService(dio); } + @factoryMethod + CoresService provideCoresService(Dio dio) { + return CoresService(dio); + } + @factoryMethod LaunchesDataSource provideLaunchesDataSource(LaunchService service) { return LaunchesNetworkDataSource(service); @@ -55,4 +62,9 @@ abstract class NetworkModule { RoadsterDataSource provideRoadsterDataSource(RoadsterService service) { return RoadsterNetworkDataSource(service); } + + @factoryMethod + CoresDataSource provideCoresDataSource(CoresService service) { + return CoresNetworkDataSource(service); + } } diff --git a/lib/di/di_repository_module.dart b/lib/di/di_repository_module.dart index 25983dc..f92f738 100644 --- a/lib/di/di_repository_module.dart +++ b/lib/di/di_repository_module.dart @@ -1,7 +1,9 @@ +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/launches_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/network/data_source/rocket_network_data_source.dart'; import 'package:flutter_bloc_app_template/data/theme_storage.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; import 'package:flutter_bloc_app_template/repository/launches_repository.dart'; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart'; @@ -25,4 +27,8 @@ abstract class RepositoryModule { @factoryMethod RoadsterRepository provideRoadsterRepository(RoadsterDataSource dataSource) => RoadsterRepositoryImpl(dataSource); + + @factoryMethod + CoresRepository provideCoresRepository(CoresDataSource dataSource) => + CoresRepositoryImpl(dataSource); } diff --git a/lib/features/cores/bloc/cores_bloc.dart b/lib/features/cores/bloc/cores_bloc.dart new file mode 100644 index 0000000..013d22d --- /dev/null +++ b/lib/features/cores/bloc/cores_bloc.dart @@ -0,0 +1,108 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/cores_ext.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'cores_bloc.freezed.dart'; +part 'cores_event.dart'; +part 'cores_state.dart'; + +class CoresBloc extends Bloc { + CoresBloc(this._repository) : super(const CoresState.loading()) { + on(_onLoad); + on(_onRefresh); + on(_onFilter); + } + + final CoresRepository _repository; + List allCores = []; + + Future _onLoad( + CoresLoadEvent event, + Emitter emit, + ) async { + emit(const CoresState.loading()); + try { + final cores = await _repository.getCores( + hasId: true, + limit: null, + offset: null, + ); + allCores = cores; + + if (cores.isEmpty) { + emit(const CoresState.empty()); + } else { + emit(CoresState.success(cores: cores, filteredCores: cores)); + } + } catch (e) { + emit(CoresState.error(e.toString())); + } + } + + Future _onRefresh( + CoresRefreshEvent event, + Emitter emit, + ) async { + emit(const CoresState.loading()); + try { + final cores = await _repository.getCores( + hasId: true, + limit: null, + offset: null, + ); + allCores = cores; + + if (cores.isEmpty) { + emit(const CoresState.empty()); + } else { + emit(CoresState.success(cores: cores, filteredCores: cores)); + } + } catch (e) { + emit(CoresState.error(e.toString())); + } + } + + void _onFilter( + CoresFilterEvent event, + Emitter emit, + ) { + if (allCores.isEmpty) { + emit(const CoresState.empty()); + return; + } + + final filtered = allCores.where((core) { + final matchesSearch = event.searchQuery.isEmpty || + (core.coreSerial + ?.toLowerCase() + .contains(event.searchQuery.toLowerCase()) ?? + false) || + (core.missions?.any((m) => + m.name + ?.toLowerCase() + .contains(event.searchQuery.toLowerCase()) ?? + false) ?? + false); + + final matchesStatus = event.statusFilter == null || + event.statusFilter == CoreFilterStatus.all || + core.status.toStatus() == event.statusFilter; + + return matchesSearch && matchesStatus; + }).toList(); + + if (filtered.isEmpty) { + emit(CoresState.notFound(searchQuery: event.searchQuery)); + } else { + emit(CoresState.success( + cores: allCores, + filteredCores: filtered, + searchQuery: event.searchQuery, + statusFilter: event.statusFilter, + )); + } + } +} diff --git a/lib/features/cores/bloc/cores_bloc.freezed.dart b/lib/features/cores/bloc/cores_bloc.freezed.dart new file mode 100644 index 0000000..9f0c1c6 --- /dev/null +++ b/lib/features/cores/bloc/cores_bloc.freezed.dart @@ -0,0 +1,950 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'cores_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CoresEvent { + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is CoresEvent); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'CoresEvent()'; + } +} + +/// @nodoc +class $CoresEventCopyWith<$Res> { + $CoresEventCopyWith(CoresEvent _, $Res Function(CoresEvent) __); +} + +/// Adds pattern-matching-related methods to [CoresEvent]. +extension CoresEventPatterns on CoresEvent { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap({ + TResult Function(CoresLoadEvent value)? load, + TResult Function(CoresRefreshEvent value)? refresh, + TResult Function(CoresFilterEvent value)? filter, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent() when load != null: + return load(_that); + case CoresRefreshEvent() when refresh != null: + return refresh(_that); + case CoresFilterEvent() when filter != null: + return filter(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map({ + required TResult Function(CoresLoadEvent value) load, + required TResult Function(CoresRefreshEvent value) refresh, + required TResult Function(CoresFilterEvent value) filter, + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent(): + return load(_that); + case CoresRefreshEvent(): + return refresh(_that); + case CoresFilterEvent(): + return filter(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(CoresLoadEvent value)? load, + TResult? Function(CoresRefreshEvent value)? refresh, + TResult? Function(CoresFilterEvent value)? filter, + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent() when load != null: + return load(_that); + case CoresRefreshEvent() when refresh != null: + return refresh(_that); + case CoresFilterEvent() when filter != null: + return filter(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(bool reload)? load, + TResult Function()? refresh, + TResult Function(String searchQuery, CoreFilterStatus? statusFilter)? + filter, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent() when load != null: + return load(_that.reload); + case CoresRefreshEvent() when refresh != null: + return refresh(); + case CoresFilterEvent() when filter != null: + return filter(_that.searchQuery, _that.statusFilter); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when({ + required TResult Function(bool reload) load, + required TResult Function() refresh, + required TResult Function( + String searchQuery, CoreFilterStatus? statusFilter) + filter, + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent(): + return load(_that.reload); + case CoresRefreshEvent(): + return refresh(); + case CoresFilterEvent(): + return filter(_that.searchQuery, _that.statusFilter); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(bool reload)? load, + TResult? Function()? refresh, + TResult? Function(String searchQuery, CoreFilterStatus? statusFilter)? + filter, + }) { + final _that = this; + switch (_that) { + case CoresLoadEvent() when load != null: + return load(_that.reload); + case CoresRefreshEvent() when refresh != null: + return refresh(); + case CoresFilterEvent() when filter != null: + return filter(_that.searchQuery, _that.statusFilter); + case _: + return null; + } + } +} + +/// @nodoc + +class CoresLoadEvent implements CoresEvent { + const CoresLoadEvent({this.reload = false}); + + @JsonKey() + final bool reload; + + /// Create a copy of CoresEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CoresLoadEventCopyWith get copyWith => + _$CoresLoadEventCopyWithImpl(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CoresLoadEvent && + (identical(other.reload, reload) || other.reload == reload)); + } + + @override + int get hashCode => Object.hash(runtimeType, reload); + + @override + String toString() { + return 'CoresEvent.load(reload: $reload)'; + } +} + +/// @nodoc +abstract mixin class $CoresLoadEventCopyWith<$Res> + implements $CoresEventCopyWith<$Res> { + factory $CoresLoadEventCopyWith( + CoresLoadEvent value, $Res Function(CoresLoadEvent) _then) = + _$CoresLoadEventCopyWithImpl; + @useResult + $Res call({bool reload}); +} + +/// @nodoc +class _$CoresLoadEventCopyWithImpl<$Res> + implements $CoresLoadEventCopyWith<$Res> { + _$CoresLoadEventCopyWithImpl(this._self, this._then); + + final CoresLoadEvent _self; + final $Res Function(CoresLoadEvent) _then; + + /// Create a copy of CoresEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? reload = null, + }) { + return _then(CoresLoadEvent( + reload: null == reload + ? _self.reload + : reload // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class CoresRefreshEvent implements CoresEvent { + const CoresRefreshEvent(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is CoresRefreshEvent); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'CoresEvent.refresh()'; + } +} + +/// @nodoc + +class CoresFilterEvent implements CoresEvent { + const CoresFilterEvent({required this.searchQuery, this.statusFilter}); + + final String searchQuery; + final CoreFilterStatus? statusFilter; + + /// Create a copy of CoresEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CoresFilterEventCopyWith get copyWith => + _$CoresFilterEventCopyWithImpl(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CoresFilterEvent && + (identical(other.searchQuery, searchQuery) || + other.searchQuery == searchQuery) && + (identical(other.statusFilter, statusFilter) || + other.statusFilter == statusFilter)); + } + + @override + int get hashCode => Object.hash(runtimeType, searchQuery, statusFilter); + + @override + String toString() { + return 'CoresEvent.filter(searchQuery: $searchQuery, statusFilter: $statusFilter)'; + } +} + +/// @nodoc +abstract mixin class $CoresFilterEventCopyWith<$Res> + implements $CoresEventCopyWith<$Res> { + factory $CoresFilterEventCopyWith( + CoresFilterEvent value, $Res Function(CoresFilterEvent) _then) = + _$CoresFilterEventCopyWithImpl; + @useResult + $Res call({String searchQuery, CoreFilterStatus? statusFilter}); +} + +/// @nodoc +class _$CoresFilterEventCopyWithImpl<$Res> + implements $CoresFilterEventCopyWith<$Res> { + _$CoresFilterEventCopyWithImpl(this._self, this._then); + + final CoresFilterEvent _self; + final $Res Function(CoresFilterEvent) _then; + + /// Create a copy of CoresEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? searchQuery = null, + Object? statusFilter = freezed, + }) { + return _then(CoresFilterEvent( + searchQuery: null == searchQuery + ? _self.searchQuery + : searchQuery // ignore: cast_nullable_to_non_nullable + as String, + statusFilter: freezed == statusFilter + ? _self.statusFilter + : statusFilter // ignore: cast_nullable_to_non_nullable + as CoreFilterStatus?, + )); + } +} + +/// @nodoc +mixin _$CoresState { + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is CoresState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'CoresState()'; + } +} + +/// @nodoc +class $CoresStateCopyWith<$Res> { + $CoresStateCopyWith(CoresState _, $Res Function(CoresState) __); +} + +/// Adds pattern-matching-related methods to [CoresState]. +extension CoresStatePatterns on CoresState { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap({ + TResult Function(CoresLoadingState value)? loading, + TResult Function(CoresSuccessState value)? success, + TResult Function(CoresErrorState value)? error, + TResult Function(CoresEmptyState value)? empty, + TResult Function(CoresNotFoundState value)? notFound, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case CoresLoadingState() when loading != null: + return loading(_that); + case CoresSuccessState() when success != null: + return success(_that); + case CoresErrorState() when error != null: + return error(_that); + case CoresEmptyState() when empty != null: + return empty(_that); + case CoresNotFoundState() when notFound != null: + return notFound(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map({ + required TResult Function(CoresLoadingState value) loading, + required TResult Function(CoresSuccessState value) success, + required TResult Function(CoresErrorState value) error, + required TResult Function(CoresEmptyState value) empty, + required TResult Function(CoresNotFoundState value) notFound, + }) { + final _that = this; + switch (_that) { + case CoresLoadingState(): + return loading(_that); + case CoresSuccessState(): + return success(_that); + case CoresErrorState(): + return error(_that); + case CoresEmptyState(): + return empty(_that); + case CoresNotFoundState(): + return notFound(_that); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(CoresLoadingState value)? loading, + TResult? Function(CoresSuccessState value)? success, + TResult? Function(CoresErrorState value)? error, + TResult? Function(CoresEmptyState value)? empty, + TResult? Function(CoresNotFoundState value)? notFound, + }) { + final _that = this; + switch (_that) { + case CoresLoadingState() when loading != null: + return loading(_that); + case CoresSuccessState() when success != null: + return success(_that); + case CoresErrorState() when error != null: + return error(_that); + case CoresEmptyState() when empty != null: + return empty(_that); + case CoresNotFoundState() when notFound != null: + return notFound(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? loading, + TResult Function( + List cores, + List? filteredCores, + String? searchQuery, + CoreFilterStatus? statusFilter)? + success, + TResult Function(String message)? error, + TResult Function()? empty, + TResult Function(String searchQuery)? notFound, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case CoresLoadingState() when loading != null: + return loading(); + case CoresSuccessState() when success != null: + return success(_that.cores, _that.filteredCores, _that.searchQuery, + _that.statusFilter); + case CoresErrorState() when error != null: + return error(_that.message); + case CoresEmptyState() when empty != null: + return empty(); + case CoresNotFoundState() when notFound != null: + return notFound(_that.searchQuery); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when({ + required TResult Function() loading, + required TResult Function( + List cores, + List? filteredCores, + String? searchQuery, + CoreFilterStatus? statusFilter) + success, + required TResult Function(String message) error, + required TResult Function() empty, + required TResult Function(String searchQuery) notFound, + }) { + final _that = this; + switch (_that) { + case CoresLoadingState(): + return loading(); + case CoresSuccessState(): + return success(_that.cores, _that.filteredCores, _that.searchQuery, + _that.statusFilter); + case CoresErrorState(): + return error(_that.message); + case CoresEmptyState(): + return empty(); + case CoresNotFoundState(): + return notFound(_that.searchQuery); + case _: + throw StateError('Unexpected subclass'); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? loading, + TResult? Function( + List cores, + List? filteredCores, + String? searchQuery, + CoreFilterStatus? statusFilter)? + success, + TResult? Function(String message)? error, + TResult? Function()? empty, + TResult? Function(String searchQuery)? notFound, + }) { + final _that = this; + switch (_that) { + case CoresLoadingState() when loading != null: + return loading(); + case CoresSuccessState() when success != null: + return success(_that.cores, _that.filteredCores, _that.searchQuery, + _that.statusFilter); + case CoresErrorState() when error != null: + return error(_that.message); + case CoresEmptyState() when empty != null: + return empty(); + case CoresNotFoundState() when notFound != null: + return notFound(_that.searchQuery); + case _: + return null; + } + } +} + +/// @nodoc + +class CoresLoadingState implements CoresState { + const CoresLoadingState(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is CoresLoadingState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'CoresState.loading()'; + } +} + +/// @nodoc + +class CoresSuccessState implements CoresState { + const CoresSuccessState( + {final List cores = const [], + final List? filteredCores, + this.searchQuery = '', + this.statusFilter}) + : _cores = cores, + _filteredCores = filteredCores; + + final List _cores; + @JsonKey() + List get cores { + if (_cores is EqualUnmodifiableListView) return _cores; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_cores); + } + + final List? _filteredCores; + List? get filteredCores { + final value = _filteredCores; + if (value == null) return null; + if (_filteredCores is EqualUnmodifiableListView) return _filteredCores; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @JsonKey() + final String? searchQuery; + final CoreFilterStatus? statusFilter; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CoresSuccessStateCopyWith get copyWith => + _$CoresSuccessStateCopyWithImpl(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CoresSuccessState && + const DeepCollectionEquality().equals(other._cores, _cores) && + const DeepCollectionEquality() + .equals(other._filteredCores, _filteredCores) && + (identical(other.searchQuery, searchQuery) || + other.searchQuery == searchQuery) && + (identical(other.statusFilter, statusFilter) || + other.statusFilter == statusFilter)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_cores), + const DeepCollectionEquality().hash(_filteredCores), + searchQuery, + statusFilter); + + @override + String toString() { + return 'CoresState.success(cores: $cores, filteredCores: $filteredCores, searchQuery: $searchQuery, statusFilter: $statusFilter)'; + } +} + +/// @nodoc +abstract mixin class $CoresSuccessStateCopyWith<$Res> + implements $CoresStateCopyWith<$Res> { + factory $CoresSuccessStateCopyWith( + CoresSuccessState value, $Res Function(CoresSuccessState) _then) = + _$CoresSuccessStateCopyWithImpl; + @useResult + $Res call( + {List cores, + List? filteredCores, + String? searchQuery, + CoreFilterStatus? statusFilter}); +} + +/// @nodoc +class _$CoresSuccessStateCopyWithImpl<$Res> + implements $CoresSuccessStateCopyWith<$Res> { + _$CoresSuccessStateCopyWithImpl(this._self, this._then); + + final CoresSuccessState _self; + final $Res Function(CoresSuccessState) _then; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? cores = null, + Object? filteredCores = freezed, + Object? searchQuery = freezed, + Object? statusFilter = freezed, + }) { + return _then(CoresSuccessState( + cores: null == cores + ? _self._cores + : cores // ignore: cast_nullable_to_non_nullable + as List, + filteredCores: freezed == filteredCores + ? _self._filteredCores + : filteredCores // ignore: cast_nullable_to_non_nullable + as List?, + searchQuery: freezed == searchQuery + ? _self.searchQuery + : searchQuery // ignore: cast_nullable_to_non_nullable + as String?, + statusFilter: freezed == statusFilter + ? _self.statusFilter + : statusFilter // ignore: cast_nullable_to_non_nullable + as CoreFilterStatus?, + )); + } +} + +/// @nodoc + +class CoresErrorState implements CoresState { + const CoresErrorState(this.message); + + final String message; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CoresErrorStateCopyWith get copyWith => + _$CoresErrorStateCopyWithImpl(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CoresErrorState && + (identical(other.message, message) || other.message == message)); + } + + @override + int get hashCode => Object.hash(runtimeType, message); + + @override + String toString() { + return 'CoresState.error(message: $message)'; + } +} + +/// @nodoc +abstract mixin class $CoresErrorStateCopyWith<$Res> + implements $CoresStateCopyWith<$Res> { + factory $CoresErrorStateCopyWith( + CoresErrorState value, $Res Function(CoresErrorState) _then) = + _$CoresErrorStateCopyWithImpl; + @useResult + $Res call({String message}); +} + +/// @nodoc +class _$CoresErrorStateCopyWithImpl<$Res> + implements $CoresErrorStateCopyWith<$Res> { + _$CoresErrorStateCopyWithImpl(this._self, this._then); + + final CoresErrorState _self; + final $Res Function(CoresErrorState) _then; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? message = null, + }) { + return _then(CoresErrorState( + null == message + ? _self.message + : message // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class CoresEmptyState implements CoresState { + const CoresEmptyState(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is CoresEmptyState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'CoresState.empty()'; + } +} + +/// @nodoc + +class CoresNotFoundState implements CoresState { + const CoresNotFoundState({this.searchQuery = ''}); + + @JsonKey() + final String searchQuery; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CoresNotFoundStateCopyWith get copyWith => + _$CoresNotFoundStateCopyWithImpl(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CoresNotFoundState && + (identical(other.searchQuery, searchQuery) || + other.searchQuery == searchQuery)); + } + + @override + int get hashCode => Object.hash(runtimeType, searchQuery); + + @override + String toString() { + return 'CoresState.notFound(searchQuery: $searchQuery)'; + } +} + +/// @nodoc +abstract mixin class $CoresNotFoundStateCopyWith<$Res> + implements $CoresStateCopyWith<$Res> { + factory $CoresNotFoundStateCopyWith( + CoresNotFoundState value, $Res Function(CoresNotFoundState) _then) = + _$CoresNotFoundStateCopyWithImpl; + @useResult + $Res call({String searchQuery}); +} + +/// @nodoc +class _$CoresNotFoundStateCopyWithImpl<$Res> + implements $CoresNotFoundStateCopyWith<$Res> { + _$CoresNotFoundStateCopyWithImpl(this._self, this._then); + + final CoresNotFoundState _self; + final $Res Function(CoresNotFoundState) _then; + + /// Create a copy of CoresState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? searchQuery = null, + }) { + return _then(CoresNotFoundState( + searchQuery: null == searchQuery + ? _self.searchQuery + : searchQuery // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +// dart format on diff --git a/lib/features/cores/bloc/cores_event.dart b/lib/features/cores/bloc/cores_event.dart new file mode 100644 index 0000000..d3ea832 --- /dev/null +++ b/lib/features/cores/bloc/cores_event.dart @@ -0,0 +1,15 @@ +part of 'cores_bloc.dart'; + +@Freezed() +abstract class CoresEvent with _$CoresEvent { + const factory CoresEvent.load({ + @Default(false) bool reload, + }) = CoresLoadEvent; + + const factory CoresEvent.refresh() = CoresRefreshEvent; + + const factory CoresEvent.filter({ + required String searchQuery, + final CoreFilterStatus? statusFilter, + }) = CoresFilterEvent; +} diff --git a/lib/features/cores/bloc/cores_state.dart b/lib/features/cores/bloc/cores_state.dart new file mode 100644 index 0000000..c5b4ba6 --- /dev/null +++ b/lib/features/cores/bloc/cores_state.dart @@ -0,0 +1,21 @@ +part of 'cores_bloc.dart'; + +@Freezed() +abstract class CoresState with _$CoresState { + const factory CoresState.loading() = CoresLoadingState; + + const factory CoresState.success({ + @Default([]) List cores, + List? filteredCores, + @Default('') String? searchQuery, + CoreFilterStatus? statusFilter, + }) = CoresSuccessState; + + const factory CoresState.error(String message) = CoresErrorState; + + const factory CoresState.empty() = CoresEmptyState; + + const factory CoresState.notFound({ + @Default('') String searchQuery, + }) = CoresNotFoundState; +} diff --git a/lib/features/cores/cores_screen.dart b/lib/features/cores/cores_screen.dart new file mode 100644 index 0000000..5a33011 --- /dev/null +++ b/lib/features/cores/cores_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/core_item_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/core_loading_content.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_empty_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_error_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_not_found_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_search_filter_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; + +class CoresScreen extends StatelessWidget { + const CoresScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: Text(S.of(context).spaceXCoresTitle), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () => print, + ), + ], + ), + + // Search and Filter Section + CoresSearchFilterWidget( + onClear: (selectedStatus) { + context.read().add( + CoresFilterEvent( + searchQuery: '', + statusFilter: selectedStatus, + ), + ); + }, + onChanged: (String value, selectedStatus) { + context.read().add( + CoresFilterEvent( + searchQuery: value, + statusFilter: selectedStatus, + ), + ); + }, + ), + + const CoresBlocContent(), + ], + ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + if (state is CoresSuccessState) { + return FloatingActionButton( + onPressed: () { + context.read().add(const CoresRefreshEvent()); + }, + child: const Icon(Icons.refresh), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} + +class CoresBlocContent extends StatelessWidget { + const CoresBlocContent({super.key}); + + @override + Widget build(BuildContext context) => BlocBuilder( + builder: (context, state) { + switch (state) { + case CoresLoadingState _: + return const CoreLoadingContent(); + case CoresSuccessState _: + return CoresListWidget( + filteredCores: state.filteredCores ?? [], + ); + + case CoresErrorState _: + return CoresErrorWidget( + errorMessage: state.message, + ); + + case CoresEmptyState _: + return const CoresEmptyWidget(); + + case CoresNotFoundState _: + return CoresNotFoundWidget(searchQuery: state.searchQuery); + } + + return const SizedBox.shrink(); + }, + ); +} + +class CoresListWidget extends StatelessWidget { + const CoresListWidget({super.key, required this.filteredCores}); + + final List filteredCores; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final core = filteredCores[index]; + return CoreItemWidget( + key: Key('${core.coreSerial}$index'), + core: core, + ); + }, + childCount: filteredCores.length, + ), + ), + ); + } +} diff --git a/lib/features/cores/model/core_filter_status.dart b/lib/features/cores/model/core_filter_status.dart new file mode 100644 index 0000000..15a8c29 --- /dev/null +++ b/lib/features/cores/model/core_filter_status.dart @@ -0,0 +1,7 @@ +enum CoreFilterStatus { + all, + active, + lost, + inactive, + unknown, +} diff --git a/lib/features/cores/utils/core_utils.dart b/lib/features/cores/utils/core_utils.dart new file mode 100644 index 0000000..ae779f7 --- /dev/null +++ b/lib/features/cores/utils/core_utils.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:intl/intl.dart'; + +Color getStatusColor(BuildContext context, String? status) { + if (status == null) return Colors.blue; + + final loc = S.of(context); + + final statusMap = { + loc.core_status_active.toLowerCase(): Colors.green, + loc.core_status_lost.toLowerCase(): Colors.red, + loc.core_status_inactive.toLowerCase(): Colors.orange, + loc.core_status_unknown.toLowerCase(): Colors.grey, + }; + + return statusMap[status.toLowerCase()] ?? Colors.blue; +} + +String formatFirstLaunch(BuildContext context, String? isoDate) { + if (isoDate == null || isoDate.isEmpty) return ''; + final date = DateTime.parse(isoDate); + final loc = S.of(context); + final formatted = DateFormat.yMMMd().format(date); + return '${loc.firstLaunch}: $formatted'; +} diff --git a/lib/features/cores/utils/cores_ext.dart b/lib/features/cores/utils/cores_ext.dart new file mode 100644 index 0000000..2ed21f8 --- /dev/null +++ b/lib/features/cores/utils/cores_ext.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +extension CoreFilterStatusX on CoreFilterStatus { + String title(BuildContext context) { + switch (this) { + case CoreFilterStatus.all: + return S.of(context).core_filter_status_all; + case CoreFilterStatus.active: + return S.of(context).core_filter_status_active; + case CoreFilterStatus.lost: + return S.of(context).core_filter_status_lost; + case CoreFilterStatus.inactive: + return S.of(context).core_filter_status_inactive; + case CoreFilterStatus.unknown: + return S.of(context).core_filter_status_unknown; + } + } +} + +extension CoreFilterStatusStringX on String? { + CoreFilterStatus toStatus() { + switch (this?.toLowerCase()) { + case 'active': + return CoreFilterStatus.active; + case 'lost': + return CoreFilterStatus.lost; + case 'inactive': + return CoreFilterStatus.inactive; + case 'unknown': + return CoreFilterStatus.unknown; + case 'all': + case null: + return CoreFilterStatus.unknown; + default: + return CoreFilterStatus.unknown; + } + } +} diff --git a/lib/features/cores/widget/core_item_widget.dart b/lib/features/cores/widget/core_item_widget.dart new file mode 100644 index 0000000..3f77d7d --- /dev/null +++ b/lib/features/cores/widget/core_item_widget.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/core_utils.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; + +class CoreItemWidget extends StatelessWidget { + const CoreItemWidget({super.key, required this.core}); + + final CoreResource core; + + @override + Widget build(BuildContext context) { + final loc = S.of(context); + return Card( + elevation: 0, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () {}, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + core.coreSerial ?? loc.na, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 8), + if (core.block != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + loc.blockLabel(core.block!), + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + ), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: getStatusColor(context, core.status) + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + core.status ?? loc.unknown, + style: TextStyle( + fontSize: 12, + color: getStatusColor(context, core.status), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + formatFirstLaunch(context, core.originalLaunch), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.rocket_launch, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + loc.missions(core.missions?.length ?? 0), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: 16), + Icon( + Icons.refresh, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + loc.reuses(core.reuseCount ?? 0), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (core.missions != null && core.missions!.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: core.missions?.take(2).map((mission) { + return Chip( + label: Text( + mission.name ?? loc.na, + style: const TextStyle(fontSize: 11), + ), + visualDensity: VisualDensity.compact, + ); + }).toList() ?? + [], + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/cores/widget/core_loading_content.dart b/lib/features/cores/widget/core_loading_content.dart new file mode 100644 index 0000000..2e1d868 --- /dev/null +++ b/lib/features/cores/widget/core_loading_content.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/widgets/loading_content.dart'; + +class CoreLoadingContent extends StatelessWidget { + const CoreLoadingContent({super.key}); + + @override + Widget build(BuildContext context) { + return const SliverFillRemaining(child: LoadingContent()); + } +} diff --git a/lib/features/cores/widget/cores_empty_widget.dart b/lib/features/cores/widget/cores_empty_widget.dart new file mode 100644 index 0000000..4de4450 --- /dev/null +++ b/lib/features/cores/widget/cores_empty_widget.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class CoresEmptyWidget extends StatelessWidget { + const CoresEmptyWidget({super.key}); + + @override + Widget build(BuildContext context) { + final loc = S.of(context); + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.inbox_outlined, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + loc.emptyList, + style: const TextStyle(fontSize: 18), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/cores/widget/cores_error_widget.dart b/lib/features/cores/widget/cores_error_widget.dart new file mode 100644 index 0000000..8377156 --- /dev/null +++ b/lib/features/cores/widget/cores_error_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class CoresErrorWidget extends StatelessWidget { + const CoresErrorWidget({super.key, required this.errorMessage}); + + final String errorMessage; + + @override + Widget build(BuildContext context) { + final loc = S.of(context); + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + loc.errorLoadingCores, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + errorMessage, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + context.read().add(const CoresRefreshEvent()); + }, + icon: const Icon(Icons.refresh), + label: Text(loc.retry), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/cores/widget/cores_not_found_widget.dart b/lib/features/cores/widget/cores_not_found_widget.dart new file mode 100644 index 0000000..4faf407 --- /dev/null +++ b/lib/features/cores/widget/cores_not_found_widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class CoresNotFoundWidget extends StatelessWidget { + const CoresNotFoundWidget({super.key, required this.searchQuery}); + + final String searchQuery; + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.inbox_outlined, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + S.of(context).noCoresFound(searchQuery), + style: const TextStyle(fontSize: 18), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/cores/widget/cores_search_filter_widget.dart b/lib/features/cores/widget/cores_search_filter_widget.dart new file mode 100644 index 0000000..7faec55 --- /dev/null +++ b/lib/features/cores/widget/cores_search_filter_widget.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/cores_ext.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class CoresSearchFilterWidget extends StatefulWidget { + const CoresSearchFilterWidget({ + super.key, + required this.onClear, + required this.onChanged, + }); + + final void Function(CoreFilterStatus? selectedStatus) onClear; + final void Function(String searchQuery, CoreFilterStatus? selectedStatus) + onChanged; + + @override + State createState() => + _CoresSearchFilterWidgetState(); +} + +class _CoresSearchFilterWidgetState extends State { + final TextEditingController _searchController = TextEditingController(); + CoreFilterStatus? _selectedStatus; + + @override + Widget build(BuildContext context) { + final statusOptions = CoreFilterStatus.values; + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + SearchBar( + controller: _searchController, + hintText: S.of(context).core_filter_search_hint, + leading: const Icon(Icons.search), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 16.0), + ), + trailing: [ + if (_searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + widget.onClear.call(_selectedStatus); + setState(() {}); + }, + ), + ], + onChanged: (value) { + widget.onChanged.call(value, _selectedStatus); + setState(() {}); + }, + ), + const SizedBox(height: 16), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: statusOptions.map((status) { + final isSelected = (_selectedStatus == null && + status == CoreFilterStatus.all) || + _selectedStatus == status; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + key: Key( + 'core_status_filter_${status.name.toLowerCase()}', + ), + label: Text(status.title(context)), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedStatus = selected ? status : null; + if (status == CoreFilterStatus.all) { + _selectedStatus = null; + } + }); + widget.onChanged.call( + _searchController.text, + _selectedStatus, + ); + }, + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/main/navigation/destinations.dart b/lib/features/main/navigation/destinations.dart index 9ae1814..2cf889e 100644 --- a/lib/features/main/navigation/destinations.dart +++ b/lib/features/main/navigation/destinations.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/cores_screen.dart'; import 'package:flutter_bloc_app_template/features/launches/launches_screen.dart'; import 'package:flutter_bloc_app_template/features/rockets/rockets_screen.dart'; import 'package:flutter_bloc_app_template/index.dart'; @@ -19,6 +20,13 @@ List getDestinations(BuildContext context) { screen: const RocketsScreen(), key: const Key('rockets_screen'), ), + NavDestination( + label: context.coresLabel, + icon: const Icon(Icons.memory_outlined), + selectedIcon: const Icon(Icons.memory), + screen: const CoresScreen(), + key: const Key('cores_screen'), + ), NavDestination( label: context.settingsTitle, icon: const Icon(Icons.settings_outlined), diff --git a/lib/generated/colors.gen.dart b/lib/generated/colors.gen.dart index b986a7b..eb8fd93 100644 --- a/lib/generated/colors.gen.dart +++ b/lib/generated/colors.gen.dart @@ -4,12 +4,12 @@ /// FlutterGen /// ***************************************************** -import 'package:flutter/material.dart'; // coverage:ignore-file // ignore_for_file: type=lint // ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import import 'package:flutter/painting.dart'; +import 'package:flutter/material.dart'; class ColorName { ColorName._(); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index cbdfb5e..64df0a1 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -20,6 +20,8 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'en'; + static String m9(blockNumber) => "Block ${blockNumber}"; + static String m0(days) => "In ${days} days"; static String m1(days) => "${days} days ago"; @@ -34,6 +36,12 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(mission) => "Mission: ${mission}"; + static String m10(count) => "${count} missions"; + + static String m11(query) => "No cores found for \"${query}\""; + + static String m12(count) => "${count} reuses"; + static String m7(rocketName, rocketType) => "Rocket: ${rocketName} (${rocketType})"; @@ -61,11 +69,29 @@ class MessageLookup extends MessageLookupByLibrary { ), "appearanceTitle": MessageLookupByLibrary.simpleMessage("Appearance"), "article": MessageLookupByLibrary.simpleMessage("Article"), + "blockLabel": m9, "borderRadiusButtonTitle": MessageLookupByLibrary.simpleMessage( "BorderRadius", ), "borderSideButtonTitle": MessageLookupByLibrary.simpleMessage("BorderSide"), "coreSerial": MessageLookupByLibrary.simpleMessage("Core Serial"), + "core_filter_search_hint": MessageLookupByLibrary.simpleMessage( + "Search cores or missions...", + ), + "core_filter_status_active": MessageLookupByLibrary.simpleMessage("Active"), + "core_filter_status_all": MessageLookupByLibrary.simpleMessage("All"), + "core_filter_status_inactive": MessageLookupByLibrary.simpleMessage( + "Inactive", + ), + "core_filter_status_lost": MessageLookupByLibrary.simpleMessage("Lost"), + "core_filter_status_unknown": MessageLookupByLibrary.simpleMessage( + "Unknown", + ), + "core_status_active": MessageLookupByLibrary.simpleMessage("active"), + "core_status_inactive": MessageLookupByLibrary.simpleMessage("inactive"), + "core_status_lost": MessageLookupByLibrary.simpleMessage("lost"), + "core_status_unknown": MessageLookupByLibrary.simpleMessage("unknown"), + "coresLabel": MessageLookupByLibrary.simpleMessage("Cores"), "currentSpeed": MessageLookupByLibrary.simpleMessage("Current Speed"), "customers": MessageLookupByLibrary.simpleMessage("Customers"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage("Dark Gold"), @@ -105,9 +131,13 @@ class MessageLookup extends MessageLookupByLibrary { "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Enabled"), "engineDetails": MessageLookupByLibrary.simpleMessage("Engine Details"), "error": MessageLookupByLibrary.simpleMessage("Error"), + "errorLoadingCores": MessageLookupByLibrary.simpleMessage( + "Error loading cores", + ), "experimentalThemeTitle": MessageLookupByLibrary.simpleMessage( "Experimental Theme", ), + "firstLaunch": MessageLookupByLibrary.simpleMessage("First Launch"), "firstStage": MessageLookupByLibrary.simpleMessage("🚀 First Stage"), "flight": MessageLookupByLibrary.simpleMessage("Flight"), "flightNumber": m2, @@ -155,8 +185,11 @@ class MessageLookup extends MessageLookupByLibrary { ), "missionTimeline": MessageLookupByLibrary.simpleMessage("Mission Timeline"), "missionTitle": m6, + "missions": m10, + "na": MessageLookupByLibrary.simpleMessage("N/A"), "nationality": MessageLookupByLibrary.simpleMessage("Nationality"), "newsScreen": MessageLookupByLibrary.simpleMessage("News"), + "noCoresFound": m11, "noDetails": MessageLookupByLibrary.simpleMessage("No details available"), "notAvailable": MessageLookupByLibrary.simpleMessage("N/A"), "numberLabel": MessageLookupByLibrary.simpleMessage("Number"), @@ -182,7 +215,9 @@ class MessageLookup extends MessageLookupByLibrary { "recoveryShips": MessageLookupByLibrary.simpleMessage("Recovery Ships"), "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), "retiredStatus": MessageLookupByLibrary.simpleMessage("Retired"), + "retry": MessageLookupByLibrary.simpleMessage("Retry"), "reused": MessageLookupByLibrary.simpleMessage("Reused"), + "reuses": m12, "roadsterDescription": MessageLookupByLibrary.simpleMessage( "Elon Musk\'s Tesla Roadster", ), @@ -198,6 +233,9 @@ class MessageLookup extends MessageLookupByLibrary { "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Semi-major axis"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Settings"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("Site ID:"), + "spaceXCoresTitle": MessageLookupByLibrary.simpleMessage( + "SpaceX Falcon Cores", + ), "specifications": MessageLookupByLibrary.simpleMessage("Specifications"), "stagesLabel": MessageLookupByLibrary.simpleMessage("Stages"), "staticFireTest": MessageLookupByLibrary.simpleMessage("Static Fire Test"), @@ -219,6 +257,7 @@ class MessageLookup extends MessageLookupByLibrary { "typeLabel": MessageLookupByLibrary.simpleMessage("Type"), "unitDays": MessageLookupByLibrary.simpleMessage("days"), "unitKph": MessageLookupByLibrary.simpleMessage("km/h"), + "unknown": MessageLookupByLibrary.simpleMessage("Unknown"), "versionLabel": MessageLookupByLibrary.simpleMessage("Version"), "watchVideo": MessageLookupByLibrary.simpleMessage("Watch Video"), "wikipedia": MessageLookupByLibrary.simpleMessage("Wikipedia"), diff --git a/lib/generated/intl/messages_pt.dart b/lib/generated/intl/messages_pt.dart index cf6af18..ce71b6e 100644 --- a/lib/generated/intl/messages_pt.dart +++ b/lib/generated/intl/messages_pt.dart @@ -20,6 +20,8 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'pt'; + static String m9(blockNumber) => "Bloco ${blockNumber}"; + static String m0(days) => "Em ${days} dias"; static String m1(days) => "Há ${days} dias"; @@ -34,6 +36,12 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(mission) => "Missão: ${mission}"; + static String m10(count) => "${count} missões"; + + static String m11(query) => "Nenhum núcleo encontrado para \"${query}\""; + + static String m12(count) => "${count} reutilizações"; + static String m7(rocketName, rocketType) => "Foguete: ${rocketName} (${rocketType})"; @@ -61,6 +69,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "appearanceTitle": MessageLookupByLibrary.simpleMessage("Aparência"), "article": MessageLookupByLibrary.simpleMessage("Artigo"), + "blockLabel": m9, "borderRadiusButtonTitle": MessageLookupByLibrary.simpleMessage( "Raio da Borda", ), @@ -70,6 +79,23 @@ class MessageLookup extends MessageLookupByLibrary { "coreSerial": MessageLookupByLibrary.simpleMessage( "Número de Série do Núcleo", ), + "core_filter_search_hint": MessageLookupByLibrary.simpleMessage( + "Pesquisar núcleos ou missões...", + ), + "core_filter_status_active": MessageLookupByLibrary.simpleMessage("Ativo"), + "core_filter_status_all": MessageLookupByLibrary.simpleMessage("Todos"), + "core_filter_status_inactive": MessageLookupByLibrary.simpleMessage( + "Inativo", + ), + "core_filter_status_lost": MessageLookupByLibrary.simpleMessage("Perdido"), + "core_filter_status_unknown": MessageLookupByLibrary.simpleMessage( + "Desconhecido", + ), + "core_status_active": MessageLookupByLibrary.simpleMessage("ativo"), + "core_status_inactive": MessageLookupByLibrary.simpleMessage("inativo"), + "core_status_lost": MessageLookupByLibrary.simpleMessage("perdido"), + "core_status_unknown": MessageLookupByLibrary.simpleMessage("desconhecido"), + "coresLabel": MessageLookupByLibrary.simpleMessage("Núcleos"), "currentSpeed": MessageLookupByLibrary.simpleMessage("Velocidade Atual"), "customers": MessageLookupByLibrary.simpleMessage("Clientes"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage( @@ -113,6 +139,10 @@ class MessageLookup extends MessageLookupByLibrary { "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Ativado"), "engineDetails": MessageLookupByLibrary.simpleMessage("Detalhes do Motor"), "error": MessageLookupByLibrary.simpleMessage("Erro"), + "errorLoadingCores": MessageLookupByLibrary.simpleMessage( + "Erro ao carregar núcleos", + ), + "firstLaunch": MessageLookupByLibrary.simpleMessage("Primeiro lançamento"), "firstStage": MessageLookupByLibrary.simpleMessage("🚀 Primeiro Estágio"), "flight": MessageLookupByLibrary.simpleMessage("Voo"), "flightNumber": m2, @@ -176,8 +206,11 @@ class MessageLookup extends MessageLookupByLibrary { "Cronograma da Missão", ), "missionTitle": m6, + "missions": m10, + "na": MessageLookupByLibrary.simpleMessage("N/D"), "nationality": MessageLookupByLibrary.simpleMessage("Nacionalidade"), "newsScreen": MessageLookupByLibrary.simpleMessage("Notícias"), + "noCoresFound": m11, "noDetails": MessageLookupByLibrary.simpleMessage( "Nenhum detalhe disponível", ), @@ -209,7 +242,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), "retiredStatus": MessageLookupByLibrary.simpleMessage("Aposentada"), + "retry": MessageLookupByLibrary.simpleMessage("Tentar novamente"), "reused": MessageLookupByLibrary.simpleMessage("Reutilizado"), + "reuses": m12, "roadsterDescription": MessageLookupByLibrary.simpleMessage( "Tesla Roadster de Elon Musk", ), @@ -227,6 +262,9 @@ class MessageLookup extends MessageLookupByLibrary { "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Eixo semi-maior"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Configurações"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("ID do Local:"), + "spaceXCoresTitle": MessageLookupByLibrary.simpleMessage( + "Núcleos Falcon da SpaceX", + ), "specifications": MessageLookupByLibrary.simpleMessage("Especificações"), "stagesLabel": MessageLookupByLibrary.simpleMessage("Estágios"), "staticFireTest": MessageLookupByLibrary.simpleMessage( @@ -250,6 +288,7 @@ class MessageLookup extends MessageLookupByLibrary { "typeLabel": MessageLookupByLibrary.simpleMessage("Tipo"), "unitDays": MessageLookupByLibrary.simpleMessage("dias"), "unitKph": MessageLookupByLibrary.simpleMessage("km/h"), + "unknown": MessageLookupByLibrary.simpleMessage("Desconhecido"), "versionLabel": MessageLookupByLibrary.simpleMessage("Versão"), "watchVideo": MessageLookupByLibrary.simpleMessage("Assistir Vídeo"), "wikipedia": MessageLookupByLibrary.simpleMessage("Wikipédia"), diff --git a/lib/generated/intl/messages_uk.dart b/lib/generated/intl/messages_uk.dart index baa8068..26ffd35 100644 --- a/lib/generated/intl/messages_uk.dart +++ b/lib/generated/intl/messages_uk.dart @@ -20,6 +20,8 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'uk'; + static String m9(blockNumber) => "Блок ${blockNumber}"; + static String m0(days) => "Через ${days} днів"; static String m1(days) => "${days} днів тому"; @@ -34,6 +36,12 @@ class MessageLookup extends MessageLookupByLibrary { static String m6(mission) => "Місія: ${mission}"; + static String m10(count) => "${count} місій"; + + static String m11(query) => "Ядра за запитом \"${query}\" не знайдено"; + + static String m12(count) => "${count} повторів"; + static String m7(rocketName, rocketType) => "Ракета: ${rocketName} (${rocketType})"; @@ -59,6 +67,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "appearanceTitle": MessageLookupByLibrary.simpleMessage("Зовнішній вигляд"), "article": MessageLookupByLibrary.simpleMessage("Стаття"), + "blockLabel": m9, "borderRadiusButtonTitle": MessageLookupByLibrary.simpleMessage( "Радіус кордону", ), @@ -66,6 +75,27 @@ class MessageLookup extends MessageLookupByLibrary { "Кордона сторона", ), "coreSerial": MessageLookupByLibrary.simpleMessage("Серійний номер ядра"), + "core_filter_search_hint": MessageLookupByLibrary.simpleMessage( + "Пошук ядер або місій...", + ), + "core_filter_status_active": MessageLookupByLibrary.simpleMessage( + "Активний", + ), + "core_filter_status_all": MessageLookupByLibrary.simpleMessage("Усі"), + "core_filter_status_inactive": MessageLookupByLibrary.simpleMessage( + "Неактивний", + ), + "core_filter_status_lost": MessageLookupByLibrary.simpleMessage( + "Втрачений", + ), + "core_filter_status_unknown": MessageLookupByLibrary.simpleMessage( + "Невідомо", + ), + "core_status_active": MessageLookupByLibrary.simpleMessage("активний"), + "core_status_inactive": MessageLookupByLibrary.simpleMessage("неактивний"), + "core_status_lost": MessageLookupByLibrary.simpleMessage("втрачений"), + "core_status_unknown": MessageLookupByLibrary.simpleMessage("невідомий"), + "coresLabel": MessageLookupByLibrary.simpleMessage("Ядра"), "currentSpeed": MessageLookupByLibrary.simpleMessage("Поточна швидкість"), "customers": MessageLookupByLibrary.simpleMessage("Клієнти"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage("Темне золото"), @@ -105,9 +135,13 @@ class MessageLookup extends MessageLookupByLibrary { "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Увімкнено"), "engineDetails": MessageLookupByLibrary.simpleMessage("Деталі двигуна"), "error": MessageLookupByLibrary.simpleMessage("Помилка"), + "errorLoadingCores": MessageLookupByLibrary.simpleMessage( + "Помилка завантаження ядер", + ), "experimentalThemeTitle": MessageLookupByLibrary.simpleMessage( "Експериментальна тема", ), + "firstLaunch": MessageLookupByLibrary.simpleMessage("Перший запуск"), "firstStage": MessageLookupByLibrary.simpleMessage("🚀 Перша ступінь"), "flight": MessageLookupByLibrary.simpleMessage("Політ"), "flightNumber": m2, @@ -159,8 +193,11 @@ class MessageLookup extends MessageLookupByLibrary { "missionSuccessful": MessageLookupByLibrary.simpleMessage("Місія успішна"), "missionTimeline": MessageLookupByLibrary.simpleMessage("Хронологія місії"), "missionTitle": m6, + "missions": m10, + "na": MessageLookupByLibrary.simpleMessage("Н/Д"), "nationality": MessageLookupByLibrary.simpleMessage("Національність"), "newsScreen": MessageLookupByLibrary.simpleMessage("Новини"), + "noCoresFound": m11, "noDetails": MessageLookupByLibrary.simpleMessage("Деталі відсутні"), "notAvailable": MessageLookupByLibrary.simpleMessage("Н/Д"), "numberLabel": MessageLookupByLibrary.simpleMessage("Кількість"), @@ -192,7 +229,9 @@ class MessageLookup extends MessageLookupByLibrary { "retiredStatus": MessageLookupByLibrary.simpleMessage( "Знято з експлуатації", ), + "retry": MessageLookupByLibrary.simpleMessage("Повторити"), "reused": MessageLookupByLibrary.simpleMessage("Повторне використання"), + "reuses": m12, "roadsterDescription": MessageLookupByLibrary.simpleMessage( "Tesla Roadster Ілона Маска", ), @@ -208,6 +247,9 @@ class MessageLookup extends MessageLookupByLibrary { "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Велика піввісь"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Налаштування"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("ID сайту:"), + "spaceXCoresTitle": MessageLookupByLibrary.simpleMessage( + "Супутникові ядра Falcon від SpaceX", + ), "specifications": MessageLookupByLibrary.simpleMessage( "Технічні характеристики", ), @@ -231,6 +273,7 @@ class MessageLookup extends MessageLookupByLibrary { "typeLabel": MessageLookupByLibrary.simpleMessage("Тип"), "unitDays": MessageLookupByLibrary.simpleMessage("днів"), "unitKph": MessageLookupByLibrary.simpleMessage("км/год"), + "unknown": MessageLookupByLibrary.simpleMessage("Невідомо"), "versionLabel": MessageLookupByLibrary.simpleMessage("Версія"), "watchVideo": MessageLookupByLibrary.simpleMessage("Дивитися відео"), "wikipedia": MessageLookupByLibrary.simpleMessage("Вікіпедія"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 58718be..6977404 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -1263,6 +1263,191 @@ class S { String get longitude { return Intl.message('Longitude', name: 'longitude', desc: '', args: []); } + + /// `active` + String get core_status_active { + return Intl.message( + 'active', + name: 'core_status_active', + desc: '', + args: [], + ); + } + + /// `lost` + String get core_status_lost { + return Intl.message('lost', name: 'core_status_lost', desc: '', args: []); + } + + /// `inactive` + String get core_status_inactive { + return Intl.message( + 'inactive', + name: 'core_status_inactive', + desc: '', + args: [], + ); + } + + /// `unknown` + String get core_status_unknown { + return Intl.message( + 'unknown', + name: 'core_status_unknown', + desc: '', + args: [], + ); + } + + /// `Error loading cores` + String get errorLoadingCores { + return Intl.message( + 'Error loading cores', + name: 'errorLoadingCores', + desc: '', + args: [], + ); + } + + /// `Retry` + String get retry { + return Intl.message('Retry', name: 'retry', desc: '', args: []); + } + + /// `First Launch` + String get firstLaunch { + return Intl.message( + 'First Launch', + name: 'firstLaunch', + desc: '', + args: [], + ); + } + + /// `{count} missions` + String missions(Object count) { + return Intl.message( + '$count missions', + name: 'missions', + desc: '', + args: [count], + ); + } + + /// `{count} reuses` + String reuses(Object count) { + return Intl.message( + '$count reuses', + name: 'reuses', + desc: '', + args: [count], + ); + } + + /// `Unknown` + String get unknown { + return Intl.message('Unknown', name: 'unknown', desc: '', args: []); + } + + /// `N/A` + String get na { + return Intl.message('N/A', name: 'na', desc: '', args: []); + } + + /// `All` + String get core_filter_status_all { + return Intl.message( + 'All', + name: 'core_filter_status_all', + desc: '', + args: [], + ); + } + + /// `Active` + String get core_filter_status_active { + return Intl.message( + 'Active', + name: 'core_filter_status_active', + desc: '', + args: [], + ); + } + + /// `Lost` + String get core_filter_status_lost { + return Intl.message( + 'Lost', + name: 'core_filter_status_lost', + desc: '', + args: [], + ); + } + + /// `Inactive` + String get core_filter_status_inactive { + return Intl.message( + 'Inactive', + name: 'core_filter_status_inactive', + desc: '', + args: [], + ); + } + + /// `Unknown` + String get core_filter_status_unknown { + return Intl.message( + 'Unknown', + name: 'core_filter_status_unknown', + desc: '', + args: [], + ); + } + + /// `Search cores or missions...` + String get core_filter_search_hint { + return Intl.message( + 'Search cores or missions...', + name: 'core_filter_search_hint', + desc: '', + args: [], + ); + } + + /// `No cores found for "{query}"` + String noCoresFound(Object query) { + return Intl.message( + 'No cores found for "$query"', + name: 'noCoresFound', + desc: '', + args: [query], + ); + } + + /// `Block {blockNumber}` + String blockLabel(Object blockNumber) { + return Intl.message( + 'Block $blockNumber', + name: 'blockLabel', + desc: '', + args: [blockNumber], + ); + } + + /// `SpaceX Falcon Cores` + String get spaceXCoresTitle { + return Intl.message( + 'SpaceX Falcon Cores', + name: 'spaceXCoresTitle', + desc: '', + args: [], + ); + } + + /// `Cores` + String get coresLabel { + return Intl.message('Cores', name: 'coresLabel', desc: '', args: []); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 65315b0..0af427c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -905,6 +905,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Longitude'** String get longitude; + + /// No description provided for @core_status_active. + /// + /// In en, this message translates to: + /// **'active'** + String get core_status_active; + + /// No description provided for @core_status_lost. + /// + /// In en, this message translates to: + /// **'lost'** + String get core_status_lost; + + /// No description provided for @core_status_inactive. + /// + /// In en, this message translates to: + /// **'inactive'** + String get core_status_inactive; + + /// No description provided for @core_status_unknown. + /// + /// In en, this message translates to: + /// **'unknown'** + String get core_status_unknown; + + /// No description provided for @errorLoadingCores. + /// + /// In en, this message translates to: + /// **'Error loading cores'** + String get errorLoadingCores; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @firstLaunch. + /// + /// In en, this message translates to: + /// **'First Launch'** + String get firstLaunch; + + /// No description provided for @missions. + /// + /// In en, this message translates to: + /// **'{count} missions'** + String missions(Object count); + + /// No description provided for @reuses. + /// + /// In en, this message translates to: + /// **'{count} reuses'** + String reuses(Object count); + + /// No description provided for @unknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get unknown; + + /// No description provided for @na. + /// + /// In en, this message translates to: + /// **'N/A'** + String get na; + + /// No description provided for @core_filter_status_all. + /// + /// In en, this message translates to: + /// **'All'** + String get core_filter_status_all; + + /// No description provided for @core_filter_status_active. + /// + /// In en, this message translates to: + /// **'Active'** + String get core_filter_status_active; + + /// No description provided for @core_filter_status_lost. + /// + /// In en, this message translates to: + /// **'Lost'** + String get core_filter_status_lost; + + /// No description provided for @core_filter_status_inactive. + /// + /// In en, this message translates to: + /// **'Inactive'** + String get core_filter_status_inactive; + + /// No description provided for @core_filter_status_unknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get core_filter_status_unknown; + + /// No description provided for @core_filter_search_hint. + /// + /// In en, this message translates to: + /// **'Search cores or missions...'** + String get core_filter_search_hint; + + /// No description provided for @noCoresFound. + /// + /// In en, this message translates to: + /// **'No cores found for \"{query}\"'** + String noCoresFound(Object query); + + /// No description provided for @blockLabel. + /// + /// In en, this message translates to: + /// **'Block {blockNumber}'** + String blockLabel(Object blockNumber); + + /// No description provided for @spaceXCoresTitle. + /// + /// In en, this message translates to: + /// **'SpaceX Falcon Cores'** + String get spaceXCoresTitle; + + /// No description provided for @coresLabel. + /// + /// In en, this message translates to: + /// **'Cores'** + String get coresLabel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f9986f4..792154b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -429,4 +429,75 @@ class AppLocalizationsDe extends AppLocalizations { @override String get longitude => 'Längengrad'; + + @override + String get core_status_active => 'active'; + + @override + String get core_status_lost => 'lost'; + + @override + String get core_status_inactive => 'inactive'; + + @override + String get core_status_unknown => 'unknown'; + + @override + String get errorLoadingCores => 'Error loading cores'; + + @override + String get retry => 'Retry'; + + @override + String get firstLaunch => 'First Launch'; + + @override + String missions(Object count) { + return '$count missions'; + } + + @override + String reuses(Object count) { + return '$count reuses'; + } + + @override + String get unknown => 'Unknown'; + + @override + String get na => 'N/A'; + + @override + String get core_filter_status_all => 'All'; + + @override + String get core_filter_status_active => 'Active'; + + @override + String get core_filter_status_lost => 'Lost'; + + @override + String get core_filter_status_inactive => 'Inactive'; + + @override + String get core_filter_status_unknown => 'Unknown'; + + @override + String get core_filter_search_hint => 'Search cores or missions...'; + + @override + String noCoresFound(Object query) { + return 'No cores found for \"$query\"'; + } + + @override + String blockLabel(Object blockNumber) { + return 'Block $blockNumber'; + } + + @override + String get spaceXCoresTitle => 'SpaceX Falcon Cores'; + + @override + String get coresLabel => 'Cores'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e4758d8..f2cd6ba 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -429,4 +429,75 @@ class AppLocalizationsEn extends AppLocalizations { @override String get longitude => 'Longitude'; + + @override + String get core_status_active => 'active'; + + @override + String get core_status_lost => 'lost'; + + @override + String get core_status_inactive => 'inactive'; + + @override + String get core_status_unknown => 'unknown'; + + @override + String get errorLoadingCores => 'Error loading cores'; + + @override + String get retry => 'Retry'; + + @override + String get firstLaunch => 'First Launch'; + + @override + String missions(Object count) { + return '$count missions'; + } + + @override + String reuses(Object count) { + return '$count reuses'; + } + + @override + String get unknown => 'Unknown'; + + @override + String get na => 'N/A'; + + @override + String get core_filter_status_all => 'All'; + + @override + String get core_filter_status_active => 'Active'; + + @override + String get core_filter_status_lost => 'Lost'; + + @override + String get core_filter_status_inactive => 'Inactive'; + + @override + String get core_filter_status_unknown => 'Unknown'; + + @override + String get core_filter_search_hint => 'Search cores or missions...'; + + @override + String noCoresFound(Object query) { + return 'No cores found for \"$query\"'; + } + + @override + String blockLabel(Object blockNumber) { + return 'Block $blockNumber'; + } + + @override + String get spaceXCoresTitle => 'SpaceX Falcon Cores'; + + @override + String get coresLabel => 'Cores'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ebe59e4..2b9bea6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -429,4 +429,75 @@ class AppLocalizationsPt extends AppLocalizations { @override String get longitude => 'Longitude'; + + @override + String get core_status_active => 'ativo'; + + @override + String get core_status_lost => 'perdido'; + + @override + String get core_status_inactive => 'inativo'; + + @override + String get core_status_unknown => 'desconhecido'; + + @override + String get errorLoadingCores => 'Erro ao carregar núcleos'; + + @override + String get retry => 'Tentar novamente'; + + @override + String get firstLaunch => 'Primeiro lançamento'; + + @override + String missions(Object count) { + return '$count missões'; + } + + @override + String reuses(Object count) { + return '$count reutilizações'; + } + + @override + String get unknown => 'Desconhecido'; + + @override + String get na => 'N/D'; + + @override + String get core_filter_status_all => 'Todos'; + + @override + String get core_filter_status_active => 'Ativo'; + + @override + String get core_filter_status_lost => 'Perdido'; + + @override + String get core_filter_status_inactive => 'Inativo'; + + @override + String get core_filter_status_unknown => 'Desconhecido'; + + @override + String get core_filter_search_hint => 'Pesquisar núcleos ou missões...'; + + @override + String noCoresFound(Object query) { + return 'Nenhum núcleo encontrado para \"$query\"'; + } + + @override + String blockLabel(Object blockNumber) { + return 'Bloco $blockNumber'; + } + + @override + String get spaceXCoresTitle => 'Núcleos Falcon da SpaceX'; + + @override + String get coresLabel => 'Núcleos'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 21df554..9017202 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -431,4 +431,75 @@ class AppLocalizationsUk extends AppLocalizations { @override String get longitude => 'Довгота'; + + @override + String get core_status_active => 'активний'; + + @override + String get core_status_lost => 'втрачений'; + + @override + String get core_status_inactive => 'неактивний'; + + @override + String get core_status_unknown => 'невідомий'; + + @override + String get errorLoadingCores => 'Помилка завантаження ядер'; + + @override + String get retry => 'Повторити'; + + @override + String get firstLaunch => 'Перший запуск'; + + @override + String missions(Object count) { + return '$count місій'; + } + + @override + String reuses(Object count) { + return '$count повторів'; + } + + @override + String get unknown => 'Невідомо'; + + @override + String get na => 'Н/Д'; + + @override + String get core_filter_status_all => 'Усі'; + + @override + String get core_filter_status_active => 'Активний'; + + @override + String get core_filter_status_lost => 'Втрачений'; + + @override + String get core_filter_status_inactive => 'Неактивний'; + + @override + String get core_filter_status_unknown => 'Невідомо'; + + @override + String get core_filter_search_hint => 'Пошук ядер або місій...'; + + @override + String noCoresFound(Object query) { + return 'Ядра за запитом \"$query\" не знайдено'; + } + + @override + String blockLabel(Object blockNumber) { + return 'Блок $blockNumber'; + } + + @override + String get spaceXCoresTitle => 'Супутникові ядра Falcon від SpaceX'; + + @override + String get coresLabel => 'Ядра'; } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bc01174..68a487e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -388,5 +388,26 @@ "semiMajorAxis": "Semi-major axis", "eccentricity": "Eccentricity", "inclination": "Inclination", - "longitude": "Longitude" + "longitude": "Longitude", + "core_status_active": "active", + "core_status_lost": "lost", + "core_status_inactive": "inactive", + "core_status_unknown": "unknown", + "errorLoadingCores": "Error loading cores", + "retry": "Retry", + "firstLaunch": "First Launch", + "missions": "{count} missions", + "reuses": "{count} reuses", + "unknown": "Unknown", + "na": "N/A", + "core_filter_status_all": "All", + "core_filter_status_active": "Active", + "core_filter_status_lost": "Lost", + "core_filter_status_inactive": "Inactive", + "core_filter_status_unknown": "Unknown", + "core_filter_search_hint": "Search cores or missions...", + "noCoresFound": "No cores found for \"{query}\"", + "blockLabel": "Block {blockNumber}", + "spaceXCoresTitle": "SpaceX Falcon Cores", + "coresLabel": "Cores" } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 8df3bcb..6476101 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -251,5 +251,26 @@ "semiMajorAxis": "Eixo semi-maior", "eccentricity": "Excentricidade", "inclination": "Inclinação", - "longitude": "Longitude" + "longitude": "Longitude", + "core_status_active": "ativo", + "core_status_lost": "perdido", + "core_status_inactive": "inativo", + "core_status_unknown": "desconhecido", + "errorLoadingCores": "Erro ao carregar núcleos", + "retry": "Tentar novamente", + "firstLaunch": "Primeiro lançamento", + "missions": "{count} missões", + "reuses": "{count} reutilizações", + "unknown": "Desconhecido", + "na": "N/D", + "core_filter_status_all": "Todos", + "core_filter_status_active": "Ativo", + "core_filter_status_lost": "Perdido", + "core_filter_status_inactive": "Inativo", + "core_filter_status_unknown": "Desconhecido", + "core_filter_search_hint": "Pesquisar núcleos ou missões...", + "noCoresFound": "Nenhum núcleo encontrado para \"{query}\"", + "blockLabel": "Bloco {blockNumber}", + "spaceXCoresTitle": "Núcleos Falcon da SpaceX", + "coresLabel": "Núcleos" } diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index 4ca4d98..56ef140 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -388,5 +388,26 @@ "semiMajorAxis": "Велика піввісь", "eccentricity": "Ексцентриситет", "inclination": "Нахил", - "longitude": "Довгота" + "longitude": "Довгота", + "core_status_active": "активний", + "core_status_lost": "втрачений", + "core_status_inactive": "неактивний", + "core_status_unknown": "невідомий", + "errorLoadingCores": "Помилка завантаження ядер", + "retry": "Повторити", + "firstLaunch": "Перший запуск", + "missions": "{count} місій", + "reuses": "{count} повторів", + "unknown": "Невідомо", + "na": "Н/Д", + "core_filter_status_all": "Усі", + "core_filter_status_active": "Активний", + "core_filter_status_lost": "Втрачений", + "core_filter_status_inactive": "Неактивний", + "core_filter_status_unknown": "Невідомо", + "core_filter_search_hint": "Пошук ядер або місій...", + "noCoresFound": "Ядра за запитом \"{query}\" не знайдено", + "blockLabel": "Блок {blockNumber}", + "spaceXCoresTitle": "Супутникові ядра Falcon від SpaceX", + "coresLabel": "Ядра" } diff --git a/lib/models/core/core_ext.dart b/lib/models/core/core_ext.dart index 4531ba5..6215926 100644 --- a/lib/models/core/core_ext.dart +++ b/lib/models/core/core_ext.dart @@ -5,15 +5,27 @@ extension CoreExt on NetworkCoreModel { CoreResource toResource() { return CoreResource( coreSerial: coreSerial, - flight: flight, block: block, - gridfins: gridfins, - legs: legs, - reused: reused, - landSuccess: landSuccess, - landingIntent: landingIntent, - landingType: landingType, - landingVehicle: landingVehicle, + status: status, + originalLaunch: originalLaunch, + originalLaunchUnix: originalLaunchUnix, + missions: missions?.map((m) => m.toResource()).toList(), + reuseCount: reuseCount, + rtlsAttempts: rtlsAttempts, + rtlsLandings: rtlsLandings, + asdsAttempts: asdsAttempts, + asdsLandings: asdsLandings, + waterLanding: waterLanding, + details: details, + ); + } +} + +extension MissionExt on NetworkMission { + MissionResource toResource() { + return MissionResource( + name: name, + flight: flight, ); } } diff --git a/lib/models/core/core_resource.dart b/lib/models/core/core_resource.dart index 7817310..7cd1514 100644 --- a/lib/models/core/core_resource.dart +++ b/lib/models/core/core_resource.dart @@ -5,39 +5,94 @@ import 'package:flutter/foundation.dart'; class CoreResource extends Equatable { const CoreResource({ this.coreSerial, - this.flight, this.block, - this.gridfins, - this.legs, - this.reused, - this.landSuccess, - this.landingIntent, - this.landingType, - this.landingVehicle, + this.status, + this.originalLaunch, + this.originalLaunchUnix, + this.missions, + this.reuseCount, + this.rtlsAttempts, + this.rtlsLandings, + this.asdsAttempts, + this.asdsLandings, + this.waterLanding, + this.details, }); final String? coreSerial; - final int? flight; final int? block; - final bool? gridfins; - final bool? legs; - final bool? reused; - final bool? landSuccess; - final bool? landingIntent; - final String? landingType; - final String? landingVehicle; + final String? status; + final String? originalLaunch; + final int? originalLaunchUnix; + final List? missions; + final int? reuseCount; + final int? rtlsAttempts; + final int? rtlsLandings; + final int? asdsAttempts; + final int? asdsLandings; + final bool? waterLanding; + final String? details; + + CoreResource copyWith({ + String? coreSerial, + int? block, + String? status, + String? originalLaunch, + int? originalLaunchUnix, + List? missions, + int? reuseCount, + int? rtlsAttempts, + int? rtlsLandings, + int? asdsAttempts, + int? asdsLandings, + bool? waterLanding, + String? details, + }) { + return CoreResource( + coreSerial: coreSerial ?? this.coreSerial, + block: block ?? this.block, + status: status ?? this.status, + originalLaunch: originalLaunch ?? this.originalLaunch, + originalLaunchUnix: originalLaunchUnix ?? this.originalLaunchUnix, + missions: missions ?? this.missions, + reuseCount: reuseCount ?? this.reuseCount, + rtlsAttempts: rtlsAttempts ?? this.rtlsAttempts, + rtlsLandings: rtlsLandings ?? this.rtlsLandings, + asdsAttempts: asdsAttempts ?? this.asdsAttempts, + asdsLandings: asdsLandings ?? this.asdsLandings, + waterLanding: waterLanding ?? this.waterLanding, + details: details ?? this.details, + ); + } @override List get props => [ - coreSerial, - flight, - block, - gridfins, - legs, - reused, - landSuccess, - landingIntent, - landingType, - landingVehicle, - ]; + coreSerial, + block, + status, + originalLaunch, + originalLaunchUnix, + missions, + reuseCount, + rtlsAttempts, + rtlsLandings, + asdsAttempts, + asdsLandings, + waterLanding, + details, + ]; +} + +@immutable +class MissionResource extends Equatable { + const MissionResource({ + this.name, + this.flight, + }); + + final String? name; + final int? flight; + + @override + List get props => [name, flight]; } diff --git a/lib/models/stage/first_stage_ext.dart b/lib/models/stage/first_stage_ext.dart index f445eb9..00bd763 100644 --- a/lib/models/stage/first_stage_ext.dart +++ b/lib/models/stage/first_stage_ext.dart @@ -1,6 +1,6 @@ import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; -import 'package:flutter_bloc_app_template/models/core/core_ext.dart'; import 'package:flutter_bloc_app_template/models/stage/first_stage_resource.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_ext.dart'; extension FirstStageExt on NetworkFirstStageModel { FirstStageResource toResource() { diff --git a/lib/models/stage/first_stage_resource.dart b/lib/models/stage/first_stage_resource.dart index 24091e6..e9a71e6 100644 --- a/lib/models/stage/first_stage_resource.dart +++ b/lib/models/stage/first_stage_resource.dart @@ -1,12 +1,12 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; @immutable class FirstStageResource extends Equatable { const FirstStageResource({this.cores}); - final List? cores; + final List? cores; @override List get props => [cores]; diff --git a/lib/models/stage/stage_core_ext.dart b/lib/models/stage/stage_core_ext.dart new file mode 100644 index 0000000..ed55bd0 --- /dev/null +++ b/lib/models/stage/stage_core_ext.dart @@ -0,0 +1,19 @@ +import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; + +extension CoreExt on NetworkStageCoreModel { + StageCoreResource toResource() { + return StageCoreResource( + coreSerial: coreSerial, + flight: flight, + block: block, + gridfins: gridfins, + legs: legs, + reused: reused, + landSuccess: landSuccess, + landingIntent: landingIntent, + landingType: landingType, + landingVehicle: landingVehicle, + ); + } +} diff --git a/lib/models/stage/stage_core_resource.dart b/lib/models/stage/stage_core_resource.dart new file mode 100644 index 0000000..3762892 --- /dev/null +++ b/lib/models/stage/stage_core_resource.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class StageCoreResource extends Equatable { + const StageCoreResource({ + this.coreSerial, + this.flight, + this.block, + this.gridfins, + this.legs, + this.reused, + this.landSuccess, + this.landingIntent, + this.landingType, + this.landingVehicle, + }); + + final String? coreSerial; + final int? flight; + final int? block; + final bool? gridfins; + final bool? legs; + final bool? reused; + final bool? landSuccess; + final bool? landingIntent; + final String? landingType; + final String? landingVehicle; + + @override + List get props => [ + coreSerial, + flight, + block, + gridfins, + legs, + reused, + landSuccess, + landingIntent, + landingType, + landingVehicle, + ]; +} diff --git a/lib/repository/cores_repository.dart b/lib/repository/cores_repository.dart new file mode 100644 index 0000000..183c3d7 --- /dev/null +++ b/lib/repository/cores_repository.dart @@ -0,0 +1,51 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart'; +import 'package:flutter_bloc_app_template/models/core/core_ext.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; + +abstract class CoresRepository { + Future> getCores({ + bool? hasId = true, + int? limit, + int? offset, + }); + + Future getCore(String coreSerial); +} + +class CoresRepositoryImpl implements CoresRepository { + CoresRepositoryImpl(this._coresDataSource); + + final CoresDataSource _coresDataSource; + + @override + Future> getCores( + {bool? hasId = true, int? limit, int? offset}) async { + final list = await _coresDataSource.getCores( + hasId: hasId, + limit: limit, + offset: offset, + ); + + return ApiResultWhen(list).when( + success: (data) => data.map((e) => e.toResource()).toList(), + error: (message) => throw Exception(message), + loading: () { + throw Exception('Loading'); + }, + ); + } + + @override + Future getCore(String coreSerial) async { + final result = await _coresDataSource.getCore(coreSerial); + + return ApiResultWhen(result).when( + success: (data) => data.toResource(), + error: (message) => throw Exception(message), + loading: () { + throw Exception('Loading'); + }, + ); + } +} diff --git a/lib/utils/string_resources.dart b/lib/utils/string_resources.dart index 7bc105f..93551ab 100644 --- a/lib/utils/string_resources.dart +++ b/lib/utils/string_resources.dart @@ -32,6 +32,8 @@ extension StringResourcesExtension on BuildContext { String get rocketsTab => l10n.rocketsTab; + String get coresLabel => l10n.coresLabel; + String get settingsTitle => l10n.settingsTitle; String get emptyList => l10n.emptyList; diff --git a/test/data/network/fixtures/cores/core.json b/test/data/network/fixtures/cores/core.json new file mode 100644 index 0000000..3c41bb8 --- /dev/null +++ b/test/data/network/fixtures/cores/core.json @@ -0,0 +1,20 @@ +{ + "core_serial": "Merlin1A", + "block": null, + "status": "lost", + "original_launch": "2006-03-24T22:30:00.000Z", + "original_launch_unix": 1143239400, + "missions": [ + { + "name": "FalconSat", + "flight": 1 + } + ], + "reuse_count": 0, + "rtls_attempts": 0, + "rtls_landings": 0, + "asds_attempts": 0, + "asds_landings": 0, + "water_landing": false, + "details": "Engine failure at T+33 seconds resulted in loss of vehicle" +} diff --git a/test/data/network/fixtures/cores/core2.json b/test/data/network/fixtures/cores/core2.json new file mode 100644 index 0000000..b08136d --- /dev/null +++ b/test/data/network/fixtures/cores/core2.json @@ -0,0 +1,20 @@ +{ + "core_serial": "Merlin2A", + "block": null, + "status": "lost", + "original_launch": "2007-03-21T01:10:00.000Z", + "original_launch_unix": 1174439400, + "missions": [ + { + "name": "DemoSat", + "flight": 2 + } + ], + "reuse_count": 0, + "rtls_attempts": 0, + "rtls_landings": 0, + "asds_attempts": 0, + "asds_landings": 0, + "water_landing": false, + "details": "Successful first-stage burn and transition to second stage, maximal altitude 289 km. Harmonic oscillation at T+5 minutes Premature engine shutdown at T+7 min 30 s. Failed to reach orbit." +} diff --git a/test/data/network/fixtures/cores/cores.json b/test/data/network/fixtures/cores/cores.json new file mode 100644 index 0000000..35f1297 --- /dev/null +++ b/test/data/network/fixtures/cores/cores.json @@ -0,0 +1,62 @@ +[ + { + "core_serial": "Merlin1A", + "block": null, + "status": "lost", + "original_launch": "2006-03-24T22:30:00.000Z", + "original_launch_unix": 1143239400, + "missions": [ + { + "name": "FalconSat", + "flight": 1 + } + ], + "reuse_count": 0, + "rtls_attempts": 0, + "rtls_landings": 0, + "asds_attempts": 0, + "asds_landings": 0, + "water_landing": false, + "details": "Engine failure at T+33 seconds resulted in loss of vehicle" + }, + { + "core_serial": "Merlin2A", + "block": null, + "status": "lost", + "original_launch": "2007-03-21T01:10:00.000Z", + "original_launch_unix": 1174439400, + "missions": [ + { + "name": "DemoSat", + "flight": 2 + } + ], + "reuse_count": 0, + "rtls_attempts": 0, + "rtls_landings": 0, + "asds_attempts": 0, + "asds_landings": 0, + "water_landing": false, + "details": "Successful first-stage burn and transition to second stage, maximal altitude 289 km. Harmonic oscillation at T+5 minutes Premature engine shutdown at T+7 min 30 s. Failed to reach orbit." + }, + { + "core_serial": "Merlin1C", + "block": null, + "status": "lost", + "original_launch": "2008-08-03T03:34:00.000Z", + "original_launch_unix": 1217734440, + "missions": [ + { + "name": "Trailblazer", + "flight": 3 + } + ], + "reuse_count": 0, + "rtls_attempts": 0, + "rtls_landings": 0, + "asds_attempts": 0, + "asds_landings": 0, + "water_landing": false, + "details": "Residual stage-1 thrust led to collision between stage 1 and stage 2." + } +] diff --git a/test/data/network/model/core/network_core_model_test.dart b/test/data/network/model/core/network_core_model_test.dart index de14b9d..2aed222 100644 --- a/test/data/network/model/core/network_core_model_test.dart +++ b/test/data/network/model/core/network_core_model_test.dart @@ -1,57 +1,74 @@ +import 'dart:convert'; + import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('NetworkCoreModel', () { - final json = { - 'core_serial': 'B1013', - 'flight': 1, - 'block': 1, - 'gridfins': true, - 'legs': true, - 'reused': false, - 'land_success': true, - 'landing_intent': true, - 'landing_type': 'Ocean', - 'landing_vehicle': null - }; + const mission = NetworkMission( + name: 'RatSat', + flight: 4, + ); - test('fromJson should parse correctly', () { - final model = NetworkCoreModel.fromJson(json); - - expect(model.coreSerial, 'B1013'); - expect(model.flight, 1); - expect(model.block, 1); - expect(model.gridfins, true); - expect(model.legs, true); - expect(model.reused, false); - expect(model.landSuccess, true); - expect(model.landingIntent, true); - expect(model.landingType, 'Ocean'); - expect(model.landingVehicle, isNull); - }); + const coreModel = NetworkCoreModel( + coreSerial: 'Merlin2C', + block: null, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [mission], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: 'Initially scheduled for 23–25 Sep, carried dummy payload – ' + 'mass simulator, 165 kg (originally intended to be RazakSAT.', + ); - test('toJson should convert correctly', () { - final model = NetworkCoreModel.fromJson(json); - final result = model.toJson(); + final jsonMap = { + 'core_serial': 'Merlin2C', + 'block': null, + 'status': 'lost', + 'original_launch': '2008-09-28T23:15:00.000Z', + 'original_launch_unix': 1222643700, + 'missions': [ + {'name': 'RatSat', 'flight': 4} + ], + 'reuse_count': 0, + 'rtls_attempts': 0, + 'rtls_landings': 0, + 'asds_attempts': 0, + 'asds_landings': 0, + 'water_landing': false, + 'details': 'Initially scheduled for 23–25 Sep, carried dummy payload – ' + 'mass simulator, 165 kg (originally intended to be RazakSAT.' + }; - expect(result, json); + test('fromJson should parse JSON correctly', () { + final model = NetworkCoreModel.fromJson(jsonMap); + expect(model, coreModel); }); - test('equality should work correctly', () { - final model1 = NetworkCoreModel.fromJson(json); - final model2 = NetworkCoreModel.fromJson(json); - - expect(model1, equals(model2)); + test('equality should work', () { + final model1 = NetworkCoreModel.fromJson(jsonMap); + final model2 = NetworkCoreModel.fromJson(jsonMap); + expect(model1, model2); + expect(model1.hashCode, model2.hashCode); }); - test('copyWith should override values', () { - final model = NetworkCoreModel.fromJson(json); - final updated = model.copyWith(coreSerial: 'B1014', flight: 2); + test('missions should contain correct values', () { + final model = NetworkCoreModel.fromJson(jsonMap); + expect(model.missions, isNotNull); + expect(model.missions!.first.name, 'RatSat'); + expect(model.missions!.first.flight, 4); + }); - expect(updated.coreSerial, 'B1014'); - expect(updated.flight, 2); - expect(updated.block, model.block); + test('can encode to JSON string', () { + final jsonString = jsonEncode(coreModel.toJson()); + final decoded = jsonDecode(jsonString); + expect(decoded, jsonMap); }); }); } diff --git a/test/data/network/model/stage/network_first_stage_model_test.dart b/test/data/network/model/stage/network_first_stage_model_test.dart index d65d216..aace0c1 100644 --- a/test/data/network/model/stage/network_first_stage_model_test.dart +++ b/test/data/network/model/stage/network_first_stage_model_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -65,7 +64,7 @@ void main() { final updated = model.copyWith( cores: [ - const NetworkCoreModel( + const NetworkStageCoreModel( coreSerial: 'B9999', flight: 9, block: 9, diff --git a/test/data/network/model/stage/network_stage_core_model_test.dart b/test/data/network/model/stage/network_stage_core_model_test.dart new file mode 100644 index 0000000..e4c4874 --- /dev/null +++ b/test/data/network/model/stage/network_stage_core_model_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NetworkStageCoreModel', () { + final json = { + 'core_serial': 'B1013', + 'flight': 1, + 'block': 1, + 'gridfins': true, + 'legs': true, + 'reused': false, + 'land_success': true, + 'landing_intent': true, + 'landing_type': 'Ocean', + 'landing_vehicle': null + }; + + test('fromJson should parse correctly', () { + final model = NetworkStageCoreModel.fromJson(json); + + expect(model.coreSerial, 'B1013'); + expect(model.flight, 1); + expect(model.block, 1); + expect(model.gridfins, true); + expect(model.legs, true); + expect(model.reused, false); + expect(model.landSuccess, true); + expect(model.landingIntent, true); + expect(model.landingType, 'Ocean'); + expect(model.landingVehicle, isNull); + }); + + test('toJson should convert correctly', () { + final model = NetworkStageCoreModel.fromJson(json); + final result = model.toJson(); + + expect(result, json); + }); + + test('equality should work correctly', () { + final model1 = NetworkStageCoreModel.fromJson(json); + final model2 = NetworkStageCoreModel.fromJson(json); + + expect(model1, equals(model2)); + }); + + test('copyWith should override values', () { + final model = NetworkStageCoreModel.fromJson(json); + final updated = model.copyWith(coreSerial: 'B1014', flight: 2); + + expect(updated.coreSerial, 'B1014'); + expect(updated.flight, 2); + expect(updated.block, model.block); + }); + }); +} diff --git a/test/data/network/service/cores/cores_service_test.dart b/test/data/network/service/cores/cores_service_test.dart new file mode 100644 index 0000000..d5ee89f --- /dev/null +++ b/test/data/network/service/cores/cores_service_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart'; +import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/cores/cores_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../fixtures_reader.dart'; + +class MockCoresService extends Mock implements CoresService {} + +void main() { + late CoresService service; + late CoresDataSource dataSource; + + setUp(() async { + registerFallbackValue(Uri()); + service = MockCoresService(); + dataSource = CoresNetworkDataSource(service); + }); + + group('getCores', () { + final mockResponse = 'cores/cores.json' + .toFixture() + .map((e) => NetworkCoreModel.fromJson((e as Map))); + + test( + 'should perform a GET request on /cores and return a list of NetworkCoreModel', + () async { + // arrange + when( + () => service.fetchCores(), + ).thenAnswer( + (_) async => Future.value(mockResponse.toList()), + ); + + // act + final call = await dataSource.getCores(); + // assert + verify(() => service.fetchCores()); + expect(call, isA>>()); + expect((call as Success>).data.isNotEmpty, true); + verifyNoMoreInteractions(service); + }, + ); + + test('should perform a GET request on /cores and return an error', + () async { + // arrange + when(() => service.fetchCores()).thenThrow(Exception('Server error')); + + // act + final call = await dataSource.getCores(); + + // assert + expect(call, isA>>()); + verify(() => service.fetchCores()); + verifyNoMoreInteractions(service); + }); + + test( + 'should perform a GET request on /cores and return an empty list of NetworkCoreModel', + () async { + // arrange + when( + () => service.fetchCores(), + ).thenAnswer( + (_) async => Future.value([]), + ); + + // act + final call = await dataSource.getCores(); + // assert + verify(() => service.fetchCores()); + expect(call, isA>>()); + expect((call as Success>).data.isEmpty, true); + verifyNoMoreInteractions(service); + }, + ); + }); + + group('fetchCore', () { + final mockResponse = + NetworkCoreModel.fromJson('cores/core.json'.toFixtureObject()); + final mockResponse2 = + NetworkCoreModel.fromJson('cores/core2.json'.toFixtureObject()); + final coreSerial = 'Merlin2A'; + + test( + 'should perform a GET request on /core and return a NetworkCoreModel', + () async { + // arrange + when( + () => service.fetchCore(coreSerial), + ).thenAnswer( + (_) async => Future.value(mockResponse), + ); + + // act + final call = await dataSource.getCore(coreSerial); + // assert + verify(() => service.fetchCore(coreSerial)); + expect(call, isA>()); + expect((call as Success).data.coreSerial, 'Merlin1A'); + verifyNoMoreInteractions(service); + }, + ); + test( + 'should perform a GET request on /core and return a NetworkCoreModel with different payload', + () async { + // arrange + when( + () => service.fetchCore(coreSerial), + ).thenAnswer( + (_) async => Future.value(mockResponse2), + ); + + // act + final call = await dataSource.getCore(coreSerial); + // assert + verify(() => service.fetchCore(coreSerial)); + expect(call, isA>()); + expect((call as Success).data.coreSerial, 'Merlin2A'); + verifyNoMoreInteractions(service); + }, + ); + test('should perform a GET request on /core and return an error', () async { + final coreSerial = 'Merlin2A'; + // arrange + when(() => service.fetchCore(coreSerial)) + .thenThrow(Exception('Server error')); + + // act + final call = await dataSource.getCore(coreSerial); + + // assert + expect(call, isA>()); + verify(() => service.fetchCore(coreSerial)); + verifyNoMoreInteractions(service); + }); + }); +} diff --git a/test/features/cores/bloc/cores_bloc_test.dart b/test/features/cores/bloc/cores_bloc_test.dart new file mode 100644 index 0000000..e1277c8 --- /dev/null +++ b/test/features/cores/bloc/cores_bloc_test.dart @@ -0,0 +1,431 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/cores_ext.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +// Mock Repository +class MockCoresRepository extends Mock implements CoresRepository {} + +// Test data +final testCores = [ + const CoreResource( + coreSerial: 'B1051', + block: 5, + status: 'active', + originalLaunch: '2019-03-02T07:45:00.000Z', + originalLaunchUnix: 1551512700, + missions: [ + MissionResource(name: 'Demo Mission', flight: 1), + MissionResource(name: 'Test Mission', flight: 2), + ], + reuseCount: 2, + ), + const CoreResource( + coreSerial: 'B1049', + block: 4, + status: 'lost', + originalLaunch: '2018-09-10T04:45:00.000Z', + originalLaunchUnix: 1536554700, + missions: [ + MissionResource(name: 'Satellite Launch', flight: 3), + ], + reuseCount: 1, + ), + const CoreResource( + coreSerial: 'B1050', + block: 3, + status: 'inactive', + originalLaunch: '2017-01-01T00:00:00.000Z', + originalLaunchUnix: 1483228800, + missions: [], + reuseCount: 0, + ), +]; + +void main() { + late CoresRepository repository; + late CoresBloc bloc; + + setUp(() { + repository = MockCoresRepository(); + bloc = CoresBloc(repository); + }); + + tearDown(() { + bloc.close(); + }); + + group('CoresBloc', () { + group('CoresLoadEvent', () { + blocTest( + 'emits [loading, success] when getCores succeeds with data', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenAnswer((_) async => testCores); + return bloc; + }, + act: (bloc) => bloc.add(const CoresLoadEvent()), + expect: () => [ + const CoresState.loading(), + CoresState.success(cores: testCores, filteredCores: testCores), + ], + verify: (_) { + verify(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).called(1); + }, + ); + + blocTest( + 'emits [loading, empty] when getCores returns empty list', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenAnswer((_) async => []); + return bloc; + }, + act: (bloc) => bloc.add(const CoresLoadEvent()), + expect: () => [ + const CoresState.loading(), + const CoresState.empty(), + ], + ); + + blocTest( + 'emits [loading, error] when getCores throws exception', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenThrow(Exception('Failed to load')); + return bloc; + }, + act: (bloc) => bloc.add(const CoresLoadEvent()), + expect: () => [ + const CoresState.loading(), + const CoresState.error('Exception: Failed to load'), + ], + ); + + test('stores cores in allCores when load succeeds', () async { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenAnswer((_) async => testCores); + + bloc.add(const CoresLoadEvent()); + await expectLater( + bloc.stream, + emitsInOrder([ + const CoresState.loading(), + CoresState.success(cores: testCores, filteredCores: testCores), + ]), + ); + + expect(bloc.allCores, equals(testCores)); + }); + }); + + group('CoresRefreshEvent', () { + blocTest( + 'emits [loading, success] and updates allCores', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenAnswer((_) async => testCores); + return bloc; + }, + act: (bloc) => bloc.add(const CoresRefreshEvent()), + expect: () => [ + const CoresState.loading(), + CoresState.success(cores: testCores, filteredCores: testCores), + ], + verify: (bloc) { + expect(bloc.allCores, equals(testCores)); + }, + ); + + blocTest( + 'emits [loading, empty]', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenAnswer((_) async => []); + return bloc; + }, + act: (bloc) => bloc.add(const CoresRefreshEvent()), + expect: () => [ + const CoresState.loading(), + const CoresState.empty(), + ], + verify: (bloc) { + expect(bloc.allCores, equals([])); + }, + ); + + blocTest( + 'emits [loading, error] when refreshCores throws exception', + build: () { + when(() => repository.getCores( + hasId: true, + limit: null, + offset: null, + )).thenThrow(Exception('Failed to load')); + return bloc; + }, + act: (bloc) => bloc.add(const CoresRefreshEvent()), + expect: () => [ + const CoresState.loading(), + const CoresState.error('Exception: Failed to load'), + ], + ); + }); + + group('CoresFilterEvent', () { + blocTest( + 'filters cores by search query (core serial)', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'B1051'), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: [testCores[0]], + searchQuery: 'B1051', + statusFilter: null, + ), + ], + ); + + blocTest( + 'filters cores by search query (mission name)', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'Satellite'), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: [testCores[1]], + searchQuery: 'Satellite', + statusFilter: null, + ), + ], + ); + + blocTest( + 'filters cores by status', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent( + searchQuery: '', + statusFilter: CoreFilterStatus.active, + ), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: [testCores[0]], + searchQuery: '', + statusFilter: CoreFilterStatus.active, + ), + ], + ); + + blocTest( + 'filters cores by both search and status', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent( + searchQuery: 'B105', + statusFilter: CoreFilterStatus.active, + ), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: [testCores[0]], + searchQuery: 'B105', + statusFilter: CoreFilterStatus.active, + ), + ], + ); + + blocTest( + 'returns all cores when filter is "all"', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent( + searchQuery: '', + statusFilter: CoreFilterStatus.all, + ), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: testCores, + searchQuery: '', + statusFilter: CoreFilterStatus.all, + ), + ], + ); + + blocTest( + 'emits empty state when allCores is empty', + build: () { + bloc.allCores = []; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'test'), + ), + expect: () => [ + const CoresState.empty(), + ], + ); + + blocTest( + 'handles case-insensitive search', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'demo mission'), + ), + expect: () => [ + CoresState.success( + cores: testCores, + filteredCores: [testCores[0]], + searchQuery: 'demo mission', + statusFilter: null, + ), + ], + ); + + blocTest( + 'returns empty list when no matches found', + build: () { + bloc.allCores = testCores; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'nonexistent'), + ), + expect: () => [ + const CoresState.notFound(searchQuery: 'nonexistent'), + ], + ); + + blocTest( + 'handles null values in core data gracefully', + build: () { + bloc.allCores = [ + const CoreResource( + coreSerial: null, + missions: null, + status: null, + ), + const CoreResource( + coreSerial: 'B1051', + missions: [MissionResource(name: null, flight: 1)], + status: 'active', + ), + ]; + return bloc; + }, + act: (bloc) => bloc.add( + const CoresFilterEvent(searchQuery: 'B1051'), + ), + expect: () => [ + CoresState.success( + cores: bloc.allCores, + filteredCores: [bloc.allCores[1]], + searchQuery: 'B1051', + statusFilter: null, + ), + ], + ); + }); + + group('State Management', () { + test('initial state is loading', () { + expect(bloc.state, const CoresState.loading()); + }); + + test('allCores is initially empty', () { + expect(bloc.allCores, isEmpty); + }); + }); + }); + + group('State Equality', () { + test('CoresLoadingState equality', () { + const state1 = CoresLoadingState(); + const state2 = CoresLoadingState(); + expect(state1, equals(state2)); + }); + + test('CoresSuccessState equality', () { + final state1 = CoresState.success(cores: testCores); + final state2 = CoresState.success(cores: testCores); + expect(state1, equals(state2)); + }); + + test('CoresEmptyState equality', () { + const state1 = CoresEmptyState(); + const state2 = CoresEmptyState(); + expect(state1, equals(state2)); + }); + + test('CoresErrorState equality', () { + const state1 = CoresErrorState('Error message'); + const state2 = CoresErrorState('Error message'); + expect(state1, equals(state2)); + }); + }); + + group('CoreFilterStatus Extension', () { + test('converts string to correct status', () { + expect('active'.toStatus(), CoreFilterStatus.active); + expect('lost'.toStatus(), CoreFilterStatus.lost); + expect('inactive'.toStatus(), CoreFilterStatus.inactive); + expect('unknown'.toStatus(), CoreFilterStatus.unknown); + expect('invalid'.toStatus(), CoreFilterStatus.unknown); + expect(null.toStatus(), CoreFilterStatus.unknown); + }); + }); +} diff --git a/test/features/cores/cores_screen_test.dart b/test/features/cores/cores_screen_test.dart new file mode 100644 index 0000000..330dc18 --- /dev/null +++ b/test/features/cores/cores_screen_test.dart @@ -0,0 +1,117 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/cores/bloc/cores_bloc.dart'; +import 'package:flutter_bloc_app_template/features/cores/cores_screen.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/core_loading_content.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_empty_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_error_widget.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_not_found_widget.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../bloc/utils.dart'; + +class MockCoresBloc extends MockBloc + implements CoresBloc {} + +void main() { + late MockCoresBloc mockBloc; + + setUp(() { + mockBloc = MockCoresBloc(); + }); + + testWidgets('renders LoadingContent when state is CoresLoadingState', + (tester) async { + when(() => mockBloc.state).thenReturn(const CoresLoadingState()); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pump(); + + expect(find.byType(CoreLoadingContent), findsOneWidget); + }); + + testWidgets('renders CoresListWidget when state is CoresSuccessState', + (tester) async { + final cores = [ + const CoreResource( + coreSerial: 'B1051', + missions: [MissionResource(name: null, flight: 1)], + status: 'active', + ), + ]; // provide mock cores + when(() => mockBloc.state) + .thenReturn(CoresSuccessState(filteredCores: cores)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresListWidget), findsOneWidget); + }); + + testWidgets('renders CoresErrorWidget when state is CoresErrorState', + (tester) async { + const errorMessage = 'Error occurred'; + when(() => mockBloc.state).thenReturn(const CoresErrorState(errorMessage)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresErrorWidget), findsOneWidget); + //expect(find.text(errorMessage), findsOneWidget); + }); + + testWidgets('renders CoresEmptyWidget when state is CoresEmptyState', + (tester) async { + when(() => mockBloc.state).thenReturn(const CoresEmptyState()); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresEmptyWidget), findsOneWidget); + }); + + testWidgets('renders CoresNotFoundWidget when state is CoresNotFoundState', + (tester) async { + const query = 'Falcon'; + when(() => mockBloc.state) + .thenReturn(const CoresNotFoundState(searchQuery: query)); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: mockBloc, + child: const CustomScrollView( + slivers: [CoresBlocContent()], + ), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CoresNotFoundWidget), findsOneWidget); + expect(find.textContaining(query), findsOneWidget); + }); +} diff --git a/test/features/cores/utils/core_utils_test.dart b/test/features/cores/utils/core_utils_test.dart new file mode 100644 index 0000000..7b6aac8 --- /dev/null +++ b/test/features/cores/utils/core_utils_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/core_utils.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Utils', () { + testWidgets('getStatusColor returns correct color for known statuses', + (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Builder( + builder: (context) { + final loc = S.of(context); + + expect(getStatusColor(context, loc.core_status_active), + Colors.green); + expect(getStatusColor(context, loc.core_status_lost), Colors.red); + expect(getStatusColor(context, loc.core_status_inactive), + Colors.orange); + expect(getStatusColor(context, loc.core_status_unknown), + Colors.grey); + + return Container(); + }, + ), + ), + ); + }); + + testWidgets('getStatusColor returns blue for null or unknown', + (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Builder( + builder: (context) { + expect(getStatusColor(context, null), Colors.blue); + expect(getStatusColor(context, 'not_a_status'), Colors.blue); + return Container(); + }, + ), + ), + ); + }); + + testWidgets('formatFirstLaunch returns formatted date', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Builder( + builder: (context) { + final isoDate = '2021-09-09T12:00:00Z'; + final result = formatFirstLaunch(context, isoDate); + + final expected = '${S.of(context).firstLaunch}:' + ' ${DateFormat.yMMMd().format(DateTime.parse(isoDate))}'; + + expect(result, expected); + return Container(); + }, + ), + ), + ); + }); + + testWidgets('formatFirstLaunch returns empty for null or empty', + (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Builder( + builder: (context) { + expect(formatFirstLaunch(context, null), ''); + expect(formatFirstLaunch(context, ''), ''); + return Container(); + }, + ), + ), + ); + }); + }); +} diff --git a/test/features/cores/utils/cores_ext_test.dart b/test/features/cores/utils/cores_ext_test.dart new file mode 100644 index 0000000..81be397 --- /dev/null +++ b/test/features/cores/utils/cores_ext_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/cores_ext.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CoreFilterStatusX.title', () { + testWidgets('returns correct localized titles', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Builder( + builder: (context) { + expect(CoreFilterStatus.all.title(context), + S.of(context).core_filter_status_all); + expect(CoreFilterStatus.active.title(context), + S.of(context).core_filter_status_active); + expect(CoreFilterStatus.lost.title(context), + S.of(context).core_filter_status_lost); + expect(CoreFilterStatus.inactive.title(context), + S.of(context).core_filter_status_inactive); + expect(CoreFilterStatus.unknown.title(context), + S.of(context).core_filter_status_unknown); + + return Container(); + }, + ), + ), + ); + }); + }); + + group('CoreFilterStatusStringX.toStatus', () { + test('converts string to CoreFilterStatus correctly', () { + expect('active'.toStatus(), CoreFilterStatus.active); + expect('lost'.toStatus(), CoreFilterStatus.lost); + expect('inactive'.toStatus(), CoreFilterStatus.inactive); + expect('unknown'.toStatus(), CoreFilterStatus.unknown); + expect('all'.toStatus(), CoreFilterStatus.unknown); + expect(null.toStatus(), CoreFilterStatus.unknown); + expect('random'.toStatus(), CoreFilterStatus.unknown); + expect('ACTIVE'.toStatus(), + CoreFilterStatus.active); // test case-insensitive + }); + }); +} diff --git a/test/features/cores/widget/core_item_widget_test.dart b/test/features/cores/widget/core_item_widget_test.dart new file mode 100644 index 0000000..0b3358b --- /dev/null +++ b/test/features/cores/widget/core_item_widget_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/cores/utils/core_utils.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/core_item_widget.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + CoreResource makeCore({ + String? coreSerial, + int? block, + String? status, + String? originalLaunch, + List? missions, + int? reuseCount, + }) { + return CoreResource( + coreSerial: coreSerial, + block: block, + status: status, + originalLaunch: originalLaunch, + missions: missions, + reuseCount: reuseCount, + ); + } + + Widget makeTestableWidget(CoreResource core) { + return MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: CoreItemWidget(core: core), + ), + ); + } + + testWidgets('displays core serial and block', (tester) async { + final core = makeCore(coreSerial: 'B1049', block: 5); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + expect(find.text('B1049'), findsOneWidget); + expect(find.textContaining('Block'), findsOneWidget); + }); + + testWidgets('displays status with correct color', (tester) async { + final core = makeCore(coreSerial: 'B1049', status: 'active'); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + final statusFinder = find.text('active'); + expect(statusFinder, findsOneWidget); + + final textWidget = tester.widget(statusFinder); + final color = textWidget.style?.color; + // should match getStatusColor(context, 'active') + expect(color, isNotNull); + }); + + testWidgets('displays formatted first launch', (tester) async { + final core = makeCore(originalLaunch: '2021-09-09T12:00:00Z'); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + final formatted = formatFirstLaunch( + tester.element(find.byType(CoreItemWidget)), core.originalLaunch); + expect(find.text(formatted), findsOneWidget); + }); + + testWidgets('displays mission and reuse counts', (tester) async { + final core = makeCore( + missions: [const MissionResource(name: 'Demo-2')], + reuseCount: 2, + ); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Demo-2'), findsOneWidget); + }); + + testWidgets('renders mission chips when missions are present', + (tester) async { + final core = makeCore( + missions: [ + const MissionResource(name: 'Demo-2'), + const MissionResource(name: 'Starlink-15'), + ], + ); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + expect(find.byType(Chip), findsNWidgets(2)); + expect(find.text('Demo-2'), findsOneWidget); + expect(find.text('Starlink-15'), findsOneWidget); + }); + + testWidgets('does not render block container if block is null', + (tester) async { + final core = makeCore(coreSerial: 'B1049', block: null); + await tester.pumpWidget(makeTestableWidget(core)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Block'), findsNothing); + }); +} diff --git a/test/features/cores/widget/cores_search_filter_widget_test.dart b/test/features/cores/widget/cores_search_filter_widget_test.dart new file mode 100644 index 0000000..dd15b74 --- /dev/null +++ b/test/features/cores/widget/cores_search_filter_widget_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/cores/model/core_filter_status.dart'; +import 'package:flutter_bloc_app_template/features/cores/widget/cores_search_filter_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockOnChanged extends Mock { + void call(String value, CoreFilterStatus? status); +} + +class MockOnClear extends Mock { + void call(CoreFilterStatus? status); +} + +void main() { + late MockOnChanged mockOnChanged; + late MockOnClear mockOnClear; + + setUp(() { + mockOnChanged = MockOnChanged(); + mockOnClear = MockOnClear(); + }); + + Future pumpWidget( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: CustomScrollView( + slivers: [ + CoresSearchFilterWidget( + onClear: mockOnClear.call, + onChanged: mockOnChanged.call, + ), + ], + ), + ), + ), + ); + } + + testWidgets('renders search bar and filter chips', (tester) async { + await pumpWidget(tester); + + expect(find.byType(TextField), findsOneWidget); + for (final status in CoreFilterStatus.values) { + expect( + find.byKey(Key('core_status_filter_${status.name.toLowerCase()}')), + findsOneWidget, + ); + } + }); + + testWidgets('typing in search triggers onChanged', (tester) async { + await pumpWidget(tester); + + final searchField = find.byType(TextField); + await tester.enterText(searchField, 'merlin'); + await tester.pumpAndSettle(); + + verify(() => mockOnChanged.call('merlin', null)).called(1); + }); + + testWidgets('tapping clear button triggers onClear', (tester) async { + await pumpWidget(tester); + + final searchField = find.byType(TextField); + await tester.enterText(searchField, 'merlin'); + await tester.pumpAndSettle(); + + final clearButton = find.byIcon(Icons.clear); + expect(clearButton, findsOneWidget); + + await tester.tap(clearButton); + await tester.pumpAndSettle(); + + verify(() => mockOnClear.call(null)).called(1); + expect(find.text(''), findsOneWidget); + }); + + testWidgets('tapping filter chip updates selection and triggers onChanged', + (tester) async { + await pumpWidget(tester); + + final activeChip = find.byKey(const Key('core_status_filter_active')); + await tester.tap(activeChip); + await tester.pumpAndSettle(); + + verify(() => mockOnChanged.call('', CoreFilterStatus.active)).called(1); + + // Tapping "all" resets status to null + final allChip = find.byKey(const Key('core_status_filter_all')); + await tester.tap(allChip); + await tester.pumpAndSettle(); + + verify(() => mockOnChanged.call('', null)).called(1); + }); +} diff --git a/test/features/main/destinations_test.dart b/test/features/main/destinations_test.dart index ec2c08d..5a229d0 100644 --- a/test/features/main/destinations_test.dart +++ b/test/features/main/destinations_test.dart @@ -30,7 +30,8 @@ void main() { // Check labels expect(destinations[0].label, context.launchesTitle); expect(destinations[1].label, context.rocketsTab); - expect(destinations[2].label, context.settingsTitle); + expect(destinations[2].label, context.coresLabel); + expect(destinations[3].label, context.settingsTitle); return Container(); }, diff --git a/test/mocks.dart b/test/mocks.dart index 7164dc8..8091907 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc_app_template/index.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart'; import 'package:mockito/annotations.dart'; @@ -11,6 +12,7 @@ export 'mocks.mocks.dart'; LaunchesRepository, RocketRepository, RoadsterRepository, + CoresRepository, ], customMocks: [ MockSpec(onMissingStub: OnMissingStub.returnDefault) ]) diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index a21817a..ee44883 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -3,17 +3,21 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i5; -import 'package:flutter/src/widgets/navigator.dart' as _i8; +import 'package:flutter/src/widgets/navigator.dart' as _i10; import 'package:flutter_bloc_app_template/index.dart' as _i2; -import 'package:flutter_bloc_app_template/models/email.dart' as _i5; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart' + as _i4; +import 'package:flutter_bloc_app_template/models/email.dart' as _i6; import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart' as _i3; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart' + as _i9; import 'package:flutter_bloc_app_template/repository/roadster_repository.dart' - as _i7; + as _i8; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart' - as _i6; + as _i7; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -63,6 +67,16 @@ class _FakeRoadsterResource_2 extends _i1.SmartFake ); } +class _FakeCoreResource_3 extends _i1.SmartFake implements _i4.CoreResource { + _FakeCoreResource_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [EmailListRepository]. /// /// See the documentation for Mockito's code generation for more information. @@ -73,13 +87,13 @@ class MockEmailListRepository extends _i1.Mock } @override - _i4.Future> loadData() => (super.noSuchMethod( + _i5.Future> loadData() => (super.noSuchMethod( Invocation.method( #loadData, [], ), - returnValue: _i4.Future>.value(<_i5.Email>[]), - ) as _i4.Future>); + returnValue: _i5.Future>.value(<_i6.Email>[]), + ) as _i5.Future>); } /// A class which mocks [LaunchesRepository]. @@ -92,7 +106,7 @@ class MockLaunchesRepository extends _i1.Mock } @override - _i4.Future> getLaunches({ + _i5.Future> getLaunches({ bool? hasId = true, int? limit, int? offset, @@ -114,37 +128,37 @@ class MockLaunchesRepository extends _i1.Mock }, ), returnValue: - _i4.Future>.value(<_i2.LaunchResource>[]), - ) as _i4.Future>); + _i5.Future>.value(<_i2.LaunchResource>[]), + ) as _i5.Future>); @override - _i4.Future<_i2.LaunchFullResource> getLaunch(int? flightNumber) => + _i5.Future<_i2.LaunchFullResource> getLaunch(int? flightNumber) => (super.noSuchMethod( Invocation.method( #getLaunch, [flightNumber], ), returnValue: - _i4.Future<_i2.LaunchFullResource>.value(_FakeLaunchFullResource_0( + _i5.Future<_i2.LaunchFullResource>.value(_FakeLaunchFullResource_0( this, Invocation.method( #getLaunch, [flightNumber], ), )), - ) as _i4.Future<_i2.LaunchFullResource>); + ) as _i5.Future<_i2.LaunchFullResource>); } /// A class which mocks [RocketRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockRocketRepository extends _i1.Mock implements _i6.RocketRepository { +class MockRocketRepository extends _i1.Mock implements _i7.RocketRepository { MockRocketRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Future> getRockets({ + _i5.Future> getRockets({ bool? hasId = true, int? limit, int? offset, @@ -160,60 +174,105 @@ class MockRocketRepository extends _i1.Mock implements _i6.RocketRepository { }, ), returnValue: - _i4.Future>.value(<_i2.RocketResource>[]), - ) as _i4.Future>); + _i5.Future>.value(<_i2.RocketResource>[]), + ) as _i5.Future>); @override - _i4.Future<_i2.RocketResource> getRocket(String? rocketId) => + _i5.Future<_i2.RocketResource> getRocket(String? rocketId) => (super.noSuchMethod( Invocation.method( #getRocket, [rocketId], ), - returnValue: _i4.Future<_i2.RocketResource>.value(_FakeRocketResource_1( + returnValue: _i5.Future<_i2.RocketResource>.value(_FakeRocketResource_1( this, Invocation.method( #getRocket, [rocketId], ), )), - ) as _i4.Future<_i2.RocketResource>); + ) as _i5.Future<_i2.RocketResource>); } /// A class which mocks [RoadsterRepository]. /// /// See the documentation for Mockito's code generation for more information. class MockRoadsterRepository extends _i1.Mock - implements _i7.RoadsterRepository { + implements _i8.RoadsterRepository { MockRoadsterRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Future<_i3.RoadsterResource> getRoadster() => (super.noSuchMethod( + _i5.Future<_i3.RoadsterResource> getRoadster() => (super.noSuchMethod( Invocation.method( #getRoadster, [], ), returnValue: - _i4.Future<_i3.RoadsterResource>.value(_FakeRoadsterResource_2( + _i5.Future<_i3.RoadsterResource>.value(_FakeRoadsterResource_2( this, Invocation.method( #getRoadster, [], ), )), - ) as _i4.Future<_i3.RoadsterResource>); + ) as _i5.Future<_i3.RoadsterResource>); +} + +/// A class which mocks [CoresRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCoresRepository extends _i1.Mock implements _i9.CoresRepository { + MockCoresRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future> getCores({ + bool? hasId = true, + int? limit, + int? offset, + }) => + (super.noSuchMethod( + Invocation.method( + #getCores, + [], + { + #hasId: hasId, + #limit: limit, + #offset: offset, + }, + ), + returnValue: + _i5.Future>.value(<_i4.CoreResource>[]), + ) as _i5.Future>); + + @override + _i5.Future<_i4.CoreResource> getCore(String? coreSerial) => + (super.noSuchMethod( + Invocation.method( + #getCore, + [coreSerial], + ), + returnValue: _i5.Future<_i4.CoreResource>.value(_FakeCoreResource_3( + this, + Invocation.method( + #getCore, + [coreSerial], + ), + )), + ) as _i5.Future<_i4.CoreResource>); } /// A class which mocks [NavigatorObserver]. /// /// See the documentation for Mockito's code generation for more information. -class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { +class MockNavigatorObserver extends _i1.Mock implements _i10.NavigatorObserver { @override void didPush( - _i8.Route? route, - _i8.Route? previousRoute, + _i10.Route? route, + _i10.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -228,8 +287,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didPop( - _i8.Route? route, - _i8.Route? previousRoute, + _i10.Route? route, + _i10.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -244,8 +303,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didRemove( - _i8.Route? route, - _i8.Route? previousRoute, + _i10.Route? route, + _i10.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -260,8 +319,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didReplace({ - _i8.Route? newRoute, - _i8.Route? oldRoute, + _i10.Route? newRoute, + _i10.Route? oldRoute, }) => super.noSuchMethod( Invocation.method( @@ -277,8 +336,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didChangeTop( - _i8.Route? topRoute, - _i8.Route? previousTopRoute, + _i10.Route? topRoute, + _i10.Route? previousTopRoute, ) => super.noSuchMethod( Invocation.method( @@ -293,8 +352,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didStartUserGesture( - _i8.Route? route, - _i8.Route? previousRoute, + _i10.Route? route, + _i10.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( diff --git a/test/models/core/core_ext_test.dart b/test/models/core/core_ext_test.dart index 4af373b..f0ce00c 100644 --- a/test/models/core/core_ext_test.dart +++ b/test/models/core/core_ext_test.dart @@ -5,74 +5,109 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('CoreExt.toResource', () { - final json = { - 'core_serial': 'B1013', - 'flight': 1, - 'block': 1, - 'gridfins': true, - 'legs': true, - 'reused': false, - 'land_success': true, - 'landing_intent': true, - 'landing_type': 'Ocean', - 'landing_vehicle': null - }; + test('maps full NetworkCoreModel to CoreResource', () { + final network = const NetworkCoreModel( + coreSerial: 'Merlin2C', + block: null, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [ + NetworkMission(name: 'RatSat', flight: 4), + ], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: + 'Initially scheduled for 23–25 Sep, carried dummy payload – mass ' + 'simulator, 165 kg (originally intended to be RazakSAT.', + ); - test('should map all fields correctly from NetworkCoreModel', () { - final model = NetworkCoreModel.fromJson(json); - final resource = model.toResource(); + final resource = network.toResource(); expect( resource, - equals( - const CoreResource( - coreSerial: 'B1013', - flight: 1, - block: 1, - gridfins: true, - legs: true, - reused: false, - landSuccess: true, - landingIntent: true, - landingType: 'Ocean', - landingVehicle: null, - ), + const CoreResource( + coreSerial: 'Merlin2C', + block: null, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [ + MissionResource(name: 'RatSat', flight: 4), + ], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: + 'Initially scheduled for 23–25 Sep, carried dummy payload – mass ' + 'simulator, 165 kg (originally intended to be RazakSAT.', ), ); }); - test('should handle null fields correctly', () { - final model = const NetworkCoreModel( + test('handles null and empty fields gracefully', () { + final network = const NetworkCoreModel( coreSerial: null, - flight: null, block: null, - gridfins: null, - legs: null, - reused: null, - landSuccess: null, - landingIntent: null, - landingType: null, - landingVehicle: null, + status: null, + originalLaunch: null, + originalLaunchUnix: null, + missions: null, + reuseCount: null, + rtlsAttempts: null, + rtlsLandings: null, + asdsAttempts: null, + asdsLandings: null, + waterLanding: null, + details: null, ); - final resource = model.toResource(); + + final resource = network.toResource(); expect( resource, - equals( - const CoreResource( - coreSerial: null, - flight: null, - block: null, - gridfins: null, - legs: null, - reused: null, - landSuccess: null, - landingIntent: null, - landingType: null, - landingVehicle: null, - ), + const CoreResource( + coreSerial: null, + block: null, + status: null, + originalLaunch: null, + originalLaunchUnix: null, + missions: null, + reuseCount: null, + rtlsAttempts: null, + rtlsLandings: null, + asdsAttempts: null, + asdsLandings: null, + waterLanding: null, + details: null, ), ); }); + + test('maps multiple missions correctly', () { + final network = const NetworkCoreModel( + missions: [ + NetworkMission(name: 'RatSat', flight: 4), + NetworkMission(name: 'TestSat', flight: 5), + ], + ); + + final resource = network.toResource(); + + expect( + resource.missions, + const [ + MissionResource(name: 'RatSat', flight: 4), + MissionResource(name: 'TestSat', flight: 5), + ], + ); + }); }); } diff --git a/test/models/core/core_resource_test.dart b/test/models/core/core_resource_test.dart index 0b3dd5a..85fe4a8 100644 --- a/test/models/core/core_resource_test.dart +++ b/test/models/core/core_resource_test.dart @@ -2,71 +2,178 @@ import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('MissionResource', () { + test('supports equality', () { + const m1 = MissionResource(name: 'RatSat', flight: 4); + const m2 = MissionResource(name: 'RatSat', flight: 4); + const m3 = MissionResource(name: 'TestSat', flight: 5); + + expect(m1, equals(m2)); + expect(m1.hashCode, equals(m2.hashCode)); + expect(m1, isNot(equals(m3))); + }); + + test('props contains all fields', () { + const mission = MissionResource(name: 'RatSat', flight: 4); + expect(mission.props, ['RatSat', 4]); + }); + }); + group('CoreResource', () { const core1 = CoreResource( - coreSerial: 'B1013', - flight: 1, + coreSerial: 'Merlin2C', block: 1, - gridfins: true, - legs: true, - reused: false, - landSuccess: true, - landingIntent: true, - landingType: 'Ocean', - landingVehicle: null, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [MissionResource(name: 'RatSat', flight: 4)], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: 'Some details', ); const core2 = CoreResource( - coreSerial: 'B1013', - flight: 1, + coreSerial: 'Merlin2C', block: 1, - gridfins: true, - legs: true, - reused: false, - landSuccess: true, - landingIntent: true, - landingType: 'Ocean', - landingVehicle: null, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [MissionResource(name: 'RatSat', flight: 4)], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: 'Some details', ); - const coreDifferent = CoreResource( - coreSerial: 'B1014', - flight: 2, - block: 2, - gridfins: false, - legs: false, - reused: true, - landSuccess: false, - landingIntent: false, - landingType: 'ASDS', - landingVehicle: 'OCISLY', + const core3 = CoreResource( + coreSerial: 'OtherCore', + status: 'active', ); - test('should support value equality', () { + test('supports equality', () { expect(core1, equals(core2)); expect(core1.hashCode, equals(core2.hashCode)); + expect(core1, isNot(equals(core3))); }); - test('should detect inequality when fields differ', () { - expect(core1, isNot(equals(coreDifferent))); + test('should copyWith correctly', () { + final core = const CoreResource(coreSerial: 'B1049', block: 5); + final copied = core.copyWith( + coreSerial: 'B1051', + block: 6, + ); + + expect(copied.coreSerial, 'B1051'); + expect(copied.block, 6); + expect(copied.status, isNull); // other fields stay the same }); - test('props should include all fields', () { + test('copyWith with no arguments should return identical object', () { + final core = const CoreResource( + coreSerial: 'B1049', + block: 5, + status: 'active', + ); + + final copied = core.copyWith(); + + expect(copied, equals(core)); // Equatable should ensure equality expect( - core1.props, - equals([ - 'B1013', - 1, - 1, - true, - true, - false, - true, - true, - 'Ocean', - null, - ]), + identical(copied, core), isFalse); // should not be the same reference + }); + + test('should check equality', () { + final core1 = const CoreResource(coreSerial: 'B1049', block: 5); + final core2 = const CoreResource(coreSerial: 'B1049', block: 5); + final core3 = const CoreResource(coreSerial: 'B1051', block: 5); + + expect(core1, core2); + expect(core1 == core3, isFalse); + }); + + test('props contains all fields', () { + expect(core1.props, [ + 'Merlin2C', + 1, + 'lost', + '2008-09-28T23:15:00.000Z', + 1222643700, + [const MissionResource(name: 'RatSat', flight: 4)], + 0, + 0, + 0, + 0, + 0, + false, + 'Some details', + ]); + }); + + test('props contains all fields', () { + final missionList = [const MissionResource(name: 'Falcon 9', flight: 1)]; + + final core = CoreResource( + coreSerial: 'B1049', + block: 5, + status: 'active', + originalLaunch: '2020-06-30', + originalLaunchUnix: 1593504000, + missions: missionList, + reuseCount: 3, + rtlsAttempts: 3, + rtlsLandings: 3, + asdsAttempts: 1, + asdsLandings: 1, + waterLanding: false, + details: 'Recovered from drone ship', ); + + expect(core.props, [ + 'B1049', + 5, + 'active', + '2020-06-30', + 1593504000, + missionList, + 3, + 3, + 3, + 1, + 1, + false, + 'Recovered from drone ship', + ]); + }); + + test('equality and hashCode are based on props', () { + final core1 = + const CoreResource(coreSerial: 'B1049', block: 5, status: 'active'); + final core2 = + const CoreResource(coreSerial: 'B1049', block: 5, status: 'active'); + final core3 = + const CoreResource(coreSerial: 'B1051', block: 5, status: 'active'); + + // core1 and core2 have identical props + expect(core1, equals(core2)); + expect(core1.hashCode, equals(core2.hashCode)); + + // core3 differs in coreSerial + expect(core1, isNot(equals(core3))); + expect(core1.hashCode, isNot(equals(core3.hashCode))); + }); + + test('is immutable', () { + // trying to reassign a const field will cause compile-time error, + // so here we just verify object identity + const modified = CoreResource(coreSerial: 'Merlin2C'); + expect(modified.coreSerial, 'Merlin2C'); }); }); } diff --git a/test/models/launch/rocket_resource_ext_test.dart b/test/models/launch/rocket_resource_ext_test.dart index 410a793..7b0a73c 100644 --- a/test/models/launch/rocket_resource_ext_test.dart +++ b/test/models/launch/rocket_resource_ext_test.dart @@ -1,13 +1,12 @@ -import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/launch/network_launch_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/payload/network_payload_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/rocket/network_rocket_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; import 'package:flutter_bloc_app_template/data/network/model/stage/network_second_stage_model.dart'; import 'package:flutter_bloc_app_template/index.dart'; -import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; import 'package:flutter_bloc_app_template/models/payload/payload_resource.dart'; import 'package:flutter_bloc_app_template/models/rocket/rocket_ext.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -17,8 +16,8 @@ void main() { type: 'FT', firstStage: NetworkFirstStageModel( cores: [ - NetworkCoreModel(coreSerial: 'B1013'), - NetworkCoreModel(coreSerial: 'B1014'), + NetworkStageCoreModel(coreSerial: 'B1013'), + NetworkStageCoreModel(coreSerial: 'B1014'), ], ), secondStage: NetworkSecondStageModel( @@ -39,8 +38,8 @@ void main() { // First stage expect(resource.firstStage?.cores, [ - const CoreResource(coreSerial: 'B1013'), - const CoreResource(coreSerial: 'B1014'), + const StageCoreResource(coreSerial: 'B1013'), + const StageCoreResource(coreSerial: 'B1014'), ]); // Second stage diff --git a/test/models/launch/rocket_resource_test.dart b/test/models/launch/rocket_resource_test.dart index ded020e..1c1f662 100644 --- a/test/models/launch/rocket_resource_test.dart +++ b/test/models/launch/rocket_resource_test.dart @@ -1,14 +1,14 @@ -import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; import 'package:flutter_bloc_app_template/models/launch/launch_rocket_resource.dart'; import 'package:flutter_bloc_app_template/models/payload/payload_resource.dart'; import 'package:flutter_bloc_app_template/models/stage/first_stage_resource.dart'; import 'package:flutter_bloc_app_template/models/stage/second_stage_resource.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('RocketResource', () { - const core1 = CoreResource(coreSerial: 'B1013'); - const core2 = CoreResource(coreSerial: 'B1014'); + const core1 = StageCoreResource(coreSerial: 'B1013'); + const core2 = StageCoreResource(coreSerial: 'B1014'); const firstStage = FirstStageResource(cores: [core1, core2]); const payload1 = PayloadResource(payloadId: 'FalconSAT-2'); @@ -60,8 +60,8 @@ void main() { test('supports deep equality on nested lists', () { const firstStage2 = FirstStageResource(cores: [ - CoreResource(coreSerial: 'B1013'), - CoreResource(coreSerial: 'B1014'), + StageCoreResource(coreSerial: 'B1013'), + StageCoreResource(coreSerial: 'B1014'), ]); const secondStage2 = SecondStageResource(block: 1, payloads: [ PayloadResource(payloadId: 'FalconSAT-2'), diff --git a/test/models/first_stage_ext_test.dart b/test/models/stage/first_stage_ext_test.dart similarity index 89% rename from test/models/first_stage_ext_test.dart rename to test/models/stage/first_stage_ext_test.dart index 6f45f31..be24df7 100644 --- a/test/models/first_stage_ext_test.dart +++ b/test/models/stage/first_stage_ext_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; -import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; import 'package:flutter_bloc_app_template/models/stage/first_stage_ext.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -34,11 +34,11 @@ void main() { ] }); - test('should map cores to CoreResource correctly', () { + test('should map cores to StageCoreResource correctly', () { final resource = model.toResource(); expect(resource.cores, [ - const CoreResource( + const StageCoreResource( coreSerial: 'B1013', flight: 1, block: 1, @@ -50,7 +50,7 @@ void main() { landingType: 'Ocean', landingVehicle: null, ), - const CoreResource( + const StageCoreResource( coreSerial: 'B1014', flight: 2, block: 2, diff --git a/test/models/first_stage_resource_test.dart b/test/models/stage/first_stage_resource_test.dart similarity index 90% rename from test/models/first_stage_resource_test.dart rename to test/models/stage/first_stage_resource_test.dart index df5a703..ab8a235 100644 --- a/test/models/first_stage_resource_test.dart +++ b/test/models/stage/first_stage_resource_test.dart @@ -1,10 +1,10 @@ -import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; import 'package:flutter_bloc_app_template/models/stage/first_stage_resource.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('FirstStageResource', () { - const core1 = CoreResource( + const core1 = StageCoreResource( coreSerial: 'B1013', flight: 1, block: 1, @@ -17,7 +17,7 @@ void main() { landingVehicle: null, ); - const core2 = CoreResource( + const core2 = StageCoreResource( coreSerial: 'B1014', flight: 2, block: 2, diff --git a/test/models/second_stage_ext_test.dart b/test/models/stage/second_stage_ext_test.dart similarity index 100% rename from test/models/second_stage_ext_test.dart rename to test/models/stage/second_stage_ext_test.dart diff --git a/test/models/second_stage_resource_test.dart b/test/models/stage/second_stage_resource_test.dart similarity index 100% rename from test/models/second_stage_resource_test.dart rename to test/models/stage/second_stage_resource_test.dart diff --git a/test/models/stage/stage_core_ext_test.dart b/test/models/stage/stage_core_ext_test.dart new file mode 100644 index 0000000..6c9b2c2 --- /dev/null +++ b/test/models/stage/stage_core_ext_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_bloc_app_template/data/network/model/stage/network_first_stage_model.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_ext.dart'; +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CoreExt.toResource', () { + final json = { + 'core_serial': 'B1013', + 'flight': 1, + 'block': 1, + 'gridfins': true, + 'legs': true, + 'reused': false, + 'land_success': true, + 'landing_intent': true, + 'landing_type': 'Ocean', + 'landing_vehicle': null + }; + + test('should map all fields correctly from NetworkStageCoreModel', () { + final model = NetworkStageCoreModel.fromJson(json); + final resource = model.toResource(); + + expect( + resource, + equals( + const StageCoreResource( + coreSerial: 'B1013', + flight: 1, + block: 1, + gridfins: true, + legs: true, + reused: false, + landSuccess: true, + landingIntent: true, + landingType: 'Ocean', + landingVehicle: null, + ), + ), + ); + }); + + test('should handle null fields correctly', () { + final model = const NetworkStageCoreModel( + coreSerial: null, + flight: null, + block: null, + gridfins: null, + legs: null, + reused: null, + landSuccess: null, + landingIntent: null, + landingType: null, + landingVehicle: null, + ); + final resource = model.toResource(); + + expect( + resource, + equals( + const StageCoreResource( + coreSerial: null, + flight: null, + block: null, + gridfins: null, + legs: null, + reused: null, + landSuccess: null, + landingIntent: null, + landingType: null, + landingVehicle: null, + ), + ), + ); + }); + }); +} diff --git a/test/models/stage/stage_core_resource_test.dart b/test/models/stage/stage_core_resource_test.dart new file mode 100644 index 0000000..1b37273 --- /dev/null +++ b/test/models/stage/stage_core_resource_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_bloc_app_template/models/stage/stage_core_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('StageCoreResource', () { + const core1 = StageCoreResource( + coreSerial: 'B1013', + flight: 1, + block: 1, + gridfins: true, + legs: true, + reused: false, + landSuccess: true, + landingIntent: true, + landingType: 'Ocean', + landingVehicle: null, + ); + + const core2 = StageCoreResource( + coreSerial: 'B1013', + flight: 1, + block: 1, + gridfins: true, + legs: true, + reused: false, + landSuccess: true, + landingIntent: true, + landingType: 'Ocean', + landingVehicle: null, + ); + + const coreDifferent = StageCoreResource( + coreSerial: 'B1014', + flight: 2, + block: 2, + gridfins: false, + legs: false, + reused: true, + landSuccess: false, + landingIntent: false, + landingType: 'ASDS', + landingVehicle: 'OCISLY', + ); + + test('should support value equality', () { + expect(core1, equals(core2)); + expect(core1.hashCode, equals(core2.hashCode)); + }); + + test('should detect inequality when fields differ', () { + expect(core1, isNot(equals(coreDifferent))); + }); + + test('props should include all fields', () { + expect( + core1.props, + equals([ + 'B1013', + 1, + 1, + true, + true, + false, + true, + true, + 'Ocean', + null, + ]), + ); + }); + }); +} diff --git a/test/repository/cores_repository_impl_test.dart b/test/repository/cores_repository_impl_test.dart new file mode 100644 index 0000000..748ff73 --- /dev/null +++ b/test/repository/cores_repository_impl_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/cores_network_data_source.dart'; +import 'package:flutter_bloc_app_template/data/network/model/core/network_core_model.dart'; +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockCoresDataSource extends Mock implements CoresDataSource {} + +void main() { + late MockCoresDataSource mockDataSource; + late CoresRepositoryImpl repository; + + setUp(() { + mockDataSource = MockCoresDataSource(); + repository = CoresRepositoryImpl(mockDataSource); + }); + + group('CoresRepositoryImpl', () { + group('getCores', () { + test('returns list of CoreResource when success', () async { + final mockNetworkCores = [ + const NetworkCoreModel( + coreSerial: 'B1049', + block: 5, + status: 'active', + ), + ]; + + when(() => mockDataSource.getCores( + hasId: any(named: 'hasId'), + limit: any(named: 'limit'), + offset: any(named: 'offset'), + )).thenAnswer((_) async => ApiResult.success(mockNetworkCores)); + + final result = await repository.getCores(); + + expect(result, isA>()); + expect(result.first.coreSerial, 'B1049'); + }); + + test('throws exception when error', () async { + when(() => mockDataSource.getCores( + hasId: any(named: 'hasId'), + limit: any(named: 'limit'), + offset: any(named: 'offset'), + )).thenAnswer((_) async => const ApiResult.error('Network error')); + + expect(() => repository.getCores(), throwsA(isA())); + }); + + test('throws exception when loading', () async { + when(() => mockDataSource.getCores( + hasId: any(named: 'hasId'), + limit: any(named: 'limit'), + offset: any(named: 'offset'), + )).thenAnswer((_) async => const ApiResult.loading()); + + expect(() => repository.getCores(), throwsA(isA())); + }); + }); + + group('getCore', () { + test('returns CoreResource when success', () async { + final mockNetworkCore = const NetworkCoreModel( + coreSerial: 'B1049', + block: 5, + status: 'active', + ); + + when(() => mockDataSource.getCore('B1049')) + .thenAnswer((_) async => ApiResult.success(mockNetworkCore)); + + final result = await repository.getCore('B1049'); + + expect(result, isA()); + expect(result.coreSerial, 'B1049'); + expect(result.status, 'active'); + }); + + test('throws exception when error', () async { + when(() => mockDataSource.getCore('B1049')) + .thenAnswer((_) async => const ApiResult.error('Not found')); + + expect(() => repository.getCore('B1049'), throwsA(isA())); + }); + + test('throws exception when loading', () async { + when(() => mockDataSource.getCore('B1049')) + .thenAnswer((_) async => const ApiResult.loading()); + + expect(() => repository.getCore('B1049'), throwsA(isA())); + }); + }); + }); +} diff --git a/test/repository/cores_repository_test.dart b/test/repository/cores_repository_test.dart new file mode 100644 index 0000000..206d762 --- /dev/null +++ b/test/repository/cores_repository_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_bloc_app_template/models/core/core_resource.dart'; +import 'package:flutter_bloc_app_template/repository/cores_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../mocks.mocks.dart'; + +void main() { + group('Cores Repository Tests', () { + late CoresRepository repository; + + setUp(() { + repository = MockCoresRepository(); + }); + + group('getCores', () { + test('returns list of core', () { + when(repository.getCores()).thenAnswer((_) => Future.value(mockCores)); + expect( + repository.getCores(), + completion(equals(mockCores)), + ); + }); + + test('returns empty list', () { + when(repository.getCores()).thenAnswer((_) => Future.value([])); + expect( + repository.getCores(), + completion(equals([])), + ); + }); + test('returns error', () { + when(repository.getCores()).thenAnswer((_) => Future.error(Error())); + + expect( + repository.getCores(), + throwsA(isA()), + ); + }); + }); + + group('getCore', () { + final coreSerial = 'Merlin2A'; + test('returns full core', () { + when(repository.getCore(coreSerial)) + .thenAnswer((_) => Future.value(mockCoreActive)); + expect( + repository.getCore(coreSerial), + completion(equals(mockCoreActive)), + ); + }); + + test('returns error', () { + when(repository.getCore(coreSerial)) + .thenAnswer((_) => Future.error(Error())); + + expect( + repository.getCore(coreSerial), + throwsA(isA()), + ); + }); + }); + }); +} + +/// Mock MissionResource instances +const mockMissionRatSat = MissionResource( + name: 'RatSat', + flight: 4, +); + +const mockMissionTestSat = MissionResource( + name: 'TestSat', + flight: 5, +); + +/// Mock CoreResource instances +const mockCoreLost = CoreResource( + coreSerial: 'Merlin2C', + block: 1, + status: 'lost', + originalLaunch: '2008-09-28T23:15:00.000Z', + originalLaunchUnix: 1222643700, + missions: [mockMissionRatSat], + reuseCount: 0, + rtlsAttempts: 0, + rtlsLandings: 0, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: 'Initially scheduled for 23–25 Sep, carried dummy payload – mass ' + 'simulator, 165 kg (originally intended to be RazakSAT.', +); + +const mockCoreActive = CoreResource( + coreSerial: 'MerlinX1', + block: 2, + status: 'active', + originalLaunch: '2010-06-04T18:45:00.000Z', + originalLaunchUnix: 1275677100, + missions: [mockMissionTestSat], + reuseCount: 1, + rtlsAttempts: 1, + rtlsLandings: 1, + asdsAttempts: 0, + asdsLandings: 0, + waterLanding: false, + details: 'Successfully launched a test satellite.', +); + +/// A list of multiple mock cores (for collections tests) +const mockCores = [ + mockCoreLost, + mockCoreActive, +];