diff --git a/assets/images/img_spacex_launch.jpeg b/assets/images/img_spacex_launch.jpeg new file mode 100644 index 0000000..28b8af4 Binary files /dev/null and b/assets/images/img_spacex_launch.jpeg differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index febcef1..3644e31 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -168,7 +168,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1210; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8605956..c87d15a 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Navigator.of(context).push(LaunchesPage.route()), + title: Text(l10n.latestLaunchSpaceXTileTitle), + imageUrl: 'assets/images/img_spacex_launch.jpeg', + ), + ), + ), Expanded( child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 315c73a..eaef4fd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4,15 +4,23 @@ "@homeAppBarTitle": { "description": "Text shown in the AppBar of the Home Page" }, - "rocketSpaceXTileTitle": "Rockets", - "@rocketSpaceXTileTitle": { - "description": "Text included as title on the rockets tile on the home page" - }, - "crewSpaceXTileTitle": "Crew", - "@crewSpaceXTileTitle": { - "description": "Text included as title on the crew tile on the home page" - }, - "rocketsAppBarTitle": "Rockets", + "rocketSpaceXTileTitle": "Rockets", + "@rocketSpaceXTileTitle": { + "description": "Text included as title on the rockets tile on the home page" + }, + "crewSpaceXTileTitle": "Crew", + "@crewSpaceXTileTitle": { + "description": "Text included as title on the crew tile on the home page" + }, + "latestLaunchSpaceXTileTitle": "Latest Launch", + "@latestLaunchSpaceXTileTitle": { + "description": "Text included as title on the latest launch tile on the home page" + }, + "latestLaunchAppBarTitle": "Latest Launch", + "@latestLaunchAppBarTitle": { + "description": "Text shown in the AppBar of the latest Launch Page" + }, + "rocketsAppBarTitle": "Rockets", "@rocketsAppBarTitle": { "description": "Text shown in the AppBar of the Rockets Page" }, @@ -29,11 +37,24 @@ } } }, + "latestLaunchSubtitle": "Launched: {date}", + "@latestLaunchSubtitle": { + "description": "Subtitle text shown on the Launches Page that indicates the latest launch.", + "placeholders": { + "date": { + "example": "31-12-2021" + } + } + }, + "openWebcastButtonText": "Open Webcast", + "@openWebcastButtonText": { + "description": "Button text shown on the Rocket Details Page that opens the corresponding Webcast page." + }, "openWikipediaButtonText": "Open Wikipedia", "@openWikipediaButtonText": { "description": "Button text shown on the Rocket Details Page that opens the corresponding Wikipedia page." }, - "crewAppBarTitle": "Crew", + "crewAppBarTitle": "Crew", "@crewAppBarTitle": { "description": "Text shown in the AppBar of the Crew Page" }, @@ -41,20 +62,20 @@ "@crewFetchErrorMessage": { "description": "Error text shown on the Home Page when an error occurred while fetching crew members." }, - "crewMemberDetailsAgency": "Agency", + "crewMemberDetailsAgency": "Agency", "@crewMemberDetailsAgency": { "description": "Prefix word placed in the 1st subtitle of the crew member details page" }, - "crewMemberDetailsParticipatedLaunches": "Has participated in", + "crewMemberDetailsParticipatedLaunches": "Has participated in", "@crewMemberDetailsParticipatedLaunches": { "description": "Prefix text placed in the 2nd subtitle of the crew member details page" }, - "crewMemberDetailsLaunch": "launch", + "crewMemberDetailsLaunch": "launch", "@crewMemberDetailsLaunch": { "description": "Singular suffix word placed in the 2nd subtitle of the crew member details page" }, - "crewMemberDetailsLaunches": "launches", + "crewMemberDetailsLaunches": "launches", "@crewMemberDetailsLaunches": { "description": "Plural suffix word placed in the 2nd subtitle of the crew member details page" } -} +} \ No newline at end of file diff --git a/lib/launches/cubit/launches_cubit.dart b/lib/launches/cubit/launches_cubit.dart new file mode 100644 index 0000000..f698af9 --- /dev/null +++ b/lib/launches/cubit/launches_cubit.dart @@ -0,0 +1,41 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:launch_repository/launch_repository.dart'; +import 'package:spacex_api/spacex_api.dart'; + +part 'launches_state.dart'; + +class LaunchesCubit extends Cubit { + LaunchesCubit({ + required LaunchRepository launchRepository, + }) : _launchRepository = launchRepository, + super(const LaunchesState()); + + final LaunchRepository _launchRepository; + + Future fetchLatestLaunch() async { + emit( + LaunchesState( + status: LaunchesStatus.loading, + latestLaunch: state.latestLaunch, + ), + ); + + try { + final latestLaunch = await _launchRepository.fetchLatestLaunch(); + emit( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ); + } on Exception { + emit( + LaunchesState( + status: LaunchesStatus.failure, + latestLaunch: state.latestLaunch, + ), + ); + } + } +} diff --git a/lib/launches/cubit/launches_state.dart b/lib/launches/cubit/launches_state.dart new file mode 100644 index 0000000..8dddc84 --- /dev/null +++ b/lib/launches/cubit/launches_state.dart @@ -0,0 +1,16 @@ +part of 'launches_cubit.dart'; + +enum LaunchesStatus { initial, loading, success, failure } + +class LaunchesState extends Equatable { + const LaunchesState({ + this.status = LaunchesStatus.initial, + this.latestLaunch, + }); + + final LaunchesStatus status; + final Launch? latestLaunch; + + @override + List get props => [status, latestLaunch]; +} diff --git a/lib/launches/launches.dart b/lib/launches/launches.dart new file mode 100644 index 0000000..74f074c --- /dev/null +++ b/lib/launches/launches.dart @@ -0,0 +1,2 @@ +export 'cubit/launches_cubit.dart'; +export 'view/launches_page.dart'; diff --git a/lib/launches/view/launches_page.dart b/lib/launches/view/launches_page.dart new file mode 100644 index 0000000..4388efb --- /dev/null +++ b/lib/launches/view/launches_page.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:launch_repository/launch_repository.dart'; +import 'package:spacex_api/spacex_api.dart'; +import 'package:spacex_demo/l10n/l10n.dart'; +import 'package:spacex_demo/launches/launches.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LaunchesPage extends StatelessWidget { + const LaunchesPage({Key? key}) : super(key: key); + + static Route route() { + return MaterialPageRoute( + builder: (context) => const LaunchesPage(), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => LaunchesCubit( + launchRepository: context.read(), + )..fetchLatestLaunch(), + child: const LaunchesView(), + ); + } +} + +class LaunchesView extends StatelessWidget { + const LaunchesView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.latestLaunchAppBarTitle), + ), + body: const Center( + child: _LaunchesContent(), + ), + ); + } +} + +class _LaunchesContent extends StatelessWidget { + const _LaunchesContent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final state = context.watch().state; + + switch (state.status) { + case LaunchesStatus.initial: + return const SizedBox( + key: Key('launchesView_initial_sizedBox'), + ); + case LaunchesStatus.loading: + return const Center( + key: Key('launchesView_loading_indicator'), + child: CircularProgressIndicator.adaptive(), + ); + case LaunchesStatus.failure: + return Center( + key: const Key('launchesView_failure_text'), + child: Text(l10n.rocketsFetchErrorMessage), + ); + case LaunchesStatus.success: + return _LatestLaunch( + key: const Key('launchesView_success_latestLaunch'), + latestLaunch: state.latestLaunch!, + ); + } + } +} + +class _LatestLaunch extends StatelessWidget { + const _LatestLaunch({Key? key, required this.latestLaunch}) : super(key: key); + + final Launch latestLaunch; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Column( + children: [ + ListTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + CircleAvatar( + backgroundImage: NetworkImage( + latestLaunch.links.patch.small ?? + 'assets/images/img_spacex_launch.jpeg', + ), + radius: 50, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text(latestLaunch.name), + Text('${latestLaunch.flightNumber}'), + ], + ), + ) + ], + ), + subtitle: latestLaunch.dateUtc == null + ? null + : Text( + l10n.latestLaunchSubtitle( + DateFormat('dd-MM-yyyy hh:mm') + .format(latestLaunch.dateUtc!), + ), + ), + ), + const SizedBox( + height: 35, + ), + Row( + key: const Key('launchesPage_link_buttons'), + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Container( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: 64, + child: ElevatedButton( + key: const Key( + 'launchesPage_openWebcast_elevatedButton', + ), + onPressed: () async { + final url = latestLaunch.links.webcast; + + if (await canLaunch(url!)) { + await launch(url); + } + }, + child: Text(l10n.openWebcastButtonText), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: 64, + child: ElevatedButton( + key: const Key( + 'launchesPage_openWikipedia_elevatedButton', + ), + onPressed: () async { + final url = latestLaunch.links.wikipedia; + + if (await canLaunch(url!)) { + await launch(url); + } + }, + child: Text(l10n.openWikipediaButtonText), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/main_development.dart b/lib/main_development.dart index 58cb9f4..989c8c4 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:crew_member_repository/crew_member_repository.dart'; import 'package:flutter/widgets.dart'; +import 'package:launch_repository/launch_repository.dart'; import 'package:rocket_repository/rocket_repository.dart'; import 'package:spacex_demo/app/app.dart'; @@ -13,12 +14,14 @@ void main() { final rocketRepository = RocketRepository(); final crewMemberRepository = CrewMemberRepository(); + final launchRepository = LaunchRepository(); runZonedGuarded( () => runApp( App( rocketRepository: rocketRepository, crewMemberRepository: crewMemberRepository, + launchRepository: launchRepository, ), ), (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), diff --git a/lib/main_production.dart b/lib/main_production.dart index 58cb9f4..989c8c4 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:crew_member_repository/crew_member_repository.dart'; import 'package:flutter/widgets.dart'; +import 'package:launch_repository/launch_repository.dart'; import 'package:rocket_repository/rocket_repository.dart'; import 'package:spacex_demo/app/app.dart'; @@ -13,12 +14,14 @@ void main() { final rocketRepository = RocketRepository(); final crewMemberRepository = CrewMemberRepository(); + final launchRepository = LaunchRepository(); runZonedGuarded( () => runApp( App( rocketRepository: rocketRepository, crewMemberRepository: crewMemberRepository, + launchRepository: launchRepository, ), ), (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 58cb9f4..989c8c4 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:crew_member_repository/crew_member_repository.dart'; import 'package:flutter/widgets.dart'; +import 'package:launch_repository/launch_repository.dart'; import 'package:rocket_repository/rocket_repository.dart'; import 'package:spacex_demo/app/app.dart'; @@ -13,12 +14,14 @@ void main() { final rocketRepository = RocketRepository(); final crewMemberRepository = CrewMemberRepository(); + final launchRepository = LaunchRepository(); runZonedGuarded( () => runApp( App( rocketRepository: rocketRepository, crewMemberRepository: crewMemberRepository, + launchRepository: launchRepository, ), ), (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), diff --git a/packages/launch_repository/.gitignore b/packages/launch_repository/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/launch_repository/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/launch_repository/README.md b/packages/launch_repository/README.md new file mode 100644 index 0000000..eb7ef81 --- /dev/null +++ b/packages/launch_repository/README.md @@ -0,0 +1,11 @@ +# launch_repository + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A Dart package to manage the launches + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/packages/launch_repository/analysis_options.yaml b/packages/launch_repository/analysis_options.yaml new file mode 100644 index 0000000..3742fc3 --- /dev/null +++ b/packages/launch_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml \ No newline at end of file diff --git a/packages/launch_repository/example/.gitignore b/packages/launch_repository/example/.gitignore new file mode 100644 index 0000000..526da15 --- /dev/null +++ b/packages/launch_repository/example/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/launch_repository/example/analysis_options.yaml b/packages/launch_repository/example/analysis_options.yaml new file mode 100644 index 0000000..f116a5a --- /dev/null +++ b/packages/launch_repository/example/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml +linter: + rules: + public_member_api_docs: false + avoid_print: false diff --git a/packages/launch_repository/example/lib/main.dart b/packages/launch_repository/example/lib/main.dart new file mode 100644 index 0000000..6255ef6 --- /dev/null +++ b/packages/launch_repository/example/lib/main.dart @@ -0,0 +1,12 @@ +import 'package:launch_repository/launch_repository.dart'; + +Future main() async { + final launchRepository = LaunchRepository(); + try { + final latestLaunch = await launchRepository.fetchLatestLaunch(); + + print(latestLaunch); + } on Exception catch (e) { + print(e); + } +} diff --git a/packages/launch_repository/example/pubspec.yaml b/packages/launch_repository/example/pubspec.yaml new file mode 100644 index 0000000..65316ff --- /dev/null +++ b/packages/launch_repository/example/pubspec.yaml @@ -0,0 +1,14 @@ +name: launch_repository_example +description: A small example package showcasing the launch_repository. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=2.14.0 <3.0.0' + +dependencies: + launch_repository: + path: ../ + +dev_dependencies: + very_good_analysis: ^2.4.0 diff --git a/packages/launch_repository/lib/launch_repository.dart b/packages/launch_repository/lib/launch_repository.dart new file mode 100644 index 0000000..9e320bd --- /dev/null +++ b/packages/launch_repository/lib/launch_repository.dart @@ -0,0 +1,3 @@ +library launch_repository; + +export 'src/launch_repository.dart'; diff --git a/packages/launch_repository/lib/src/launch_repository.dart b/packages/launch_repository/lib/src/launch_repository.dart new file mode 100644 index 0000000..c84a681 --- /dev/null +++ b/packages/launch_repository/lib/src/launch_repository.dart @@ -0,0 +1,26 @@ +import 'package:spacex_api/spacex_api.dart'; + +///Thrown when an error occurs while looking up for launches +class LaunchException implements Exception {} + +/// {@template launch_repository} +/// A Dart package to manage the launches +/// {@endtemplate} +class LaunchRepository { + /// {@macro launches_repository} + LaunchRepository({SpaceXApiClient? spaceXApiClient}) + : _spaceXApiClient = spaceXApiClient ?? SpaceXApiClient(); + + final SpaceXApiClient _spaceXApiClient; + + /// Returns the latest launch. + /// + /// Throws a [LaunchException] if an error occurs. + Future fetchLatestLaunch() { + try { + return _spaceXApiClient.fetchLatestLaunch(); + } on Exception { + throw LaunchException(); + } + } +} diff --git a/packages/launch_repository/pubspec.yaml b/packages/launch_repository/pubspec.yaml new file mode 100644 index 0000000..84419c4 --- /dev/null +++ b/packages/launch_repository/pubspec.yaml @@ -0,0 +1,17 @@ +name: launch_repository +description: A Dart package to manage the launches +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=2.14.0 <3.0.0' + +dependencies: + spacex_api: + path: ../spacex_api + +dev_dependencies: + coverage: ^1.0.2 + mocktail: ^0.1.1 + test: ^1.19.2 + very_good_analysis: ^2.4.0 diff --git a/packages/launch_repository/test/src/launch_repository_test.dart b/packages/launch_repository/test/src/launch_repository_test.dart new file mode 100644 index 0000000..a72b8ba --- /dev/null +++ b/packages/launch_repository/test/src/launch_repository_test.dart @@ -0,0 +1,71 @@ +// ignore_for_file: prefer_const_constructors +import 'package:launch_repository/launch_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:spacex_api/spacex_api.dart'; +import 'package:test/test.dart'; + +class MockSpaceXApiClient extends Mock implements SpaceXApiClient {} + +void main() { + group('LaunchRepository', () { + late SpaceXApiClient spaceXApiClient; + late LaunchRepository subject; + + final date = DateTime.now(); + + final latestLaunch = Launch( + id: '0', + name: 'mock-launch-name', + dateLocal: date, + dateUtc: date, + links: const Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4', + large: 'https://avatars.githubusercontent.com/u/2918581?v=4', + ), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/', + ), + ); + + setUp(() { + spaceXApiClient = MockSpaceXApiClient(); + when(() => spaceXApiClient.fetchLatestLaunch()) + .thenAnswer((_) async => latestLaunch); + + subject = LaunchRepository(spaceXApiClient: spaceXApiClient); + }); + + test('constructor returns normally', () { + expect( + () => LaunchRepository(), + returnsNormally, + ); + }); + + group('.fetchLatestLaunch', () { + test('throws LaunchException when api throws an exception', () async { + when(() => spaceXApiClient.fetchLatestLaunch()).thenThrow(Exception()); + + expect( + () => subject.fetchLatestLaunch(), + throwsA(isA()), + ); + + verify(() => spaceXApiClient.fetchLatestLaunch()).called(1); + }); + + test('makes correct request', () async { + await subject.fetchLatestLaunch(); + + verify(() => spaceXApiClient.fetchLatestLaunch()).called(1); + }); + }); + + test('makes correct request', () async { + await subject.fetchLatestLaunch(); + + verify(() => spaceXApiClient.fetchLatestLaunch()).called(1); + }); + }); +} diff --git a/packages/rocket_repository/test/rocket_repository_test.dart b/packages/rocket_repository/test/rocket_repository_test.dart index 4c946b1..184313d 100644 --- a/packages/rocket_repository/test/rocket_repository_test.dart +++ b/packages/rocket_repository/test/rocket_repository_test.dart @@ -19,6 +19,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ), ); diff --git a/packages/spacex_api/build.yaml b/packages/spacex_api/build.yaml new file mode 100644 index 0000000..5d6aeda --- /dev/null +++ b/packages/spacex_api/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + json_serializable: + options: + field_rename: snake diff --git a/packages/spacex_api/lib/src/models/crew_member.dart b/packages/spacex_api/lib/src/models/crew_member.dart index b24430f..b9b778c 100644 --- a/packages/spacex_api/lib/src/models/crew_member.dart +++ b/packages/spacex_api/lib/src/models/crew_member.dart @@ -6,7 +6,7 @@ part 'crew_member.g.dart'; /// {@template crew_member} /// A model containing data about a SpaceX crew member /// {@endtemplate} -@JsonSerializable(fieldRename: FieldRename.snake) +@JsonSerializable() class CrewMember extends Equatable { /// {@macro crew_member} const CrewMember({ @@ -60,9 +60,6 @@ class CrewMember extends Equatable { /// Converts this [CrewMember] instance into a JSON [Map] Map toJson() => _$CrewMemberToJson(this); - @override - bool get stringify => true; - @override String toString() => 'Crew Member($id, $name)'; } diff --git a/packages/spacex_api/lib/src/models/crew_member.g.dart b/packages/spacex_api/lib/src/models/crew_member.g.dart index 20ffd06..62a1cfc 100644 --- a/packages/spacex_api/lib/src/models/crew_member.g.dart +++ b/packages/spacex_api/lib/src/models/crew_member.g.dart @@ -14,9 +14,8 @@ CrewMember _$CrewMemberFromJson(Map json) { agency: json['agency'] as String, image: json['image'] as String, wikipedia: json['wikipedia'] as String, - launches: (json['launches'] as List) - .map((dynamic e) => e as String) - .toList(), + launches: + (json['launches'] as List).map((e) => e as String).toList(), ); } diff --git a/packages/spacex_api/lib/src/models/launch.dart b/packages/spacex_api/lib/src/models/launch.dart new file mode 100644 index 0000000..a36d17f --- /dev/null +++ b/packages/spacex_api/lib/src/models/launch.dart @@ -0,0 +1,146 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:spacex_api/spacex_api.dart'; + +part 'launch.g.dart'; + +/// {@template launch} +/// A model containing data about a scheduled SpaceX rocket launch. +/// {@endtemplate} +@JsonSerializable() +class Launch extends Equatable { + /// {@macro launch} + const Launch({ + required this.id, + required this.name, + required this.links, + this.details, + this.crew = const [], + this.flightNumber, + this.rocket, + this.success, + required this.dateUtc, + required this.dateLocal, + }); + + /// The ID of the launch. + final String id; + + /// The name of the launch. + final String name; + + /// The details of the launch. + /// + /// May be null + final String? details; + + /// A list of crew members involved in the launch. + /// + /// May be `null` or empty. + final List? crew; + + /// The flightNumber of the launch + final int? flightNumber; + + /// The ID of the rocket + final String? rocket; + + /// If launch succeeded + final bool? success; + + /// The launch date in UTC + final DateTime? dateUtc; + + /// The launch date + final DateTime? dateLocal; + + /// Available source links + final Links links; + + @override + List get props => [ + id, + name, + details, + crew, + flightNumber, + rocket, + success, + dateUtc, + dateLocal, + links + ]; + + /// Converts a JSON [Map] into a [Launch] instance + static Launch fromJson(Map json) => _$LaunchFromJson(json); + + /// Converts this [Launch] instance into a JSON [Map] + Map toJson() => _$LaunchToJson(this); + + @override + String toString() => 'Latest Launch($id, $name)'; +} + +/// {@template links} +/// A model that represents available links to images, videos and articles. +/// {@endtemplate} +@JsonSerializable() +class Links extends Equatable { + /// {@macro links} + const Links({ + required this.patch, + this.webcast, + this.wikipedia, + }); + + /// The Patch for the launch mission + final Patch patch; + + /// The launch video link + final String? webcast; + + /// The latest launch information on wikipedia + final String? wikipedia; + + @override + List get props => [patch, webcast, wikipedia]; + + /// Converts a JSON [Map] into a [Links] instance. + static Links fromJson(Map json) => _$LinksFromJson(json); + + /// Converts this [Links] instance into a JSON [Map]. + Map toJson() => _$LinksToJson(this); + + @override + String toString() => 'Links(Webcast: $webcast, Wikipedia: $wikipedia)'; +} + +/// {@template patch} +/// A model that represents small and large images of the mission patch. +/// {@endtemplate} +@JsonSerializable() +class Patch extends Equatable { + /// {@macro patch} + const Patch({ + this.small, + this.large, + }); + + /// A small patch image link + final String? small; + + /// A large patch image link + final String? large; + + @override + List get props => [small, large]; + + /// Converts a JSON [Map] into a [Patch] instance. + static Patch fromJson(Map json) => _$PatchFromJson(json); + + /// Converts this [Patch] instance into a JSON [Map]. + Map toJson() => _$PatchToJson(this); + + @override + String toString() => 'Patch(Small: $small)'; +} diff --git a/packages/spacex_api/lib/src/models/launch.g.dart b/packages/spacex_api/lib/src/models/launch.g.dart new file mode 100644 index 0000000..b691ee7 --- /dev/null +++ b/packages/spacex_api/lib/src/models/launch.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'launch.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Launch _$LaunchFromJson(Map json) { + return Launch( + id: json['id'] as String, + name: json['name'] as String, + links: Links.fromJson(json['links'] as Map), + details: json['details'] as String?, + crew: (json['crew'] as List?) + ?.map((e) => CrewMember.fromJson(e as Map)) + .toList(), + flightNumber: json['flight_number'] as int?, + rocket: json['rocket'] as String?, + success: json['success'] as bool?, + dateUtc: json['date_utc'] == null + ? null + : DateTime.parse(json['date_utc'] as String), + dateLocal: json['date_local'] == null + ? null + : DateTime.parse(json['date_local'] as String), + ); +} + +Map _$LaunchToJson(Launch instance) => { + 'id': instance.id, + 'name': instance.name, + 'details': instance.details, + 'crew': instance.crew, + 'flight_number': instance.flightNumber, + 'rocket': instance.rocket, + 'success': instance.success, + 'date_utc': instance.dateUtc?.toIso8601String(), + 'date_local': instance.dateLocal?.toIso8601String(), + 'links': instance.links, + }; + +Links _$LinksFromJson(Map json) { + return Links( + patch: Patch.fromJson(json['patch'] as Map), + webcast: json['webcast'] as String?, + wikipedia: json['wikipedia'] as String?, + ); +} + +Map _$LinksToJson(Links instance) => { + 'patch': instance.patch, + 'webcast': instance.webcast, + 'wikipedia': instance.wikipedia, + }; + +Patch _$PatchFromJson(Map json) { + return Patch( + small: json['small'] as String?, + large: json['large'] as String?, + ); +} + +Map _$PatchToJson(Patch instance) => { + 'small': instance.small, + 'large': instance.large, + }; diff --git a/packages/spacex_api/lib/src/models/models.dart b/packages/spacex_api/lib/src/models/models.dart index 50f49d2..1374ca9 100644 --- a/packages/spacex_api/lib/src/models/models.dart +++ b/packages/spacex_api/lib/src/models/models.dart @@ -1,2 +1,3 @@ export 'crew_member.dart'; +export 'launch.dart'; export 'rocket.dart'; diff --git a/packages/spacex_api/lib/src/models/rocket.dart b/packages/spacex_api/lib/src/models/rocket.dart index 2890784..8f0767d 100644 --- a/packages/spacex_api/lib/src/models/rocket.dart +++ b/packages/spacex_api/lib/src/models/rocket.dart @@ -6,9 +6,7 @@ part 'rocket.g.dart'; /// {@template rocket} /// A model containing data about a SpaceX rocket. /// {@endtemplate} -@JsonSerializable( - fieldRename: FieldRename.snake, -) +@JsonSerializable() class Rocket extends Equatable { /// {@macro rocket} const Rocket({ @@ -18,13 +16,13 @@ class Rocket extends Equatable { required this.height, required this.diameter, required this.mass, + required this.firstFlight, this.flickrImages = const [], this.active, this.stages, this.boosters, this.costPerLaunch, this.successRatePct, - this.firstFlight, this.country, this.company, this.wikipedia, @@ -48,6 +46,9 @@ class Rocket extends Equatable { /// The mass of the rocket. final Mass mass; + /// The date this rocket was first launched. + final DateTime? firstFlight; + /// A collection of images if this rocket hosted on https://flickr.com /// /// May be empty. @@ -70,9 +71,6 @@ class Rocket extends Equatable { /// This value must be in between `0` and `100`. final int? successRatePct; - /// The date this rocket was first launched. - final DateTime? firstFlight; - /// The country in which this rocket was built. final String? country; @@ -115,9 +113,7 @@ class Rocket extends Equatable { /// {@template length} /// A model that represents a certain length in both meters and feet. /// {@endtemplate} -@JsonSerializable( - fieldRename: FieldRename.snake, -) +@JsonSerializable() class Length extends Equatable { /// {@macro length} const Length({ @@ -147,9 +143,7 @@ class Length extends Equatable { /// {@template mass} /// A model that represents a certain length in both meters and feet. /// {@endtemplate} -@JsonSerializable( - fieldRename: FieldRename.snake, -) +@JsonSerializable() class Mass extends Equatable { /// {@macro mass} const Mass({ diff --git a/packages/spacex_api/lib/src/models/rocket.g.dart b/packages/spacex_api/lib/src/models/rocket.g.dart index f4e360d..190f0a0 100644 --- a/packages/spacex_api/lib/src/models/rocket.g.dart +++ b/packages/spacex_api/lib/src/models/rocket.g.dart @@ -14,17 +14,17 @@ Rocket _$RocketFromJson(Map json) { height: Length.fromJson(json['height'] as Map), diameter: Length.fromJson(json['diameter'] as Map), mass: Mass.fromJson(json['mass'] as Map), + firstFlight: json['first_flight'] == null + ? null + : DateTime.parse(json['first_flight'] as String), flickrImages: (json['flickr_images'] as List) - .map((dynamic e) => e as String) + .map((e) => e as String) .toList(), active: json['active'] as bool?, stages: json['stages'] as int?, boosters: json['boosters'] as int?, costPerLaunch: json['cost_per_launch'] as int?, successRatePct: json['success_rate_pct'] as int?, - firstFlight: json['first_flight'] == null - ? null - : DateTime.parse(json['first_flight'] as String), country: json['country'] as String?, company: json['company'] as String?, wikipedia: json['wikipedia'] as String?, @@ -38,13 +38,13 @@ Map _$RocketToJson(Rocket instance) => { 'height': instance.height, 'diameter': instance.diameter, 'mass': instance.mass, + 'first_flight': instance.firstFlight?.toIso8601String(), 'flickr_images': instance.flickrImages, 'active': instance.active, 'stages': instance.stages, 'boosters': instance.boosters, 'cost_per_launch': instance.costPerLaunch, 'success_rate_pct': instance.successRatePct, - 'first_flight': instance.firstFlight?.toIso8601String(), 'country': instance.country, 'company': instance.company, 'wikipedia': instance.wikipedia, diff --git a/packages/spacex_api/lib/src/spacex_api_client.dart b/packages/spacex_api/lib/src/spacex_api_client.dart index 524fd7e..6de79d9 100644 --- a/packages/spacex_api/lib/src/spacex_api_client.dart +++ b/packages/spacex_api/lib/src/spacex_api_client.dart @@ -74,6 +74,20 @@ class SpaceXApiClient { } } + /// Fetch latest launch. + /// + /// REST call: `GET /launches/latest` + Future fetchLatestLaunch() async { + final uri = Uri.https(authority, '/v4/launches/latest/'); + final responseBody = await _getOne(uri); + + try { + return Launch.fromJson(responseBody); + } catch (_) { + throw JsonDeserializationException(); + } + } + Future> _get(Uri uri) async { http.Response response; @@ -93,4 +107,24 @@ class SpaceXApiClient { throw JsonDecodeException(); } } + + Future> _getOne(Uri uri) async { + http.Response response; + + try { + response = await _httpClient.get(uri); + } catch (_) { + throw HttpException(); + } + + if (response.statusCode != 200) { + throw HttpRequestFailure(response.statusCode); + } + + try { + return json.decode(response.body) as Map; + } catch (_) { + throw JsonDecodeException(); + } + } } diff --git a/packages/spacex_api/test/models/crew_member_test.dart b/packages/spacex_api/test/models/crew_member_test.dart index bfa6211..d260b1d 100644 --- a/packages/spacex_api/test/models/crew_member_test.dart +++ b/packages/spacex_api/test/models/crew_member_test.dart @@ -57,7 +57,7 @@ void main() { wikipedia: 'https://www.wikipedia.org/', launches: const ['Launch 1', 'Launch 2'], ).stringify, - isTrue, + isNull, ); }); }); diff --git a/packages/spacex_api/test/models/launches_test.dart b/packages/spacex_api/test/models/launches_test.dart new file mode 100644 index 0000000..21fba7a --- /dev/null +++ b/packages/spacex_api/test/models/launches_test.dart @@ -0,0 +1,152 @@ +// ignore_for_file: prefer_const_constructors +import 'package:spacex_api/spacex_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('Launch', () { + final date = DateTime.now(); + final crewMembers = List.generate( + 3, + (i) => CrewMember( + id: '$i', + name: 'Alejandro Ferrero', + status: 'active', + agency: 'Very Good Aliens', + image: + 'https://media-exp1.licdn.com/dms/image/C4D03AQHVNIVOMkwQaA/profile-displayphoto-shrink_200_200/0/1631637257882?e=1637193600&v=beta&t=jFm-Ckb0KS0Z5hJDbo3ZBSEZSYLHfllUf4N-IV2NDTc', + wikipedia: 'https://www.wikipedia.org/', + launches: ['Launch $i'], + ), + ); + final launch = Launch( + id: '1', + name: 'Starlink Mission 1337', + dateLocal: date, + dateUtc: date, + crew: crewMembers, + links: Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/')); + + test('supports value comparison', () { + expect( + launch, + Launch( + id: '1', + name: 'Starlink Mission 1337', + crew: crewMembers, + dateLocal: date, + dateUtc: date, + links: Links( + patch: Patch( + small: + 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/')), + ); + }); + + test('has concise toString', () { + expect( + Launch( + id: '1', + name: 'Starlink Mission 1337', + dateLocal: date, + dateUtc: date, + links: Links( + patch: Patch( + small: + 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/')) + .toString(), + equals('Latest Launch(1, Starlink Mission 1337)')); + }); + + test('overrides stringify', () { + expect( + Launch( + id: '1', + name: 'Starlink Mission 1337', + dateLocal: date, + dateUtc: date, + links: Links( + patch: Patch( + small: + 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/')) + .stringify, + isNull); + }); + group('Links', () { + test('supports value comparison', () { + expect( + Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/'), + Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/'), + ); + }); + + test('has concise toString', () { + expect( + Links( + patch: Patch( + small: + 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/') + .toString(), + equals( + 'Links(Webcast: https://www.youtube.com, Wikipedia: https://www.wikipedia.org/)')); + }); + + test('overrides stringify', () { + expect( + Links( + patch: Patch( + small: + 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/') + .stringify, + isNull); + }); + }); + + group('Patch', () { + test('supports value comparison', () { + expect( + const Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + const Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + ); + }); + + test('has concise toString', () { + expect( + Patch(small: 'https://avatars.githubusercontent.com/u/2918581?v=4') + .toString(), + equals( + 'Patch(Small: https://avatars.githubusercontent.com/u/2918581?v=4)')); + }); + + test('overrides stringify', () { + expect( + Patch(small: 'https://avatars.githubusercontent.com/u/2918581?v=4') + .stringify, + isNull); + }); + }); + }); +} diff --git a/packages/spacex_api/test/models/rocket_test.dart b/packages/spacex_api/test/models/rocket_test.dart index dd26a2c..e06bbd9 100644 --- a/packages/spacex_api/test/models/rocket_test.dart +++ b/packages/spacex_api/test/models/rocket_test.dart @@ -4,6 +4,21 @@ import 'package:test/test.dart'; void main() { group('Rocket', () { + test('checks if the first flight date is not null', () { + const DateTime? date = null; + final rocket = Rocket( + id: '0', + name: 'no first flight', + description: 'never in the air', + height: const Length(meters: 1, feet: 1), + diameter: const Length(meters: 1, feet: 1), + mass: const Mass(kg: 1, lb: 1), + firstFlight: date ?? DateTime.now(), + ); + + expect(rocket.firstFlight, isNotNull); + }); + test('supports value comparisons', () { expect( Rocket( @@ -13,6 +28,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime(2021), ), Rocket( id: '1', @@ -21,6 +37,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime(2021), ), ); }); @@ -34,6 +51,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime(2021), ).toString(), equals('Rocket(1, mock-rocket-name-1)'), ); diff --git a/packages/spacex_api/test/spacex_api_client_test.dart b/packages/spacex_api/test/spacex_api_client_test.dart index aa35b99..e02f383 100644 --- a/packages/spacex_api/test/spacex_api_client_test.dart +++ b/packages/spacex_api/test/spacex_api_client_test.dart @@ -10,21 +10,24 @@ class MockHttpClient extends Mock implements http.Client {} void main() { late Uri rocketUri; late Uri crewUri; + late Uri latestLaunchUri; group('SpaceXApiClient', () { late http.Client httpClient; late SpaceXApiClient subject; + final date = DateTime.now(); + final rockets = List.generate( 3, (i) => Rocket( - id: '$i', - name: 'mock-rocket-name-$i', - description: 'mock-rocket-description-$i', - height: const Length(meters: 1, feet: 1), - diameter: const Length(meters: 1, feet: 1), - mass: const Mass(kg: 1, lb: 1), - ), + id: '$i', + name: 'mock-rocket-name-$i', + description: 'mock-rocket-description-$i', + height: const Length(meters: 1, feet: 1), + diameter: const Length(meters: 1, feet: 1), + mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime.now()), ); final crewMembers = List.generate( @@ -41,11 +44,25 @@ void main() { ), ); + final latestLaunch = Launch( + id: '1337', + name: 'mock-launch-name-1337', + dateLocal: date, + dateUtc: date, + links: const Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4'), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/'), + ); + setUp(() { httpClient = MockHttpClient(); subject = SpaceXApiClient(httpClient: httpClient); rocketUri = Uri.https(SpaceXApiClient.authority, '/v4/rockets'); crewUri = Uri.https(SpaceXApiClient.authority, '/v4/crew'); + latestLaunchUri = + Uri.https(SpaceXApiClient.authority, '/v4/launches/latest/'); }); test('constructor returns normally', () { @@ -105,7 +122,7 @@ void main() { test( 'throws JsonDeserializationException ' 'when deserializing json body fails', - () { + () async { when(() => httpClient.get(rocketUri)).thenAnswer( (_) async => http.Response( '[{"this_is_not_a_rocket_doc": true}]', @@ -216,5 +233,86 @@ void main() { ); }); }); + + group('.fetchLatestLaunch', () { + setUp(() { + when(() => httpClient.get(latestLaunchUri)).thenAnswer( + (_) async => http.Response(json.encode(latestLaunch), 200), + ); + }); + + test('throws HttpException when http client throws exception', () { + when(() => httpClient.get(latestLaunchUri)).thenThrow(Exception()); + + expect( + () => subject.fetchLatestLaunch(), + throwsA(isA()), + ); + }); + + test( + 'throws HttpRequestFailure when response status code is not 200', + () { + when(() => httpClient.get(latestLaunchUri)).thenAnswer( + (_) async => http.Response('', 400), + ); + + expect( + () => subject.fetchLatestLaunch(), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 400), + ), + ); + }, + ); + + test( + 'throws JsonDecodeException when decoding response fails', + () { + when(() => httpClient.get(latestLaunchUri)).thenAnswer( + (_) async => http.Response('definitely not json!', 200), + ); + + expect( + () => subject.fetchLatestLaunch(), + throwsA(isA()), + ); + }, + ); + + test( + 'throws JsonDeserializationException ' + 'when deserializing json body fails', + () { + when(() => httpClient.get(latestLaunchUri)).thenAnswer( + (_) async => http.Response( + '{"this_is_not_the_latest_launch": true}', + 200, + ), + ); + + expect( + () => subject.fetchLatestLaunch(), + throwsA(isA()), + ); + }, + ); + + test('makes correct request', () async { + await subject.fetchLatestLaunch(); + + verify( + () => httpClient.get(latestLaunchUri), + ).called(1); + }); + + test('returns correct element of the latest launch', () { + expect( + subject.fetchLatestLaunch(), + completion(equals(latestLaunch)), + ); + }); + }); }); } diff --git a/pubspec.lock b/pubspec.lock index 78aa478..c0b0995 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "26.0.0" + version: "31.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.8.0" args: dependency: transitive description: @@ -28,7 +28,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.8.1" + version: "2.8.2" bloc: dependency: "direct main" description: @@ -56,7 +56,7 @@ packages: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" charcode: dependency: transitive description: @@ -224,6 +224,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.1.0" + launch_repository: + dependency: "direct main" + description: + path: "packages/launch_repository" + relative: true + source: path + version: "1.0.0+1" logging: dependency: transitive description: @@ -237,7 +244,14 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10" + version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" meta: dependency: transitive description: @@ -438,21 +452,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.17.10" + version: "1.19.5" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.2" + version: "0.4.8" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.4.0" + version: "0.4.9" typed_data: dependency: transitive description: @@ -508,7 +522,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" very_good_analysis: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2a8dcc1..00ca1ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ homepage: https://github.com/VGVentures/spacex_demo publish_to: none environment: - sdk: ">=2.14.0-0 <3.0.0" + sdk: '>=2.14.0-0 <3.0.0' dependencies: bloc: ^7.2.1 @@ -18,6 +18,8 @@ dependencies: flutter_localizations: sdk: flutter intl: ^0.17.0 + launch_repository: + path: packages/launch_repository rocket_repository: path: packages/rocket_repository spacex_api: diff --git a/test/app/app_test.dart b/test/app/app_test.dart index 651da3c..b87f29e 100644 --- a/test/app/app_test.dart +++ b/test/app/app_test.dart @@ -1,5 +1,6 @@ import 'package:crew_member_repository/crew_member_repository.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:launch_repository/launch_repository.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:rocket_repository/rocket_repository.dart'; import 'package:spacex_demo/app/app.dart'; @@ -14,10 +15,12 @@ class MockCrewMemberRepository extends Mock implements CrewMemberRepository {} void main() { late RocketRepository rocketRepository; late CrewMemberRepository crewMemberRepository; + late LaunchRepository launchRepository; setUp(() { rocketRepository = MockRocketRepository(); crewMemberRepository = MockCrewMemberRepository(); + launchRepository = LaunchRepository(); }); group('App', () { @@ -26,6 +29,7 @@ void main() { App( rocketRepository: rocketRepository, crewMemberRepository: crewMemberRepository, + launchRepository: launchRepository, ), ); expect(find.byType(AppView), findsOneWidget); diff --git a/test/crew/view/crew_page_test.dart b/test/crew/view/crew_page_test.dart index 79cb09a..be1d466 100644 --- a/test/crew/view/crew_page_test.dart +++ b/test/crew/view/crew_page_test.dart @@ -67,7 +67,9 @@ void main() { navigator = MockNavigator(); when(() => navigator.push(any(that: isRoute()))) - .thenAnswer((_) async {}); + .thenAnswer((_) async { + return null; + }); }); setUpAll(() { diff --git a/test/crew_member_details/cubit/crew_member_details_state_test.dart b/test/crew_member_details/cubit/crew_member_details_state_test.dart index b7beea8..093148c 100644 --- a/test/crew_member_details/cubit/crew_member_details_state_test.dart +++ b/test/crew_member_details/cubit/crew_member_details_state_test.dart @@ -3,7 +3,7 @@ import 'package:spacex_api/spacex_api.dart'; import 'package:spacex_demo/crew_member_details/cubit/crew_member_details_cubit.dart'; void main() { - group('CrewMemberDewtailsState', () { + group('CrewMemberDetailsState', () { const crewMember = CrewMember( id: '0', name: 'Alejandro Ferrero', diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index ff82dd3..c819d3e 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -1,11 +1,10 @@ import 'package:crew_member_repository/crew_member_repository.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:launch_repository/launch_repository.dart'; import 'package:mockingjay/mockingjay.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:rocket_repository/rocket_repository.dart'; import 'package:spacex_demo/l10n/l10n.dart'; @@ -13,12 +12,15 @@ class MockRocketRepository extends Mock implements RocketRepository {} class MockCrewMemberRepository extends Mock implements CrewMemberRepository {} +class MockLaunchRepository extends Mock implements LaunchRepository {} + extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { MockNavigator? navigator, RocketRepository? rocketRepository, CrewMemberRepository? crewMemberRepository, + LaunchRepository? launchRepository, }) { final innerChild = Scaffold( body: widget, @@ -32,6 +34,9 @@ extension PumpApp on WidgetTester { ), RepositoryProvider.value( value: crewMemberRepository ?? MockCrewMemberRepository(), + ), + RepositoryProvider.value( + value: launchRepository ?? MockLaunchRepository(), ) ], child: MaterialApp( diff --git a/test/home/view/home_page_test.dart b/test/home/view/home_page_test.dart index f307540..462bc05 100644 --- a/test/home/view/home_page_test.dart +++ b/test/home/view/home_page_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spacex_demo/home/home.dart'; -import 'package:spacex_demo/home/widgets/home_page_content.dart'; import '../../helpers/pump_app.dart'; diff --git a/test/home/widgets/home_page_content_test.dart b/test/home/widgets/home_page_content_test.dart index 2cf354b..94a10a0 100644 --- a/test/home/widgets/home_page_content_test.dart +++ b/test/home/widgets/home_page_content_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:spacex_demo/home/widgets/home_page_content.dart'; import 'package:spacex_demo/home/widgets/spacex_category_card.dart'; @@ -15,17 +14,21 @@ void main() { navigator = MockNavigator(); when(() => navigator.push(any(that: isRoute()))) - .thenAnswer((_) async {}); + .thenAnswer((_) async { + return null; + }); when(() => navigator.push(any(that: isRoute()))) - .thenAnswer((_) async {}); + .thenAnswer((_) async { + return null; + }); }); testWidgets( 'renders correct amount of ' 'SpaceXCategoryCards', (tester) async { await tester.pumpApp(const HomePageContent()); - expect(find.byType(SpaceXCategoryCard), findsNWidgets(2)); + expect(find.byType(SpaceXCategoryCard), findsNWidgets(3)); }, ); @@ -85,5 +88,24 @@ void main() { verify(() => navigator.push(any(that: isRoute()))).called(1); }, ); + + testWidgets( + 'navigates to LaunchesPage ' + 'when launch category card is tapped', + (tester) async { + await tester.pumpApp( + const HomePageContent(), + navigator: navigator, + ); + + await tester.tap( + find.byKey( + const Key('homePageContent_latestLaunch_spaceXCategoryCard'), + ), + ); + + verify(() => navigator.push(any(that: isRoute()))).called(1); + }, + ); }); } diff --git a/test/launches/cubit/launches_cubit_test.dart b/test/launches/cubit/launches_cubit_test.dart new file mode 100644 index 0000000..4738e0e --- /dev/null +++ b/test/launches/cubit/launches_cubit_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: prefer_const_constructors +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:launch_repository/launch_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:spacex_api/spacex_api.dart'; +import 'package:spacex_demo/launches/launches.dart'; + +class MockLaunchRepository extends Mock implements LaunchRepository {} + +void main() { + group('CrewCubit', () { + late LaunchRepository launchRepository; + + final date = DateTime.now(); + + final latestLaunch = Launch( + id: '0', + name: 'mock-launch-name', + dateLocal: date, + dateUtc: date, + links: const Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4', + large: 'https://avatars.githubusercontent.com/u/2918581?v=4', + ), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/', + ), + ); + + setUp(() { + launchRepository = MockLaunchRepository(); + when(() => launchRepository.fetchLatestLaunch()) + .thenAnswer((_) async => latestLaunch); + }); + + test( + 'initial state is correct', + () => { + expect( + LaunchesCubit(launchRepository: launchRepository).state, + equals(const LaunchesState()), + ) + }, + ); + + blocTest( + 'emits state with updated launch', + build: () => LaunchesCubit(launchRepository: launchRepository), + act: (cubit) => cubit.fetchLatestLaunch(), + expect: () => [ + const LaunchesState(status: LaunchesStatus.loading), + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ], + ); + + blocTest( + 'emits failure state when repository throws exception', + setUp: () { + when(() => launchRepository.fetchLatestLaunch()).thenThrow(Exception()); + }, + build: () => LaunchesCubit(launchRepository: launchRepository), + act: (cubit) => cubit.fetchLatestLaunch(), + expect: () => [ + const LaunchesState(status: LaunchesStatus.loading), + const LaunchesState(status: LaunchesStatus.failure), + ], + ); + }); +} diff --git a/test/launches/cubit/launches_state_test.dart b/test/launches/cubit/launches_state_test.dart new file mode 100644 index 0000000..d46f0ba --- /dev/null +++ b/test/launches/cubit/launches_state_test.dart @@ -0,0 +1,36 @@ +// ignore_for_file: cascade_invocations +import 'package:flutter_test/flutter_test.dart'; +import 'package:spacex_api/spacex_api.dart'; +import 'package:spacex_demo/launches/launches.dart'; + +void main() { + group('LaunchesState', () { + final date = DateTime.now(); + final launch = Launch( + id: '0', + name: 'mock-launch-name', + dateLocal: date, + dateUtc: date, + links: const Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4', + large: 'https://avatars.githubusercontent.com/u/2918581?v=4', + ), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/', + ), + ); + test('supports value comparison', () { + expect( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: launch, + ), + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: launch, + ), + ); + }); + }); +} diff --git a/test/launches/view/launches_page_test.dart b/test/launches/view/launches_page_test.dart new file mode 100644 index 0000000..321c00c --- /dev/null +++ b/test/launches/view/launches_page_test.dart @@ -0,0 +1,308 @@ +// ignore_for_file: prefer_const_constructors +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:launch_repository/launch_repository.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:spacex_api/spacex_api.dart'; +import 'package:spacex_demo/launches/launches.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../../helpers/helpers.dart'; + +class MockLaunchRepository extends Mock implements LaunchRepository {} + +class MockLaunchesCubit extends MockCubit + implements LaunchesCubit {} + +class MockUrlLauncherPlatorm extends Mock + with MockPlatformInterfaceMixin + implements UrlLauncherPlatform {} + +void main() { + late LaunchesCubit launchesCubit; + late UrlLauncherPlatform urlLauncherPlatform; + + const status = LaunchesStatus.success; + final date = DateTime.now(); + + final latestLaunch = Launch( + id: '0', + name: 'mock-launch-name', + dateLocal: date, + dateUtc: date, + links: const Links( + patch: Patch( + small: 'https://avatars.githubusercontent.com/u/2918581?v=4', + large: 'https://avatars.githubusercontent.com/u/2918581?v=4', + ), + webcast: 'https://www.youtube.com', + wikipedia: 'https://www.wikipedia.org/', + ), + ); + + setUp(() { + launchesCubit = MockLaunchesCubit(); + when(() => launchesCubit.state) + .thenReturn(LaunchesState(latestLaunch: latestLaunch, status: status)); + + urlLauncherPlatform = MockUrlLauncherPlatorm(); + UrlLauncherPlatform.instance = urlLauncherPlatform; + when(() => urlLauncherPlatform.canLaunch(any())) + .thenAnswer((_) async => true); + when( + () => urlLauncherPlatform.launch( + any(), + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + webOnlyWindowName: any(named: 'webOnlyWindowName'), + ), + ).thenAnswer((_) async => true); + }); + + setUpAll(() { + registerFallbackValue( + LaunchesState(latestLaunch: latestLaunch, status: LaunchesStatus.success), + ); + }); + + group('LaunchesPage', () { + late LaunchRepository launchRepository; + + setUp(() { + launchRepository = MockLaunchRepository(); + when( + () => launchRepository.fetchLatestLaunch(), + ).thenAnswer((_) async => latestLaunch); + }); + + test('has route', () { + expect( + LaunchesPage.route(), + isA>(), + ); + }); + + testWidgets('renders LaunchesView', (tester) async { + await tester.pumpApp( + Navigator( + onGenerateRoute: (_) => LaunchesPage.route(), + ), + launchRepository: launchRepository, + ); + + expect(find.byType(LaunchesPage), findsOneWidget); + }); + }); + + group('LaunchesView', () { + late LaunchesCubit launchesCubit; + late MockNavigator navigator; + + setUp(() { + launchesCubit = MockLaunchesCubit(); + navigator = MockNavigator(); + + when(() => navigator.push(any(that: isRoute()))) + .thenAnswer((_) async { + return null; + }); + }); + + setUpAll(() { + registerFallbackValue(const LaunchesState()); + registerFallbackValue(Uri()); + }); + + testWidgets('renders empty page when status is initial', (tester) async { + const key = Key('launchesView_initial_sizedBox'); + + when(() => launchesCubit.state).thenReturn( + const LaunchesState(), + ); + + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets( + 'renders loading indicator when status is loading', + (tester) async { + const key = Key('launchesView_loading_indicator'); + + when(() => launchesCubit.state).thenReturn( + const LaunchesState( + status: LaunchesStatus.loading, + ), + ); + + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }, + ); + + testWidgets( + 'renders error text when status is failure', + (tester) async { + const key = Key('launchesView_failure_text'); + + when(() => launchesCubit.state).thenReturn( + const LaunchesState( + status: LaunchesStatus.failure, + ), + ); + + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }, + ); + + testWidgets( + 'renders the latest launch when status is success', + (tester) async { + const key = Key('launchesView_success_latestLaunch'); + + when(() => launchesCubit.state).thenReturn( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ); + + await mockNetworkImages(() async { + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + }); + + expect(find.byKey(key), findsOneWidget); + }, + ); + + group('open link buttons', () { + const key = Key('launchesPage_link_buttons'); + const webcastKey = Key('launchesPage_openWebcast_elevatedButton'); + const wikipediaKey = Key('launchesPage_openWikipedia_elevatedButton'); + + testWidgets( + 'is rendered when the latest launch contain a wikipedia url', + (tester) async { + when(() => launchesCubit.state).thenReturn( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ); + await mockNetworkImages(() async { + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + }); + + expect(find.byKey(key), findsOneWidget); + }, + ); + + testWidgets('attemps to open wikipedia url when pressed', (tester) async { + await mockNetworkImages(() async { + when(() => launchesCubit.state).thenReturn( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ); + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + }); + await tester.tap(find.byKey(wikipediaKey)); + + verify( + () => urlLauncherPlatform.canLaunch(latestLaunch.links.wikipedia!), + ).called(1); + verify( + () => urlLauncherPlatform.launch( + latestLaunch.links.wikipedia!, + useSafariVC: true, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + ).called(1); + }); + + testWidgets( + 'attempts to open webcast url when pressed', + (tester) async { + when(() => launchesCubit.state).thenReturn( + LaunchesState( + status: LaunchesStatus.success, + latestLaunch: latestLaunch, + ), + ); + await mockNetworkImages(() async { + await tester.pumpApp( + BlocProvider.value( + value: launchesCubit, + child: const LaunchesView(), + ), + ); + }); + + await tester.tap(find.byKey(webcastKey)); + + verify( + () => urlLauncherPlatform.canLaunch(latestLaunch.links.webcast!), + ).called(1); + verify( + () => urlLauncherPlatform.launch( + latestLaunch.links.webcast!, + useSafariVC: true, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + ).called(1); + }, + ); + }); + }); +} diff --git a/test/rocket_details/cubit/rocket_details_cubit_test.dart b/test/rocket_details/cubit/rocket_details_cubit_test.dart index dfe46af..11449da 100644 --- a/test/rocket_details/cubit/rocket_details_cubit_test.dart +++ b/test/rocket_details/cubit/rocket_details_cubit_test.dart @@ -9,13 +9,14 @@ class MockRocketRepository extends Mock implements RocketRepository {} void main() { group('RocketDetailsCubit', () { - const rocket = Rocket( + final rocket = Rocket( id: '0', name: 'mock-rocket-name', description: 'mock-rocket-description', height: Length(meters: 1, feet: 1), diameter: Length(meters: 1, feet: 1), mass: Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ); test('initial state is correct', () { diff --git a/test/rocket_details/cubit/rocket_details_state_test.dart b/test/rocket_details/cubit/rocket_details_state_test.dart index d3171db..dbcd69f 100644 --- a/test/rocket_details/cubit/rocket_details_state_test.dart +++ b/test/rocket_details/cubit/rocket_details_state_test.dart @@ -5,13 +5,14 @@ import 'package:spacex_demo/rocket_details/rocket_details.dart'; void main() { group('RocketDetailsState', () { - const rocket = Rocket( + final rocket = Rocket( id: '0', name: 'mock-rocket-name', description: 'mock-rocket-description', height: Length(meters: 1, feet: 1), diameter: Length(meters: 1, feet: 1), mass: Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ); test('supports value comparison', () { diff --git a/test/rocket_details/view/rocket_details_page_test.dart b/test/rocket_details/view/rocket_details_page_test.dart index 1324246..33a5121 100644 --- a/test/rocket_details/view/rocket_details_page_test.dart +++ b/test/rocket_details/view/rocket_details_page_test.dart @@ -139,15 +139,16 @@ void main() { testWidgets('renders cross icon when rocket is inactive', (tester) async { when(() => rocketDetailsCubit.state).thenReturn( - const RocketDetailsState( + RocketDetailsState( rocket: Rocket( id: '0', name: 'mock-rocket-name', description: 'mock-rocket-description', - height: Length(meters: 1, feet: 1), - diameter: Length(meters: 1, feet: 1), - mass: Mass(kg: 1, lb: 1), + height: const Length(meters: 1, feet: 1), + diameter: const Length(meters: 1, feet: 1), + mass: const Mass(kg: 1, lb: 1), active: false, + firstFlight: DateTime.now(), ), ), ); @@ -201,14 +202,15 @@ void main() { 'is not rendered when the rocket does not contain a wikipedia url', (tester) async { when(() => rocketDetailsCubit.state).thenReturn( - const RocketDetailsState( + RocketDetailsState( rocket: Rocket( id: '0', name: 'mock-rocket-name', description: 'mock-rocket-description', - height: Length(meters: 1, feet: 1), - diameter: Length(meters: 1, feet: 1), - mass: Mass(kg: 1, lb: 1), + height: const Length(meters: 1, feet: 1), + diameter: const Length(meters: 1, feet: 1), + mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ), ), ); diff --git a/test/rockets/cubit/rockets_cubit_test.dart b/test/rockets/cubit/rockets_cubit_test.dart index 4f9be46..dc63b84 100644 --- a/test/rockets/cubit/rockets_cubit_test.dart +++ b/test/rockets/cubit/rockets_cubit_test.dart @@ -21,6 +21,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ), ); diff --git a/test/rockets/view/rockets_page_test.dart b/test/rockets/view/rockets_page_test.dart index c5cb415..d77e53d 100644 --- a/test/rockets/view/rockets_page_test.dart +++ b/test/rockets/view/rockets_page_test.dart @@ -24,6 +24,7 @@ void main() { height: const Length(meters: 1, feet: 1), diameter: const Length(meters: 1, feet: 1), mass: const Mass(kg: 1, lb: 1), + firstFlight: DateTime.now(), ), ); @@ -64,7 +65,9 @@ void main() { navigator = MockNavigator(); when(() => navigator.push(any(that: isRoute()))) - .thenAnswer((_) async {}); + .thenAnswer((_) async { + return null; + }); }); setUpAll(() {