diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1447011..c8cb031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,13 +126,4 @@ jobs: profile: Nexus 6 script: | flutter pub get - flutter test integration_test/app_test.dart --flavor dev - flutter test integration_test/appearance_test.dart --flavor dev - flutter test integration_test/launch_navigation_test.dart --flavor dev - flutter test integration_test/launch_test.dart --flavor dev - flutter test integration_test/launches_mock_test.dart --flavor dev - flutter test integration_test/launches_test.dart --flavor dev - flutter test integration_test/settings_test.dart --flavor dev - flutter test integration_test/rockets_screen_integration_test.dart --flavor dev - flutter test integration_test/rocket_screen_test.dart --flavor dev - flutter test integration_test/rockets_integration_live_test.dart --flavor dev + flutter test integration_test --flavor dev \ No newline at end of file diff --git a/Makefile b/Makefile index b80c2b1..e3f4b5d 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ integration_test: flutter test integration_test --flavor dev screenshot_test: - flutter drive --driver=test_driver/integration_test.dart --target=integration_test/settings_screenshot_test.dart --flavor dev + flutter drive --driver=test_driver/integration_test.dart --target=screenshot_test/settings_screenshot_test.dart --flavor dev # upgrade_deps: # flutter pub upgrade --major-versions diff --git a/lib/data/network/data_source/roadster_network_data_source.dart b/lib/data/network/data_source/roadster_network_data_source.dart new file mode 100644 index 0000000..6ed3094 --- /dev/null +++ b/lib/data/network/data_source/roadster_network_data_source.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/roadster/roadster_service.dart'; + +abstract class RoadsterDataSource { + Future> getRoadster(); +} + +class RoadsterNetworkDataSource implements RoadsterDataSource { + RoadsterNetworkDataSource(this._service); + + final RoadsterService _service; + + @override + Future> getRoadster() async { + try { + final result = await _service.fetchRoadster(); + return ApiResult.success(result); + } on DioException catch (e) { + return ApiResult.error(e.message ?? e.toString()); + } catch (e) { + return Future.value(ApiResult.error(e.toString())); + } + } +} 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 5b350d0..cd3959d 100644 --- a/lib/data/network/model/core/network_core_model.freezed.dart +++ b/lib/data/network/model/core/network_core_model.freezed.dart @@ -16,26 +16,17 @@ 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; @@ -99,7 +90,6 @@ abstract mixin class $NetworkCoreModelCopyWith<$Res> { factory $NetworkCoreModelCopyWith( NetworkCoreModel value, $Res Function(NetworkCoreModel) _then) = _$NetworkCoreModelCopyWithImpl; - @useResult $Res call( {@JsonKey(name: 'core_serial') String? coreSerial, @@ -418,7 +408,6 @@ class _NetworkCoreModel extends NetworkCoreModel { @JsonKey(name: 'landing_type') this.landingType, @JsonKey(name: 'landing_vehicle') this.landingVehicle}) : super._(); - factory _NetworkCoreModel.fromJson(Map json) => _$NetworkCoreModelFromJson(json); @@ -513,7 +502,6 @@ abstract mixin class _$NetworkCoreModelCopyWith<$Res> factory _$NetworkCoreModelCopyWith( _NetworkCoreModel value, $Res Function(_NetworkCoreModel) _then) = __$NetworkCoreModelCopyWithImpl; - @override @useResult $Res call( diff --git a/lib/data/network/model/roadster/network_roadster_model.dart b/lib/data/network/model/roadster/network_roadster_model.dart new file mode 100644 index 0000000..b0061e6 --- /dev/null +++ b/lib/data/network/model/roadster/network_roadster_model.dart @@ -0,0 +1,42 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'network_roadster_model.freezed.dart'; +part 'network_roadster_model.g.dart'; + +@freezed +abstract class NetworkRoadsterModel with _$NetworkRoadsterModel { + const factory NetworkRoadsterModel({ + String? name, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id, + }) = _NetworkRoadsterModel; + + const NetworkRoadsterModel._(); + + factory NetworkRoadsterModel.fromJson(Map json) => + _$NetworkRoadsterModelFromJson(json); +} diff --git a/lib/data/network/model/roadster/network_roadster_model.freezed.dart b/lib/data/network/model/roadster/network_roadster_model.freezed.dart new file mode 100644 index 0000000..94a8ddd --- /dev/null +++ b/lib/data/network/model/roadster/network_roadster_model.freezed.dart @@ -0,0 +1,1101 @@ +// 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 'network_roadster_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NetworkRoadsterModel { + String? get name; + @JsonKey(name: 'launch_date_utc') + String? get launchDateUtc; + @JsonKey(name: 'launch_date_unix') + int? get launchDateUnix; + @JsonKey(name: 'launch_mass_kg') + int? get launchMassKg; + @JsonKey(name: 'launch_mass_lbs') + int? get launchMassLbs; + @JsonKey(name: 'norad_id') + int? get noradId; + @JsonKey(name: 'epoch_jd') + double? get epochJd; + @JsonKey(name: 'orbit_type') + String? get orbitType; + @JsonKey(name: 'apoapsis_au') + double? get apoapsisAu; + @JsonKey(name: 'periapsis_au') + double? get periapsisAu; + @JsonKey(name: 'semi_major_axis_au') + double? get semiMajorAxisAu; + double? get eccentricity; + double? get inclination; + double? get longitude; + @JsonKey(name: 'periapsis_arg') + double? get periapsisArg; + @JsonKey(name: 'period_days') + double? get periodDays; + @JsonKey(name: 'speed_kph') + double? get speedKph; + @JsonKey(name: 'speed_mph') + double? get speedMph; + @JsonKey(name: 'earth_distance_km') + double? get earthDistanceKm; + @JsonKey(name: 'earth_distance_mi') + double? get earthDistanceMi; + @JsonKey(name: 'mars_distance_km') + double? get marsDistanceKm; + @JsonKey(name: 'mars_distance_mi') + double? get marsDistanceMi; + @JsonKey(name: 'flickr_images') + List? get flickrImages; + String? get wikipedia; + String? get video; + String? get details; + String? get id; + + /// Create a copy of NetworkRoadsterModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $NetworkRoadsterModelCopyWith get copyWith => + _$NetworkRoadsterModelCopyWithImpl( + this as NetworkRoadsterModel, _$identity); + + /// Serializes this NetworkRoadsterModel to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is NetworkRoadsterModel && + (identical(other.name, name) || other.name == name) && + (identical(other.launchDateUtc, launchDateUtc) || + other.launchDateUtc == launchDateUtc) && + (identical(other.launchDateUnix, launchDateUnix) || + other.launchDateUnix == launchDateUnix) && + (identical(other.launchMassKg, launchMassKg) || + other.launchMassKg == launchMassKg) && + (identical(other.launchMassLbs, launchMassLbs) || + other.launchMassLbs == launchMassLbs) && + (identical(other.noradId, noradId) || other.noradId == noradId) && + (identical(other.epochJd, epochJd) || other.epochJd == epochJd) && + (identical(other.orbitType, orbitType) || + other.orbitType == orbitType) && + (identical(other.apoapsisAu, apoapsisAu) || + other.apoapsisAu == apoapsisAu) && + (identical(other.periapsisAu, periapsisAu) || + other.periapsisAu == periapsisAu) && + (identical(other.semiMajorAxisAu, semiMajorAxisAu) || + other.semiMajorAxisAu == semiMajorAxisAu) && + (identical(other.eccentricity, eccentricity) || + other.eccentricity == eccentricity) && + (identical(other.inclination, inclination) || + other.inclination == inclination) && + (identical(other.longitude, longitude) || + other.longitude == longitude) && + (identical(other.periapsisArg, periapsisArg) || + other.periapsisArg == periapsisArg) && + (identical(other.periodDays, periodDays) || + other.periodDays == periodDays) && + (identical(other.speedKph, speedKph) || + other.speedKph == speedKph) && + (identical(other.speedMph, speedMph) || + other.speedMph == speedMph) && + (identical(other.earthDistanceKm, earthDistanceKm) || + other.earthDistanceKm == earthDistanceKm) && + (identical(other.earthDistanceMi, earthDistanceMi) || + other.earthDistanceMi == earthDistanceMi) && + (identical(other.marsDistanceKm, marsDistanceKm) || + other.marsDistanceKm == marsDistanceKm) && + (identical(other.marsDistanceMi, marsDistanceMi) || + other.marsDistanceMi == marsDistanceMi) && + const DeepCollectionEquality() + .equals(other.flickrImages, flickrImages) && + (identical(other.wikipedia, wikipedia) || + other.wikipedia == wikipedia) && + (identical(other.video, video) || other.video == video) && + (identical(other.details, details) || other.details == details) && + (identical(other.id, id) || other.id == id)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hashAll([ + runtimeType, + name, + launchDateUtc, + launchDateUnix, + launchMassKg, + launchMassLbs, + noradId, + epochJd, + orbitType, + apoapsisAu, + periapsisAu, + semiMajorAxisAu, + eccentricity, + inclination, + longitude, + periapsisArg, + periodDays, + speedKph, + speedMph, + earthDistanceKm, + earthDistanceMi, + marsDistanceKm, + marsDistanceMi, + const DeepCollectionEquality().hash(flickrImages), + wikipedia, + video, + details, + id + ]); + + @override + String toString() { + return 'NetworkRoadsterModel(name: $name, launchDateUtc: $launchDateUtc, launchDateUnix: $launchDateUnix, launchMassKg: $launchMassKg, launchMassLbs: $launchMassLbs, noradId: $noradId, epochJd: $epochJd, orbitType: $orbitType, apoapsisAu: $apoapsisAu, periapsisAu: $periapsisAu, semiMajorAxisAu: $semiMajorAxisAu, eccentricity: $eccentricity, inclination: $inclination, longitude: $longitude, periapsisArg: $periapsisArg, periodDays: $periodDays, speedKph: $speedKph, speedMph: $speedMph, earthDistanceKm: $earthDistanceKm, earthDistanceMi: $earthDistanceMi, marsDistanceKm: $marsDistanceKm, marsDistanceMi: $marsDistanceMi, flickrImages: $flickrImages, wikipedia: $wikipedia, video: $video, details: $details, id: $id)'; + } +} + +/// @nodoc +abstract mixin class $NetworkRoadsterModelCopyWith<$Res> { + factory $NetworkRoadsterModelCopyWith(NetworkRoadsterModel value, + $Res Function(NetworkRoadsterModel) _then) = + _$NetworkRoadsterModelCopyWithImpl; + @useResult + $Res call( + {String? name, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id}); +} + +/// @nodoc +class _$NetworkRoadsterModelCopyWithImpl<$Res> + implements $NetworkRoadsterModelCopyWith<$Res> { + _$NetworkRoadsterModelCopyWithImpl(this._self, this._then); + + final NetworkRoadsterModel _self; + final $Res Function(NetworkRoadsterModel) _then; + + /// Create a copy of NetworkRoadsterModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = freezed, + Object? launchDateUtc = freezed, + Object? launchDateUnix = freezed, + Object? launchMassKg = freezed, + Object? launchMassLbs = freezed, + Object? noradId = freezed, + Object? epochJd = freezed, + Object? orbitType = freezed, + Object? apoapsisAu = freezed, + Object? periapsisAu = freezed, + Object? semiMajorAxisAu = freezed, + Object? eccentricity = freezed, + Object? inclination = freezed, + Object? longitude = freezed, + Object? periapsisArg = freezed, + Object? periodDays = freezed, + Object? speedKph = freezed, + Object? speedMph = freezed, + Object? earthDistanceKm = freezed, + Object? earthDistanceMi = freezed, + Object? marsDistanceKm = freezed, + Object? marsDistanceMi = freezed, + Object? flickrImages = freezed, + Object? wikipedia = freezed, + Object? video = freezed, + Object? details = freezed, + Object? id = freezed, + }) { + return _then(_self.copyWith( + name: freezed == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + launchDateUtc: freezed == launchDateUtc + ? _self.launchDateUtc + : launchDateUtc // ignore: cast_nullable_to_non_nullable + as String?, + launchDateUnix: freezed == launchDateUnix + ? _self.launchDateUnix + : launchDateUnix // ignore: cast_nullable_to_non_nullable + as int?, + launchMassKg: freezed == launchMassKg + ? _self.launchMassKg + : launchMassKg // ignore: cast_nullable_to_non_nullable + as int?, + launchMassLbs: freezed == launchMassLbs + ? _self.launchMassLbs + : launchMassLbs // ignore: cast_nullable_to_non_nullable + as int?, + noradId: freezed == noradId + ? _self.noradId + : noradId // ignore: cast_nullable_to_non_nullable + as int?, + epochJd: freezed == epochJd + ? _self.epochJd + : epochJd // ignore: cast_nullable_to_non_nullable + as double?, + orbitType: freezed == orbitType + ? _self.orbitType + : orbitType // ignore: cast_nullable_to_non_nullable + as String?, + apoapsisAu: freezed == apoapsisAu + ? _self.apoapsisAu + : apoapsisAu // ignore: cast_nullable_to_non_nullable + as double?, + periapsisAu: freezed == periapsisAu + ? _self.periapsisAu + : periapsisAu // ignore: cast_nullable_to_non_nullable + as double?, + semiMajorAxisAu: freezed == semiMajorAxisAu + ? _self.semiMajorAxisAu + : semiMajorAxisAu // ignore: cast_nullable_to_non_nullable + as double?, + eccentricity: freezed == eccentricity + ? _self.eccentricity + : eccentricity // ignore: cast_nullable_to_non_nullable + as double?, + inclination: freezed == inclination + ? _self.inclination + : inclination // ignore: cast_nullable_to_non_nullable + as double?, + longitude: freezed == longitude + ? _self.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double?, + periapsisArg: freezed == periapsisArg + ? _self.periapsisArg + : periapsisArg // ignore: cast_nullable_to_non_nullable + as double?, + periodDays: freezed == periodDays + ? _self.periodDays + : periodDays // ignore: cast_nullable_to_non_nullable + as double?, + speedKph: freezed == speedKph + ? _self.speedKph + : speedKph // ignore: cast_nullable_to_non_nullable + as double?, + speedMph: freezed == speedMph + ? _self.speedMph + : speedMph // ignore: cast_nullable_to_non_nullable + as double?, + earthDistanceKm: freezed == earthDistanceKm + ? _self.earthDistanceKm + : earthDistanceKm // ignore: cast_nullable_to_non_nullable + as double?, + earthDistanceMi: freezed == earthDistanceMi + ? _self.earthDistanceMi + : earthDistanceMi // ignore: cast_nullable_to_non_nullable + as double?, + marsDistanceKm: freezed == marsDistanceKm + ? _self.marsDistanceKm + : marsDistanceKm // ignore: cast_nullable_to_non_nullable + as double?, + marsDistanceMi: freezed == marsDistanceMi + ? _self.marsDistanceMi + : marsDistanceMi // ignore: cast_nullable_to_non_nullable + as double?, + flickrImages: freezed == flickrImages + ? _self.flickrImages + : flickrImages // ignore: cast_nullable_to_non_nullable + as List?, + wikipedia: freezed == wikipedia + ? _self.wikipedia + : wikipedia // ignore: cast_nullable_to_non_nullable + as String?, + video: freezed == video + ? _self.video + : video // ignore: cast_nullable_to_non_nullable + as String?, + details: freezed == details + ? _self.details + : details // ignore: cast_nullable_to_non_nullable + as String?, + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// Adds pattern-matching-related methods to [NetworkRoadsterModel]. +extension NetworkRoadsterModelPatterns on NetworkRoadsterModel { + /// 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(_NetworkRoadsterModel value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel() 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(_NetworkRoadsterModel value) $default, + ) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel(): + 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(_NetworkRoadsterModel value)? $default, + ) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel() 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, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel() when $default != null: + return $default( + _that.name, + _that.launchDateUtc, + _that.launchDateUnix, + _that.launchMassKg, + _that.launchMassLbs, + _that.noradId, + _that.epochJd, + _that.orbitType, + _that.apoapsisAu, + _that.periapsisAu, + _that.semiMajorAxisAu, + _that.eccentricity, + _that.inclination, + _that.longitude, + _that.periapsisArg, + _that.periodDays, + _that.speedKph, + _that.speedMph, + _that.earthDistanceKm, + _that.earthDistanceMi, + _that.marsDistanceKm, + _that.marsDistanceMi, + _that.flickrImages, + _that.wikipedia, + _that.video, + _that.details, + _that.id); + 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, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id) + $default, + ) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel(): + return $default( + _that.name, + _that.launchDateUtc, + _that.launchDateUnix, + _that.launchMassKg, + _that.launchMassLbs, + _that.noradId, + _that.epochJd, + _that.orbitType, + _that.apoapsisAu, + _that.periapsisAu, + _that.semiMajorAxisAu, + _that.eccentricity, + _that.inclination, + _that.longitude, + _that.periapsisArg, + _that.periodDays, + _that.speedKph, + _that.speedMph, + _that.earthDistanceKm, + _that.earthDistanceMi, + _that.marsDistanceKm, + _that.marsDistanceMi, + _that.flickrImages, + _that.wikipedia, + _that.video, + _that.details, + _that.id); + 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, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id)? + $default, + ) { + final _that = this; + switch (_that) { + case _NetworkRoadsterModel() when $default != null: + return $default( + _that.name, + _that.launchDateUtc, + _that.launchDateUnix, + _that.launchMassKg, + _that.launchMassLbs, + _that.noradId, + _that.epochJd, + _that.orbitType, + _that.apoapsisAu, + _that.periapsisAu, + _that.semiMajorAxisAu, + _that.eccentricity, + _that.inclination, + _that.longitude, + _that.periapsisArg, + _that.periodDays, + _that.speedKph, + _that.speedMph, + _that.earthDistanceKm, + _that.earthDistanceMi, + _that.marsDistanceKm, + _that.marsDistanceMi, + _that.flickrImages, + _that.wikipedia, + _that.video, + _that.details, + _that.id); + case _: + return null; + } + } +} + +/// @nodoc +@JsonSerializable() +class _NetworkRoadsterModel extends NetworkRoadsterModel { + const _NetworkRoadsterModel( + {this.name, + @JsonKey(name: 'launch_date_utc') this.launchDateUtc, + @JsonKey(name: 'launch_date_unix') this.launchDateUnix, + @JsonKey(name: 'launch_mass_kg') this.launchMassKg, + @JsonKey(name: 'launch_mass_lbs') this.launchMassLbs, + @JsonKey(name: 'norad_id') this.noradId, + @JsonKey(name: 'epoch_jd') this.epochJd, + @JsonKey(name: 'orbit_type') this.orbitType, + @JsonKey(name: 'apoapsis_au') this.apoapsisAu, + @JsonKey(name: 'periapsis_au') this.periapsisAu, + @JsonKey(name: 'semi_major_axis_au') this.semiMajorAxisAu, + this.eccentricity, + this.inclination, + this.longitude, + @JsonKey(name: 'periapsis_arg') this.periapsisArg, + @JsonKey(name: 'period_days') this.periodDays, + @JsonKey(name: 'speed_kph') this.speedKph, + @JsonKey(name: 'speed_mph') this.speedMph, + @JsonKey(name: 'earth_distance_km') this.earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') this.earthDistanceMi, + @JsonKey(name: 'mars_distance_km') this.marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') this.marsDistanceMi, + @JsonKey(name: 'flickr_images') final List? flickrImages, + this.wikipedia, + this.video, + this.details, + this.id}) + : _flickrImages = flickrImages, + super._(); + factory _NetworkRoadsterModel.fromJson(Map json) => + _$NetworkRoadsterModelFromJson(json); + + @override + final String? name; + @override + @JsonKey(name: 'launch_date_utc') + final String? launchDateUtc; + @override + @JsonKey(name: 'launch_date_unix') + final int? launchDateUnix; + @override + @JsonKey(name: 'launch_mass_kg') + final int? launchMassKg; + @override + @JsonKey(name: 'launch_mass_lbs') + final int? launchMassLbs; + @override + @JsonKey(name: 'norad_id') + final int? noradId; + @override + @JsonKey(name: 'epoch_jd') + final double? epochJd; + @override + @JsonKey(name: 'orbit_type') + final String? orbitType; + @override + @JsonKey(name: 'apoapsis_au') + final double? apoapsisAu; + @override + @JsonKey(name: 'periapsis_au') + final double? periapsisAu; + @override + @JsonKey(name: 'semi_major_axis_au') + final double? semiMajorAxisAu; + @override + final double? eccentricity; + @override + final double? inclination; + @override + final double? longitude; + @override + @JsonKey(name: 'periapsis_arg') + final double? periapsisArg; + @override + @JsonKey(name: 'period_days') + final double? periodDays; + @override + @JsonKey(name: 'speed_kph') + final double? speedKph; + @override + @JsonKey(name: 'speed_mph') + final double? speedMph; + @override + @JsonKey(name: 'earth_distance_km') + final double? earthDistanceKm; + @override + @JsonKey(name: 'earth_distance_mi') + final double? earthDistanceMi; + @override + @JsonKey(name: 'mars_distance_km') + final double? marsDistanceKm; + @override + @JsonKey(name: 'mars_distance_mi') + final double? marsDistanceMi; + final List? _flickrImages; + @override + @JsonKey(name: 'flickr_images') + List? get flickrImages { + final value = _flickrImages; + if (value == null) return null; + if (_flickrImages is EqualUnmodifiableListView) return _flickrImages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + final String? wikipedia; + @override + final String? video; + @override + final String? details; + @override + final String? id; + + /// Create a copy of NetworkRoadsterModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$NetworkRoadsterModelCopyWith<_NetworkRoadsterModel> get copyWith => + __$NetworkRoadsterModelCopyWithImpl<_NetworkRoadsterModel>( + this, _$identity); + + @override + Map toJson() { + return _$NetworkRoadsterModelToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _NetworkRoadsterModel && + (identical(other.name, name) || other.name == name) && + (identical(other.launchDateUtc, launchDateUtc) || + other.launchDateUtc == launchDateUtc) && + (identical(other.launchDateUnix, launchDateUnix) || + other.launchDateUnix == launchDateUnix) && + (identical(other.launchMassKg, launchMassKg) || + other.launchMassKg == launchMassKg) && + (identical(other.launchMassLbs, launchMassLbs) || + other.launchMassLbs == launchMassLbs) && + (identical(other.noradId, noradId) || other.noradId == noradId) && + (identical(other.epochJd, epochJd) || other.epochJd == epochJd) && + (identical(other.orbitType, orbitType) || + other.orbitType == orbitType) && + (identical(other.apoapsisAu, apoapsisAu) || + other.apoapsisAu == apoapsisAu) && + (identical(other.periapsisAu, periapsisAu) || + other.periapsisAu == periapsisAu) && + (identical(other.semiMajorAxisAu, semiMajorAxisAu) || + other.semiMajorAxisAu == semiMajorAxisAu) && + (identical(other.eccentricity, eccentricity) || + other.eccentricity == eccentricity) && + (identical(other.inclination, inclination) || + other.inclination == inclination) && + (identical(other.longitude, longitude) || + other.longitude == longitude) && + (identical(other.periapsisArg, periapsisArg) || + other.periapsisArg == periapsisArg) && + (identical(other.periodDays, periodDays) || + other.periodDays == periodDays) && + (identical(other.speedKph, speedKph) || + other.speedKph == speedKph) && + (identical(other.speedMph, speedMph) || + other.speedMph == speedMph) && + (identical(other.earthDistanceKm, earthDistanceKm) || + other.earthDistanceKm == earthDistanceKm) && + (identical(other.earthDistanceMi, earthDistanceMi) || + other.earthDistanceMi == earthDistanceMi) && + (identical(other.marsDistanceKm, marsDistanceKm) || + other.marsDistanceKm == marsDistanceKm) && + (identical(other.marsDistanceMi, marsDistanceMi) || + other.marsDistanceMi == marsDistanceMi) && + const DeepCollectionEquality() + .equals(other._flickrImages, _flickrImages) && + (identical(other.wikipedia, wikipedia) || + other.wikipedia == wikipedia) && + (identical(other.video, video) || other.video == video) && + (identical(other.details, details) || other.details == details) && + (identical(other.id, id) || other.id == id)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hashAll([ + runtimeType, + name, + launchDateUtc, + launchDateUnix, + launchMassKg, + launchMassLbs, + noradId, + epochJd, + orbitType, + apoapsisAu, + periapsisAu, + semiMajorAxisAu, + eccentricity, + inclination, + longitude, + periapsisArg, + periodDays, + speedKph, + speedMph, + earthDistanceKm, + earthDistanceMi, + marsDistanceKm, + marsDistanceMi, + const DeepCollectionEquality().hash(_flickrImages), + wikipedia, + video, + details, + id + ]); + + @override + String toString() { + return 'NetworkRoadsterModel(name: $name, launchDateUtc: $launchDateUtc, launchDateUnix: $launchDateUnix, launchMassKg: $launchMassKg, launchMassLbs: $launchMassLbs, noradId: $noradId, epochJd: $epochJd, orbitType: $orbitType, apoapsisAu: $apoapsisAu, periapsisAu: $periapsisAu, semiMajorAxisAu: $semiMajorAxisAu, eccentricity: $eccentricity, inclination: $inclination, longitude: $longitude, periapsisArg: $periapsisArg, periodDays: $periodDays, speedKph: $speedKph, speedMph: $speedMph, earthDistanceKm: $earthDistanceKm, earthDistanceMi: $earthDistanceMi, marsDistanceKm: $marsDistanceKm, marsDistanceMi: $marsDistanceMi, flickrImages: $flickrImages, wikipedia: $wikipedia, video: $video, details: $details, id: $id)'; + } +} + +/// @nodoc +abstract mixin class _$NetworkRoadsterModelCopyWith<$Res> + implements $NetworkRoadsterModelCopyWith<$Res> { + factory _$NetworkRoadsterModelCopyWith(_NetworkRoadsterModel value, + $Res Function(_NetworkRoadsterModel) _then) = + __$NetworkRoadsterModelCopyWithImpl; + @override + @useResult + $Res call( + {String? name, + @JsonKey(name: 'launch_date_utc') String? launchDateUtc, + @JsonKey(name: 'launch_date_unix') int? launchDateUnix, + @JsonKey(name: 'launch_mass_kg') int? launchMassKg, + @JsonKey(name: 'launch_mass_lbs') int? launchMassLbs, + @JsonKey(name: 'norad_id') int? noradId, + @JsonKey(name: 'epoch_jd') double? epochJd, + @JsonKey(name: 'orbit_type') String? orbitType, + @JsonKey(name: 'apoapsis_au') double? apoapsisAu, + @JsonKey(name: 'periapsis_au') double? periapsisAu, + @JsonKey(name: 'semi_major_axis_au') double? semiMajorAxisAu, + double? eccentricity, + double? inclination, + double? longitude, + @JsonKey(name: 'periapsis_arg') double? periapsisArg, + @JsonKey(name: 'period_days') double? periodDays, + @JsonKey(name: 'speed_kph') double? speedKph, + @JsonKey(name: 'speed_mph') double? speedMph, + @JsonKey(name: 'earth_distance_km') double? earthDistanceKm, + @JsonKey(name: 'earth_distance_mi') double? earthDistanceMi, + @JsonKey(name: 'mars_distance_km') double? marsDistanceKm, + @JsonKey(name: 'mars_distance_mi') double? marsDistanceMi, + @JsonKey(name: 'flickr_images') List? flickrImages, + String? wikipedia, + String? video, + String? details, + String? id}); +} + +/// @nodoc +class __$NetworkRoadsterModelCopyWithImpl<$Res> + implements _$NetworkRoadsterModelCopyWith<$Res> { + __$NetworkRoadsterModelCopyWithImpl(this._self, this._then); + + final _NetworkRoadsterModel _self; + final $Res Function(_NetworkRoadsterModel) _then; + + /// Create a copy of NetworkRoadsterModel + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? name = freezed, + Object? launchDateUtc = freezed, + Object? launchDateUnix = freezed, + Object? launchMassKg = freezed, + Object? launchMassLbs = freezed, + Object? noradId = freezed, + Object? epochJd = freezed, + Object? orbitType = freezed, + Object? apoapsisAu = freezed, + Object? periapsisAu = freezed, + Object? semiMajorAxisAu = freezed, + Object? eccentricity = freezed, + Object? inclination = freezed, + Object? longitude = freezed, + Object? periapsisArg = freezed, + Object? periodDays = freezed, + Object? speedKph = freezed, + Object? speedMph = freezed, + Object? earthDistanceKm = freezed, + Object? earthDistanceMi = freezed, + Object? marsDistanceKm = freezed, + Object? marsDistanceMi = freezed, + Object? flickrImages = freezed, + Object? wikipedia = freezed, + Object? video = freezed, + Object? details = freezed, + Object? id = freezed, + }) { + return _then(_NetworkRoadsterModel( + name: freezed == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + launchDateUtc: freezed == launchDateUtc + ? _self.launchDateUtc + : launchDateUtc // ignore: cast_nullable_to_non_nullable + as String?, + launchDateUnix: freezed == launchDateUnix + ? _self.launchDateUnix + : launchDateUnix // ignore: cast_nullable_to_non_nullable + as int?, + launchMassKg: freezed == launchMassKg + ? _self.launchMassKg + : launchMassKg // ignore: cast_nullable_to_non_nullable + as int?, + launchMassLbs: freezed == launchMassLbs + ? _self.launchMassLbs + : launchMassLbs // ignore: cast_nullable_to_non_nullable + as int?, + noradId: freezed == noradId + ? _self.noradId + : noradId // ignore: cast_nullable_to_non_nullable + as int?, + epochJd: freezed == epochJd + ? _self.epochJd + : epochJd // ignore: cast_nullable_to_non_nullable + as double?, + orbitType: freezed == orbitType + ? _self.orbitType + : orbitType // ignore: cast_nullable_to_non_nullable + as String?, + apoapsisAu: freezed == apoapsisAu + ? _self.apoapsisAu + : apoapsisAu // ignore: cast_nullable_to_non_nullable + as double?, + periapsisAu: freezed == periapsisAu + ? _self.periapsisAu + : periapsisAu // ignore: cast_nullable_to_non_nullable + as double?, + semiMajorAxisAu: freezed == semiMajorAxisAu + ? _self.semiMajorAxisAu + : semiMajorAxisAu // ignore: cast_nullable_to_non_nullable + as double?, + eccentricity: freezed == eccentricity + ? _self.eccentricity + : eccentricity // ignore: cast_nullable_to_non_nullable + as double?, + inclination: freezed == inclination + ? _self.inclination + : inclination // ignore: cast_nullable_to_non_nullable + as double?, + longitude: freezed == longitude + ? _self.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double?, + periapsisArg: freezed == periapsisArg + ? _self.periapsisArg + : periapsisArg // ignore: cast_nullable_to_non_nullable + as double?, + periodDays: freezed == periodDays + ? _self.periodDays + : periodDays // ignore: cast_nullable_to_non_nullable + as double?, + speedKph: freezed == speedKph + ? _self.speedKph + : speedKph // ignore: cast_nullable_to_non_nullable + as double?, + speedMph: freezed == speedMph + ? _self.speedMph + : speedMph // ignore: cast_nullable_to_non_nullable + as double?, + earthDistanceKm: freezed == earthDistanceKm + ? _self.earthDistanceKm + : earthDistanceKm // ignore: cast_nullable_to_non_nullable + as double?, + earthDistanceMi: freezed == earthDistanceMi + ? _self.earthDistanceMi + : earthDistanceMi // ignore: cast_nullable_to_non_nullable + as double?, + marsDistanceKm: freezed == marsDistanceKm + ? _self.marsDistanceKm + : marsDistanceKm // ignore: cast_nullable_to_non_nullable + as double?, + marsDistanceMi: freezed == marsDistanceMi + ? _self.marsDistanceMi + : marsDistanceMi // ignore: cast_nullable_to_non_nullable + as double?, + flickrImages: freezed == flickrImages + ? _self._flickrImages + : flickrImages // ignore: cast_nullable_to_non_nullable + as List?, + wikipedia: freezed == wikipedia + ? _self.wikipedia + : wikipedia // ignore: cast_nullable_to_non_nullable + as String?, + video: freezed == video + ? _self.video + : video // ignore: cast_nullable_to_non_nullable + as String?, + details: freezed == details + ? _self.details + : details // ignore: cast_nullable_to_non_nullable + as String?, + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +// dart format on diff --git a/lib/data/network/model/roadster/network_roadster_model.g.dart b/lib/data/network/model/roadster/network_roadster_model.g.dart new file mode 100644 index 0000000..ce95955 --- /dev/null +++ b/lib/data/network/model/roadster/network_roadster_model.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'network_roadster_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NetworkRoadsterModel _$NetworkRoadsterModelFromJson( + Map json) => + _NetworkRoadsterModel( + name: json['name'] as String?, + launchDateUtc: json['launch_date_utc'] as String?, + launchDateUnix: (json['launch_date_unix'] as num?)?.toInt(), + launchMassKg: (json['launch_mass_kg'] as num?)?.toInt(), + launchMassLbs: (json['launch_mass_lbs'] as num?)?.toInt(), + noradId: (json['norad_id'] as num?)?.toInt(), + epochJd: (json['epoch_jd'] as num?)?.toDouble(), + orbitType: json['orbit_type'] as String?, + apoapsisAu: (json['apoapsis_au'] as num?)?.toDouble(), + periapsisAu: (json['periapsis_au'] as num?)?.toDouble(), + semiMajorAxisAu: (json['semi_major_axis_au'] as num?)?.toDouble(), + eccentricity: (json['eccentricity'] as num?)?.toDouble(), + inclination: (json['inclination'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + periapsisArg: (json['periapsis_arg'] as num?)?.toDouble(), + periodDays: (json['period_days'] as num?)?.toDouble(), + speedKph: (json['speed_kph'] as num?)?.toDouble(), + speedMph: (json['speed_mph'] as num?)?.toDouble(), + earthDistanceKm: (json['earth_distance_km'] as num?)?.toDouble(), + earthDistanceMi: (json['earth_distance_mi'] as num?)?.toDouble(), + marsDistanceKm: (json['mars_distance_km'] as num?)?.toDouble(), + marsDistanceMi: (json['mars_distance_mi'] as num?)?.toDouble(), + flickrImages: (json['flickr_images'] as List?) + ?.map((e) => e as String) + .toList(), + wikipedia: json['wikipedia'] as String?, + video: json['video'] as String?, + details: json['details'] as String?, + id: json['id'] as String?, + ); + +Map _$NetworkRoadsterModelToJson( + _NetworkRoadsterModel instance) => + { + 'name': instance.name, + 'launch_date_utc': instance.launchDateUtc, + 'launch_date_unix': instance.launchDateUnix, + 'launch_mass_kg': instance.launchMassKg, + 'launch_mass_lbs': instance.launchMassLbs, + 'norad_id': instance.noradId, + 'epoch_jd': instance.epochJd, + 'orbit_type': instance.orbitType, + 'apoapsis_au': instance.apoapsisAu, + 'periapsis_au': instance.periapsisAu, + 'semi_major_axis_au': instance.semiMajorAxisAu, + 'eccentricity': instance.eccentricity, + 'inclination': instance.inclination, + 'longitude': instance.longitude, + 'periapsis_arg': instance.periapsisArg, + 'period_days': instance.periodDays, + 'speed_kph': instance.speedKph, + 'speed_mph': instance.speedMph, + 'earth_distance_km': instance.earthDistanceKm, + 'earth_distance_mi': instance.earthDistanceMi, + 'mars_distance_km': instance.marsDistanceKm, + 'mars_distance_mi': instance.marsDistanceMi, + 'flickr_images': instance.flickrImages, + 'wikipedia': instance.wikipedia, + 'video': instance.video, + 'details': instance.details, + 'id': instance.id, + }; 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 ba24df7..443ea3e 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 @@ -51,7 +51,6 @@ abstract mixin class $NetworkFirstStageModelCopyWith<$Res> { factory $NetworkFirstStageModelCopyWith(NetworkFirstStageModel value, $Res Function(NetworkFirstStageModel) _then) = _$NetworkFirstStageModelCopyWithImpl; - @useResult $Res call({List? cores}); } @@ -243,12 +242,10 @@ class _NetworkFirstStageModel extends NetworkFirstStageModel { const _NetworkFirstStageModel({final List? cores}) : _cores = cores, super._(); - factory _NetworkFirstStageModel.fromJson(Map json) => _$NetworkFirstStageModelFromJson(json); final List? _cores; - @override List? get cores { final value = _cores; @@ -299,7 +296,6 @@ abstract mixin class _$NetworkFirstStageModelCopyWith<$Res> factory _$NetworkFirstStageModelCopyWith(_NetworkFirstStageModel value, $Res Function(_NetworkFirstStageModel) _then) = __$NetworkFirstStageModelCopyWithImpl; - @override @useResult $Res call({List? cores}); diff --git a/lib/data/network/service/constants.dart b/lib/data/network/service/constants.dart index 825f668..3b36e3f 100644 --- a/lib/data/network/service/constants.dart +++ b/lib/data/network/service/constants.dart @@ -1 +1,2 @@ const String baseUrl = 'https://api.spacexdata.com/v3/'; +const String baseUrlVersion4 = 'https://api.spacexdata.com/v4/'; diff --git a/lib/data/network/service/roadster/roadster_service.dart b/lib/data/network/service/roadster/roadster_service.dart new file mode 100644 index 0000000..9757cce --- /dev/null +++ b/lib/data/network/service/roadster/roadster_service.dart @@ -0,0 +1,14 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/constants.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'roadster_service.g.dart'; + +@RestApi(baseUrl: baseUrlVersion4) +abstract class RoadsterService { + factory RoadsterService(Dio dio) = _RoadsterService; + + @GET('roadster') + Future fetchRoadster(); +} diff --git a/lib/data/network/service/roadster/roadster_service.g.dart b/lib/data/network/service/roadster/roadster_service.g.dart new file mode 100644 index 0000000..6989248 --- /dev/null +++ b/lib/data/network/service/roadster/roadster_service.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'roadster_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 _RoadsterService implements RoadsterService { + _RoadsterService(this._dio, {this.baseUrl, this.errorLogger}) { + baseUrl ??= 'https://api.spacexdata.com/v4/'; + } + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future fetchRoadster() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'roadster', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late NetworkRoadsterModel _value; + try { + _value = NetworkRoadsterModel.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 846c3fc..9089f7f 100644 --- a/lib/di/app_bloc_providers.dart +++ b/lib/di/app_bloc_providers.dart @@ -3,9 +3,11 @@ 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/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/email_list_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'; import 'package:flutter_bloc_app_template/repository/theme_repository.dart'; import 'package:provider/single_child_widget.dart' show SingleChildWidget; @@ -41,6 +43,13 @@ abstract class AppBlocProviders { const RocketsEvent.load(), ), ), + BlocProvider( + create: (context) => RoadsterBloc( + RepositoryProvider.of(context), + )..add( + const RoadsterEvent.load(), + ), + ), BlocProvider( create: (_) => InitBloc() ..add( diff --git a/lib/di/app_repository_providers.dart b/lib/di/app_repository_providers.dart index 4547b21..7cc92a4 100644 --- a/lib/di/app_repository_providers.dart +++ b/lib/di/app_repository_providers.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.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'; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart'; import 'package:flutter_bloc_app_template/routes/router.dart'; import 'package:provider/single_child_widget.dart' show SingleChildWidget; @@ -22,6 +23,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 968a123..9050b05 100644 --- a/lib/di/di_initializer.config.dart +++ b/lib/di/di_initializer.config.dart @@ -13,10 +13,14 @@ import 'package:dio/dio.dart' as _i361; import 'package:flutter/material.dart' as _i409; 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/launch/launch_service.dart' as _i511; +import 'package:flutter_bloc_app_template/data/network/service/roadster/roadster_service.dart' + as _i837; import 'package:flutter_bloc_app_template/data/network/service/rocket/rocket_service.dart' as _i1029; import 'package:flutter_bloc_app_template/data/theme_storage.dart' as _i750; @@ -27,6 +31,8 @@ import 'package:flutter_bloc_app_template/di/di_repository_module.dart' as _i381; import 'package:flutter_bloc_app_template/repository/launches_repository.dart' as _i11; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart' + as _i128; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart' as _i31; import 'package:flutter_bloc_app_template/repository/theme_repository.dart' @@ -59,14 +65,20 @@ extension GetItInjectableX on _i174.GetIt { () => networkModule.provideLaunchService(gh<_i361.Dio>())); gh.factory<_i1029.RocketService>( () => networkModule.provideRocketService(gh<_i361.Dio>())); + gh.factory<_i837.RoadsterService>( + () => networkModule.provideRoadsterService(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<_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<_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 ec22013..7ddb185 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/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/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'; import 'package:injectable/injectable.dart'; import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; @@ -34,6 +36,11 @@ abstract class NetworkModule { return RocketService(dio); } + @factoryMethod + RoadsterService provideRoadsterService(Dio dio) { + return RoadsterService(dio); + } + @factoryMethod LaunchesDataSource provideLaunchesDataSource(LaunchService service) { return LaunchesNetworkDataSource(service); @@ -43,4 +50,9 @@ abstract class NetworkModule { RocketDataSource provideRocketDataSource(RocketService service) { return RocketNetworkDataSource(service); } + + @factoryMethod + RoadsterDataSource provideRoadsterDataSource(RoadsterService service) { + return RoadsterNetworkDataSource(service); + } } diff --git a/lib/di/di_repository_module.dart b/lib/di/di_repository_module.dart index b8a8bd6..25983dc 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/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/launches_repository.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart'; import 'package:flutter_bloc_app_template/repository/theme_repository.dart'; import 'package:injectable/injectable.dart'; @@ -19,4 +21,8 @@ abstract class RepositoryModule { @factoryMethod RocketRepository provideRocketRepository(RocketDataSource dataSource) => RocketRepositoryImpl(dataSource); + + @factoryMethod + RoadsterRepository provideRoadsterRepository(RoadsterDataSource dataSource) => + RoadsterRepositoryImpl(dataSource); } diff --git a/lib/features/roadster/bloc/roadster_bloc.dart b/lib/features/roadster/bloc/roadster_bloc.dart new file mode 100644 index 0000000..38e41db --- /dev/null +++ b/lib/features/roadster/bloc/roadster_bloc.dart @@ -0,0 +1,24 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'roadster_bloc.freezed.dart'; +part 'roadster_event.dart'; +part 'roadster_state.dart'; + +class RoadsterBloc extends Bloc { + RoadsterBloc(this._repository) : super(const RoadsterState.loading()) { + on((event, emit) async { + emit(const RoadsterState.loading()); + try { + final roadster = await _repository.getRoadster(); + emit(RoadsterState.success(roadster: roadster)); + } catch (e) { + emit(const RoadsterState.error()); + } + }); + } + + final RoadsterRepository _repository; +} diff --git a/lib/features/roadster/bloc/roadster_bloc.freezed.dart b/lib/features/roadster/bloc/roadster_bloc.freezed.dart new file mode 100644 index 0000000..3b38ee9 --- /dev/null +++ b/lib/features/roadster/bloc/roadster_bloc.freezed.dart @@ -0,0 +1,535 @@ +// 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 'roadster_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$RoadsterEvent { + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is RoadsterEvent); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'RoadsterEvent()'; + } +} + +/// @nodoc +class $RoadsterEventCopyWith<$Res> { + $RoadsterEventCopyWith(RoadsterEvent _, $Res Function(RoadsterEvent) __); +} + +/// Adds pattern-matching-related methods to [RoadsterEvent]. +extension RoadsterEventPatterns on RoadsterEvent { + /// 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(RoadsterLoadEvent value)? load, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent() when load != null: + return load(_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(RoadsterLoadEvent value) load, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent(): + return load(_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(RoadsterLoadEvent value)? load, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent() when load != null: + return load(_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()? load, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent() when load != null: + return load(); + 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() load, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent(): + return load(); + 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()? load, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadEvent() when load != null: + return load(); + case _: + return null; + } + } +} + +/// @nodoc + +class RoadsterLoadEvent implements RoadsterEvent { + const RoadsterLoadEvent(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is RoadsterLoadEvent); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'RoadsterEvent.load()'; + } +} + +/// @nodoc +mixin _$RoadsterState { + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is RoadsterState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'RoadsterState()'; + } +} + +/// @nodoc +class $RoadsterStateCopyWith<$Res> { + $RoadsterStateCopyWith(RoadsterState _, $Res Function(RoadsterState) __); +} + +/// Adds pattern-matching-related methods to [RoadsterState]. +extension RoadsterStatePatterns on RoadsterState { + /// 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(RoadsterLoadingState value)? loading, + TResult Function(RoadsterSuccessState value)? success, + TResult Function(RoadsterErrorState value)? error, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState() when loading != null: + return loading(_that); + case RoadsterSuccessState() when success != null: + return success(_that); + case RoadsterErrorState() when error != null: + return error(_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(RoadsterLoadingState value) loading, + required TResult Function(RoadsterSuccessState value) success, + required TResult Function(RoadsterErrorState value) error, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState(): + return loading(_that); + case RoadsterSuccessState(): + return success(_that); + case RoadsterErrorState(): + return error(_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(RoadsterLoadingState value)? loading, + TResult? Function(RoadsterSuccessState value)? success, + TResult? Function(RoadsterErrorState value)? error, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState() when loading != null: + return loading(_that); + case RoadsterSuccessState() when success != null: + return success(_that); + case RoadsterErrorState() when error != null: + return error(_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(RoadsterResource roadster)? success, + TResult Function()? error, + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState() when loading != null: + return loading(); + case RoadsterSuccessState() when success != null: + return success(_that.roadster); + case RoadsterErrorState() when error != null: + return error(); + 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(RoadsterResource roadster) success, + required TResult Function() error, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState(): + return loading(); + case RoadsterSuccessState(): + return success(_that.roadster); + case RoadsterErrorState(): + return error(); + 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(RoadsterResource roadster)? success, + TResult? Function()? error, + }) { + final _that = this; + switch (_that) { + case RoadsterLoadingState() when loading != null: + return loading(); + case RoadsterSuccessState() when success != null: + return success(_that.roadster); + case RoadsterErrorState() when error != null: + return error(); + case _: + return null; + } + } +} + +/// @nodoc + +class RoadsterLoadingState implements RoadsterState { + const RoadsterLoadingState(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is RoadsterLoadingState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'RoadsterState.loading()'; + } +} + +/// @nodoc + +class RoadsterSuccessState implements RoadsterState { + const RoadsterSuccessState({required this.roadster}); + + final RoadsterResource roadster; + + /// Create a copy of RoadsterState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $RoadsterSuccessStateCopyWith get copyWith => + _$RoadsterSuccessStateCopyWithImpl( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is RoadsterSuccessState && + (identical(other.roadster, roadster) || + other.roadster == roadster)); + } + + @override + int get hashCode => Object.hash(runtimeType, roadster); + + @override + String toString() { + return 'RoadsterState.success(roadster: $roadster)'; + } +} + +/// @nodoc +abstract mixin class $RoadsterSuccessStateCopyWith<$Res> + implements $RoadsterStateCopyWith<$Res> { + factory $RoadsterSuccessStateCopyWith(RoadsterSuccessState value, + $Res Function(RoadsterSuccessState) _then) = + _$RoadsterSuccessStateCopyWithImpl; + @useResult + $Res call({RoadsterResource roadster}); +} + +/// @nodoc +class _$RoadsterSuccessStateCopyWithImpl<$Res> + implements $RoadsterSuccessStateCopyWith<$Res> { + _$RoadsterSuccessStateCopyWithImpl(this._self, this._then); + + final RoadsterSuccessState _self; + final $Res Function(RoadsterSuccessState) _then; + + /// Create a copy of RoadsterState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? roadster = null, + }) { + return _then(RoadsterSuccessState( + roadster: null == roadster + ? _self.roadster + : roadster // ignore: cast_nullable_to_non_nullable + as RoadsterResource, + )); + } +} + +/// @nodoc + +class RoadsterErrorState implements RoadsterState { + const RoadsterErrorState(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is RoadsterErrorState); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'RoadsterState.error()'; + } +} + +// dart format on diff --git a/lib/features/roadster/bloc/roadster_event.dart b/lib/features/roadster/bloc/roadster_event.dart new file mode 100644 index 0000000..c4062b6 --- /dev/null +++ b/lib/features/roadster/bloc/roadster_event.dart @@ -0,0 +1,6 @@ +part of 'roadster_bloc.dart'; + +@Freezed() +abstract class RoadsterEvent with _$RoadsterEvent { + const factory RoadsterEvent.load() = RoadsterLoadEvent; +} diff --git a/lib/features/roadster/bloc/roadster_state.dart b/lib/features/roadster/bloc/roadster_state.dart new file mode 100644 index 0000000..37bf116 --- /dev/null +++ b/lib/features/roadster/bloc/roadster_state.dart @@ -0,0 +1,12 @@ +part of 'roadster_bloc.dart'; + +@Freezed() +abstract class RoadsterState with _$RoadsterState { + const factory RoadsterState.loading() = RoadsterLoadingState; + + const factory RoadsterState.success({ + required RoadsterResource roadster, + }) = RoadsterSuccessState; + + const factory RoadsterState.error() = RoadsterErrorState; +} diff --git a/lib/features/roadster/model/mission.dart b/lib/features/roadster/model/mission.dart new file mode 100644 index 0000000..2c00644 --- /dev/null +++ b/lib/features/roadster/model/mission.dart @@ -0,0 +1,11 @@ +import 'package:equatable/equatable.dart'; + +class Mission extends Equatable { + Mission({required this.name, required this.isPrimary}); + + final String name; + final bool isPrimary; + + @override + List get props => [name, isPrimary]; +} diff --git a/lib/features/roadster/model/orbital_data.dart b/lib/features/roadster/model/orbital_data.dart new file mode 100644 index 0000000..0be1075 --- /dev/null +++ b/lib/features/roadster/model/orbital_data.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class OrbitalData extends Equatable { + OrbitalData({required this.label, required this.value, required this.icon}); + + final String label; + final String value; + final IconData icon; + + @override + List get props => [label, value, icon]; +} diff --git a/lib/features/roadster/roadster_screen.dart b/lib/features/roadster/roadster_screen.dart new file mode 100644 index 0000000..2d32cc5 --- /dev/null +++ b/lib/features/roadster/roadster_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_bloc_app_template/features/roadster/bloc/roadster_bloc.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/roadster_app_bar.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_gradient_background.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_stars_field.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/buttons/track_live_button.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/roadster_content.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; +import 'package:flutter_bloc_app_template/widgets/empty_widget.dart'; +import 'package:flutter_bloc_app_template/widgets/error_content.dart'; +import 'package:flutter_bloc_app_template/widgets/loading_content.dart'; + +class RoadsterScreen extends StatelessWidget { + const RoadsterScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RoadsterBloc( + RepositoryProvider.of(context), + )..add( + const RoadsterLoadEvent(), + ), + child: const RoadsterScreenBlocContent(), + ); + } +} + +class RoadsterScreenBlocContent extends StatelessWidget { + const RoadsterScreenBlocContent({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder(builder: (context, state) { + switch (state) { + case RoadsterLoadingState _: + return Scaffold( + appBar: AppBar(), + body: const LoadingContent(), + ); + case RoadsterSuccessState _: + return RoadsterDetailScreen( + roadster: state.roadster, + images: state.roadster.flickrImages ?? [], + ); + case RoadsterErrorState _: + return Scaffold( + appBar: AppBar(), + body: ErrorContent( + onTryAgainClick: () { + context.read().add( + const RoadsterLoadEvent(), + ); + }, + ), + ); + } + return EmptyWidget(); + }); + } +} + +class RoadsterDetailScreen extends StatefulWidget { + const RoadsterDetailScreen({ + super.key, + required this.roadster, + required this.images, + }); + + final RoadsterResource roadster; + final List images; + + @override + State createState() => _RoadsterDetailScreenState(); +} + +class _RoadsterDetailScreenState extends State + with TickerProviderStateMixin { + final ScrollController _scrollController = ScrollController(); + late AnimationController _fadeController; + late AnimationController _slideController; + late AnimationController _pulseController; + late AnimationController _rotationController; + + double _scrollOffset = 0.0; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + _setupScrollListener(); + } + + void _initializeAnimations() { + _fadeController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..forward(); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + )..forward(); + + _pulseController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + )..repeat(reverse: true); + + _rotationController = AnimationController( + duration: const Duration(seconds: 20), + vsync: this, + )..repeat(); + } + + void _setupScrollListener() { + _scrollController.addListener(() { + setState(() { + _scrollOffset = _scrollController.offset; + }); + }); + } + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + _pulseController.dispose(); + _rotationController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToTop() { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + // Animated background and stars + AnimatedGradientBackground(pulseController: _pulseController), + AnimatedStarsField(pulseController: _pulseController), + + // Main content + CustomScrollView( + controller: _scrollController, + slivers: [ + RoadsterAppBar( + roadster: widget.roadster, + images: widget.images, + scrollOffset: _scrollOffset, + fadeController: _fadeController, + ), + SliverToBoxAdapter( + child: RoadsterContent( + roadster: widget.roadster, + slideController: _slideController, + pulseController: _pulseController, + ), + ), + ], + ), + + // Floating action button + TrackLiveButton( + fadeController: _fadeController, + onPressed: _scrollToTop, + ), + ], + ), + ); + } +} diff --git a/lib/features/roadster/utils/roadster_utils.dart b/lib/features/roadster/utils/roadster_utils.dart new file mode 100644 index 0000000..7edcf09 --- /dev/null +++ b/lib/features/roadster/utils/roadster_utils.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +extension NumberFormatterEXt on double { + String formatSpeed() { + final formatter = NumberFormat('#,##0.00', 'en_US'); + return formatter.format(this); + } + + String formatPeriod() { + final formatter = NumberFormat('0.0', 'en_US'); + return formatter.format(this); + } +} + +extension RoadsterStringExt on String { + String toFormattedDate({String locale = 'en_US'}) { + try { + final dateTime = DateTime.parse(this).toLocal(); + final formatter = DateFormat.yMMMd(locale); + return formatter.format(dateTime); + } catch (e) { + return this; // fallback if parsing fails + } + } + + List toSplitTextWidgets({ + TextStyle? firstLineStyle, + TextStyle? secondLineStyle, + String delimiter = "'s ", + String? suffixForFirstLine, // defaults to delimiter trimmed + }) { + final idx = indexOf(delimiter); + if (idx < 0) { + return [ + Text( + this, + style: secondLineStyle ?? const TextStyle(), + ), + ]; + } + + final first = substring(0, idx); + final rest = substring(idx + delimiter.length); + final suffix = suffixForFirstLine ?? delimiter.trimRight(); + + return [ + Text( + '$first$suffix', + style: firstLineStyle ?? + const TextStyle( + color: Colors.white70, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + ), + Text( + rest, + style: secondLineStyle ?? + const TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ]; + } +} + +extension DoublRoadsterExtension on double { + /// Converts a double to a formatted string in Astronomical Units (AU) + /// Example: 1.664332332453025 -> "1.664 AU" + String toAuString({int fractionDigits = 3}) { + return '${toStringAsFixed(fractionDigits)} AU'; + } + + /// Converts a double to a string with fixed decimal places + /// Example: 0.2559348215918733 -> "0.256" + String toFixedString({int fractionDigits = 3}) { + return toStringAsFixed(fractionDigits); + } + + /// Converts a double to degrees with a ° suffix + /// Example: 1.075052357364693 -> "1.075°" + /// Example: 316.9112133411523 -> "316.91°" + String toDegreeString({int fractionDigits = 2}) { + return '${toStringAsFixed(fractionDigits)}°'; + } +} diff --git a/lib/features/roadster/widget/animated_counter_widget.dart b/lib/features/roadster/widget/animated_counter_widget.dart new file mode 100644 index 0000000..b9a0652 --- /dev/null +++ b/lib/features/roadster/widget/animated_counter_widget.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +class AnimatedCounterWidget extends StatefulWidget { + const AnimatedCounterWidget({ + super.key, + required this.value, + required this.duration, + this.decimals = 0, + }); + + final double value; + final Duration duration; + final int decimals; + + @override + State createState() => _AnimatedCounterWidgetState(); +} + +class _AnimatedCounterWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + _animation = Tween( + begin: 0, + end: widget.value, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + _controller.forward(); + } + + @override + void didUpdateWidget(covariant AnimatedCounterWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value || + oldWidget.duration != widget.duration) { + _controller.duration = widget.duration; + _animation = Tween( + begin: _animation.value, + end: widget.value, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ), + ); + _controller + ..reset() + ..forward(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final text = widget.decimals > 0 + ? _animation.value.toStringAsFixed(widget.decimals) + : _animation.value.toInt().toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + + return Text( + text, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ); + }, + ); + } +} diff --git a/lib/features/roadster/widget/animated_star_widget.dart b/lib/features/roadster/widget/animated_star_widget.dart new file mode 100644 index 0000000..358b4dd --- /dev/null +++ b/lib/features/roadster/widget/animated_star_widget.dart @@ -0,0 +1,40 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +class AnimatedStarWidget extends StatelessWidget { + const AnimatedStarWidget( + {super.key, required this.index, required this.pulseController}); + + final AnimationController pulseController; + + final int index; + + @override + Widget build(BuildContext context) { + final random = math.Random(index); + final size = random.nextDouble() * 3 + 1; + final top = random.nextDouble() * 500; + final left = random.nextDouble() * 400; + + return Positioned( + top: top, + left: left, + child: AnimatedBuilder( + animation: pulseController, + builder: (context, child) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Colors.white.withValues( + alpha: 0.3 + (pulseController.value * 0.5), + ), + shape: BoxShape.circle, + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/roadster/widget/animated_stat_card_widget.dart b/lib/features/roadster/widget/animated_stat_card_widget.dart new file mode 100644 index 0000000..1809b7b --- /dev/null +++ b/lib/features/roadster/widget/animated_stat_card_widget.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_counter_widget.dart'; + +class AnimatedStatCardWidget extends StatelessWidget { + const AnimatedStatCardWidget({ + super.key, + required this.icon, + required this.title, + required this.value, + required this.unit, + required this.color, + required this.delay, + }); + + final IconData icon; + final String title; + final String value; + final String unit; + final Color color; + final int delay; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 1000 + delay), + curve: Curves.easeOutCubic, + builder: (context, animation, child) { + return Transform.scale( + scale: animation, + child: Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withValues(alpha: 0.2), + color.withValues(alpha: 0.1), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + AnimatedCounterWidget( + value: double.parse(value.replaceAll(',', '')), + duration: Duration(milliseconds: 2000 + delay), + ), + const SizedBox(width: 4), + Text( + unit, + style: TextStyle( + color: color, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/roadster/widget/app_bar/gradient_overlay.dart b/lib/features/roadster/widget/app_bar/gradient_overlay.dart new file mode 100644 index 0000000..e410a9d --- /dev/null +++ b/lib/features/roadster/widget/app_bar/gradient_overlay.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class GradientOverlay extends StatelessWidget { + const GradientOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 100, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/app_bar/image_carousel.dart b/lib/features/roadster/widget/app_bar/image_carousel.dart new file mode 100644 index 0000000..256f1e5 --- /dev/null +++ b/lib/features/roadster/widget/app_bar/image_carousel.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +class ImageCarousel extends StatelessWidget { + const ImageCarousel({ + super.key, + required this.images, + required this.pageController, + required this.scrollOffset, + required this.onPageChanged, + }); + + final List images; + final PageController pageController; + final double scrollOffset; + final ValueChanged onPageChanged; + + double? _calculateProgress(ImageChunkEvent? loadingProgress) { + if (loadingProgress == null || loadingProgress.expectedTotalBytes == null) { + return null; + } + return loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes!; + } + + Widget _buildAnimatedImageCard(double value, String imageUrl) { + final transformedValue = Curves.easeInOut.transform(value); + final cardHeight = transformedValue * 300; + final cardWidth = transformedValue * 400; + + return SizedBox( + height: cardHeight, + width: cardWidth, + child: Card( + elevation: 10, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.network( + imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: _calculateProgress(loadingProgress), + ), + ); + }, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + key: const Key('parallax_transform'), + offset: Offset(0, scrollOffset * 0.5), + child: SizedBox( + height: 300, + child: PageView.builder( + controller: pageController, + onPageChanged: onPageChanged, + itemCount: images.length, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: pageController, + builder: (context, child) { + var value = 1.0; + if (pageController.hasClients) { + final currentPage = pageController.page ?? + pageController.initialPage.toDouble(); + value = + (1 - ((currentPage - index).abs() * 0.3)).clamp(0.0, 1.0); + } + return Center( + child: _buildAnimatedImageCard(value, images[index]), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/app_bar/image_indicators.dart b/lib/features/roadster/widget/app_bar/image_indicators.dart new file mode 100644 index 0000000..b9ef9d3 --- /dev/null +++ b/lib/features/roadster/widget/app_bar/image_indicators.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ImageIndicators extends StatelessWidget { + const ImageIndicators({ + super.key, + required this.imageCount, + required this.currentIndex, + }); + + final int imageCount; + final int currentIndex; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 10, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + imageCount, + (index) => AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 4), + height: 8, + width: currentIndex == index ? 24 : 8, + decoration: BoxDecoration( + color: currentIndex == index + ? Theme.of(context).colorScheme.primary + : Colors.white30, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/app_bar/roadster_app_bar.dart b/lib/features/roadster/widget/app_bar/roadster_app_bar.dart new file mode 100644 index 0000000..6a68255 --- /dev/null +++ b/lib/features/roadster/widget/app_bar/roadster_app_bar.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/gradient_overlay.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/image_carousel.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/roadster_title_section.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +import 'image_indicators.dart'; + +class RoadsterAppBar extends StatefulWidget { + const RoadsterAppBar({ + super.key, + required this.roadster, + required this.images, + required this.scrollOffset, + required this.fadeController, + }); + + final RoadsterResource roadster; + final List images; + final double scrollOffset; + final AnimationController fadeController; + + @override + State createState() => _RoadsterAppBarState(); +} + +class _RoadsterAppBarState extends State { + late PageController _pageController; + int _currentImageIndex = 0; + Timer? _autoScrollTimer; + + @override + void initState() { + super.initState(); + _pageController = PageController(viewportFraction: 0.85); + _startAutoScroll(); + } + + void _startAutoScroll() { + if (widget.images.length <= 1) return; + _autoScrollTimer = Timer.periodic(const Duration(seconds: 4), (timer) { + if (!mounted || !_pageController.hasClients) return; + if (_currentImageIndex < widget.images.length - 1) { + _currentImageIndex++; + } else { + _currentImageIndex = 0; + } + _pageController.animateToPage( + _currentImageIndex, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOutCubic, + ); + }); + } + + @override + void dispose() { + _pageController.dispose(); + _autoScrollTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SliverAppBar( + expandedHeight: 380, + floating: false, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + title: AnimatedOpacity( + opacity: widget.scrollOffset > 200 ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: const Text( + 'Tesla Roadster', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + background: Stack( + fit: StackFit.expand, + children: [ + ImageCarousel( + images: widget.images, + pageController: _pageController, + scrollOffset: widget.scrollOffset, + onPageChanged: (index) { + setState(() { + _currentImageIndex = index; + }); + }, + ), + const GradientOverlay(), + RoadsterTitleSection( + roadster: widget.roadster, + fadeController: widget.fadeController, + ), + ImageIndicators( + imageCount: widget.images.length, + currentIndex: _currentImageIndex, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/app_bar/roadster_title_section.dart b/lib/features/roadster/widget/app_bar/roadster_title_section.dart new file mode 100644 index 0000000..f3903d6 --- /dev/null +++ b/lib/features/roadster/widget/app_bar/roadster_title_section.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/utils/roadster_utils.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class RoadsterTitleSection extends StatelessWidget { + const RoadsterTitleSection({ + super.key, + required this.roadster, + required this.fadeController, + }); + + final RoadsterResource roadster; + final AnimationController fadeController; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 40, + left: 20, + right: 20, + child: FadeTransition( + opacity: fadeController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...(roadster.name ?? 'Elon Musk\'s Tesla Roadster') + .toSplitTextWidgets(), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.rocket_launch, + color: Colors.orange[400], + size: 20, + ), + const SizedBox(width: 8), + Text( + S.of(context).launched( + roadster.launchDateUtc?.toFormattedDate() ?? '', + ), + style: TextStyle( + color: Colors.orange[400], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/background/animated_gradient_background.dart b/lib/features/roadster/widget/background/animated_gradient_background.dart new file mode 100644 index 0000000..62fa3f9 --- /dev/null +++ b/lib/features/roadster/widget/background/animated_gradient_background.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class AnimatedGradientBackground extends StatelessWidget { + const AnimatedGradientBackground({ + super.key, + required this.pulseController, + }); + + final AnimationController pulseController; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Define gradients for light and dark themes + final darkColors = [ + const Color(0xFF0D0E1C), + const Color(0xFF1A1B3A), + const Color(0xFF2D1B3D), + ]; + + final lightColors = [ + const Color(0xFFF0F4FF), + const Color(0xFFE8ECFF), + const Color(0xFFDDE3FF), + ]; + + final colors = isDark ? darkColors : lightColors; + + return AnimatedBuilder( + animation: pulseController, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.lerp(colors[0], colors[1], pulseController.value)!, + Color.lerp(colors[1], colors[2], pulseController.value)!, + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/roadster/widget/background/animated_stars_field.dart b/lib/features/roadster/widget/background/animated_stars_field.dart new file mode 100644 index 0000000..a585604 --- /dev/null +++ b/lib/features/roadster/widget/background/animated_stars_field.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_star_widget.dart'; + +class AnimatedStarsField extends StatelessWidget { + const AnimatedStarsField({ + super.key, + required this.pulseController, + }); + + final AnimationController pulseController; + + @override + Widget build(BuildContext context) { + return Stack( + children: List.generate( + 50, + (index) => AnimatedStarWidget( + index: index, + pulseController: pulseController, + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/buttons/track_live_button.dart b/lib/features/roadster/widget/buttons/track_live_button.dart new file mode 100644 index 0000000..0b76f24 --- /dev/null +++ b/lib/features/roadster/widget/buttons/track_live_button.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class TrackLiveButton extends StatelessWidget { + const TrackLiveButton({ + super.key, + required this.fadeController, + required this.onPressed, + }); + + final AnimationController fadeController; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 20, + right: 20, + child: ScaleTransition( + scale: CurvedAnimation( + parent: fadeController, + curve: Curves.elasticOut, + ), + child: FloatingActionButton.extended( + onPressed: onPressed, + icon: const Icon(Icons.rocket_launch), + label: Text(S.of(context).trackLive), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/roadster/widget/details_card_widget.dart b/lib/features/roadster/widget/details_card_widget.dart new file mode 100644 index 0000000..89970b1 --- /dev/null +++ b/lib/features/roadster/widget/details_card_widget.dart @@ -0,0 +1,124 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/mission.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class DetailsCardWidget extends StatefulWidget { + const DetailsCardWidget({ + super.key, + required this.description1, + required this.description2, + required this.missions, + }); + + final String description1; + final String description2; + final List missions; + + @override + State createState() => _DetailsCardWidgetState(); +} + +class _DetailsCardWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _rotationController; + + @override + void initState() { + super.initState(); + + _rotationController = AnimationController( + duration: const Duration(seconds: 20), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _rotationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AnimatedBuilder( + animation: _rotationController, + builder: (context, child) { + return Transform.rotate( + angle: _rotationController.value * 2 * math.pi, + child: Icon( + Icons.satellite_alt, + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + ); + }, + ), + const SizedBox(width: 12), + Text( + S.of(context).missionDetails, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + widget.description1, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + height: 1.5, + ), + ), + const SizedBox(height: 12), + Text( + widget.description2, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + ...widget.missions.map((mission) => Chip( + avatar: const Icon(Icons.tag, size: 18), + label: Text(mission.name), + backgroundColor: mission.isPrimary + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.secondaryContainer, + )) + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/distance_card_widget.dart b/lib/features/roadster/widget/distance_card_widget.dart new file mode 100644 index 0000000..6f06772 --- /dev/null +++ b/lib/features/roadster/widget/distance_card_widget.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_counter_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class DistanceCardWidget extends StatelessWidget { + const DistanceCardWidget({ + super.key, + required this.title, + required this.distance, + required this.color, + required this.icon, + required this.delay, + required this.pulseController, + }); + + final String title; + final double distance; + final Color color; + final IconData icon; + final int delay; + final AnimationController pulseController; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 1000 + delay), + curve: Curves.easeOutBack, + builder: (context, animation, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - animation.clamp(0.0, 1.0))), + child: Opacity( + opacity: animation.clamp(0.0, 1.0), + child: Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.surface, + Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.9), + ], + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + AnimatedCounterWidget( + value: distance / 1000000, + duration: Duration(milliseconds: 2000 + delay), + decimals: 1, + ), + const SizedBox(width: 4), + Text( + S.of(context).millionKm, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + AnimatedBuilder( + animation: pulseController, + builder: (context, child) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color.withValues( + alpha: 0.5 + (pulseController.value * 0.5), + ), + shape: BoxShape.circle, + ), + ); + }, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/roadster/widget/distance_cards.dart b/lib/features/roadster/widget/distance_cards.dart new file mode 100644 index 0000000..dc4c186 --- /dev/null +++ b/lib/features/roadster/widget/distance_cards.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_card_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class DistanceCards extends StatelessWidget { + const DistanceCards({ + super.key, + required this.roadster, + required this.pulseController, + }); + + final RoadsterResource roadster; + final AnimationController pulseController; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DistanceCardWidget( + title: S.of(context).earthDistance, + distance: roadster.earthDistanceKm ?? 0, + color: Colors.blue, + icon: Icons.public, + delay: 600, + pulseController: pulseController, + ), + const SizedBox(height: 16), + DistanceCardWidget( + title: S.of(context).marsDistance, + distance: roadster.marsDistanceKm ?? 0, + color: Colors.orange, + icon: Icons.circle, + delay: 800, + pulseController: pulseController, + ), + ], + ); + } +} diff --git a/lib/features/roadster/widget/launch_details_section.dart b/lib/features/roadster/widget/launch_details_section.dart new file mode 100644 index 0000000..5361045 --- /dev/null +++ b/lib/features/roadster/widget/launch_details_section.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_section_widget.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class LaunchDetailsSection extends StatelessWidget { + const LaunchDetailsSection({ + super.key, + required this.roadster, + }); + + final RoadsterResource roadster; + + @override + Widget build(BuildContext context) { + return LaunchSectionWidget( + massKg: '${roadster.launchMassKg}', + vehicle: 'Falcon Heavy', + ); + } +} diff --git a/lib/features/roadster/widget/launch_section_widget.dart b/lib/features/roadster/widget/launch_section_widget.dart new file mode 100644 index 0000000..5d5873b --- /dev/null +++ b/lib/features/roadster/widget/launch_section_widget.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class LaunchSectionWidget extends StatelessWidget { + const LaunchSectionWidget({ + super.key, + required this.massKg, + required this.vehicle, + }); + + final String massKg; + final String vehicle; + + @override + Widget build(BuildContext context) { + final l10n = S.of(context); + + final scheme = Theme.of(context).colorScheme; + + return Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + colors: [ + scheme.primary.withValues(alpha: 0.2), + scheme.secondary.withValues(alpha: 0.1), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.rocket, + color: scheme.primary, + ), + const SizedBox(width: 8), + Text( + l10n.launchInformation, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.launchMass, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + massKg, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.launchVehicle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + vehicle, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/links_section_widget.dart b/lib/features/roadster/widget/links_section_widget.dart new file mode 100644 index 0000000..dc43a30 --- /dev/null +++ b/lib/features/roadster/widget/links_section_widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class LinksSectionWidget extends StatelessWidget { + const LinksSectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = S.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.learnMore, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.article), + label: Text(l10n.wikipedia), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.play_arrow), + label: Text(l10n.watchVideo), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/roadster/widget/mission_details_card.dart b/lib/features/roadster/widget/mission_details_card.dart new file mode 100644 index 0000000..3f9f5ea --- /dev/null +++ b/lib/features/roadster/widget/mission_details_card.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/mission.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/details_card_widget.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class MissionDetailsCard extends StatelessWidget { + const MissionDetailsCard({ + super.key, + required this.roadster, + required this.slideController, + }); + + final RoadsterResource roadster; + final AnimationController slideController; + + @override + Widget build(BuildContext context) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: slideController, + curve: Curves.easeOutCubic, + )), + child: DetailsCardWidget( + description1: roadster.details ?? '', + description2: '', + missions: [ + Mission( + name: '${roadster.noradId}', + isPrimary: true, + ), + Mission( + name: '${roadster.orbitType}', + isPrimary: false, + ), + ], + ), + ); + } +} diff --git a/lib/features/roadster/widget/orbital_parameters_section.dart b/lib/features/roadster/widget/orbital_parameters_section.dart new file mode 100644 index 0000000..a636820 --- /dev/null +++ b/lib/features/roadster/widget/orbital_parameters_section.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/orbital_data.dart'; +import 'package:flutter_bloc_app_template/features/roadster/utils/roadster_utils.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/orbital_section_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class OrbitalParametersSection extends StatelessWidget { + const OrbitalParametersSection({ + super.key, + required this.roadster, + }); + + final RoadsterResource roadster; + + @override + Widget build(BuildContext context) { + return OrbitalSectionWidget( + orbitalData: [ + OrbitalData( + label: S.of(context).apoapsis, + value: roadster.apoapsisAu?.toAuString() ?? 'N/A', + icon: Icons.arrow_upward, + ), + OrbitalData( + label: S.of(context).periapsis, + value: roadster.periapsisAu?.toAuString() ?? 'N/A', + icon: Icons.arrow_downward, + ), + OrbitalData( + label: S.of(context).semiMajorAxis, + value: roadster.semiMajorAxisAu?.toAuString() ?? 'N/A', + icon: Icons.circle_outlined, + ), + OrbitalData( + label: S.of(context).eccentricity, + value: roadster.eccentricity?.toFixedString() ?? 'N/A', + icon: Icons.blur_circular, + ), + OrbitalData( + label: S.of(context).inclination, + value: roadster.inclination?.toDegreeString() ?? 'N/A', + icon: Icons.trending_up, + ), + OrbitalData( + label: S.of(context).longitude, + value: roadster.longitude?.toDegreeString() ?? 'N/A', + icon: Icons.explore, + ), + ], + ); + } +} diff --git a/lib/features/roadster/widget/orbital_section_widget.dart b/lib/features/roadster/widget/orbital_section_widget.dart new file mode 100644 index 0000000..36e7a08 --- /dev/null +++ b/lib/features/roadster/widget/orbital_section_widget.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/orbital_data.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; + +class OrbitalSectionWidget extends StatelessWidget { + const OrbitalSectionWidget({super.key, required this.orbitalData}); + + final List orbitalData; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.public, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 8), + Text( + S.of(context).orbitalParameters, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 2.5, + ), + itemCount: orbitalData.length, + itemBuilder: (context, index) { + final data = orbitalData[index]; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Duration(milliseconds: 800 + (index * 100)), + curve: Curves.easeOutBack, + builder: (context, animation, child) { + return Transform.scale( + scale: animation, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + data.icon, + color: Theme.of(context).colorScheme.secondary, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + data.label, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + fontSize: 11, + ), + ), + Text( + data.value, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/roadster/widget/roadster_content.dart b/lib/features/roadster/widget/roadster_content.dart new file mode 100644 index 0000000..b8dbfb2 --- /dev/null +++ b/lib/features/roadster/widget/roadster_content.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_cards.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_details_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/links_section_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/mission_details_card.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/orbital_parameters_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/speed_distance_cards.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class RoadsterContent extends StatelessWidget { + const RoadsterContent({ + super.key, + required this.roadster, + required this.slideController, + required this.pulseController, + }); + + final RoadsterResource roadster; + final AnimationController slideController; + final AnimationController pulseController; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( // <--- added + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MissionDetailsCard( + roadster: roadster, + slideController: slideController, + ), + const SizedBox(height: 24), + SpeedDistanceCards(roadster: roadster), + const SizedBox(height: 16), + DistanceCards( + roadster: roadster, + pulseController: pulseController, + ), + const SizedBox(height: 24), + OrbitalParametersSection(roadster: roadster), + const SizedBox(height: 24), + LaunchDetailsSection(roadster: roadster), + const SizedBox(height: 24), + const LinksSectionWidget(), + const SizedBox(height: 100), + ], + ), + ), + ); + } +} diff --git a/lib/features/roadster/widget/speed_distance_cards.dart b/lib/features/roadster/widget/speed_distance_cards.dart new file mode 100644 index 0000000..af48052 --- /dev/null +++ b/lib/features/roadster/widget/speed_distance_cards.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/utils/roadster_utils.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_stat_card_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +class SpeedDistanceCards extends StatelessWidget { + const SpeedDistanceCards({ + super.key, + required this.roadster, + }); + + final RoadsterResource roadster; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: AnimatedStatCardWidget( + icon: Icons.speed, + title: S.of(context).currentSpeed, + value: roadster.speedKph?.formatSpeed() ?? 'N/A', + unit: S.of(context).unitKph, + color: Colors.blue, + delay: 200, + ), + ), + const SizedBox(width: 16), + Expanded( + child: AnimatedStatCardWidget( + icon: Icons.timer_outlined, + title: S.of(context).orbitalPeriod, + value: roadster.periodDays?.formatPeriod() ?? 'N/A', + unit: S.of(context).unitDays, + color: Colors.purple, + delay: 400, + ), + ), + ], + ); + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index c2756e7..1274467 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -115,6 +115,15 @@ class SettingsScreen extends StatelessWidget { NavigationService.of(context).navigateTo(Routes.appearance); }, ), + SettingItem( + key: const Key('roadster'), + title: S.of(context).roadsterTitle, + description: S.of(context).roadsterDescription, + icon: Icons.rocket_launch, + onClick: () { + NavigationService.of(context).navigateTo(Routes.roadster); + }, + ), SettingItem( key: const Key('about'), title: context.aboutSettingsItem, diff --git a/lib/generated/intl/messages_de.dart b/lib/generated/intl/messages_de.dart index 0e78794..2730dd8 100644 --- a/lib/generated/intl/messages_de.dart +++ b/lib/generated/intl/messages_de.dart @@ -28,14 +28,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(id) => "Beispielartikel ${id}"; - static String m4(launchedAt) => "Gestartet am: ${launchedAt}"; + static String m4(date) => "Gestartet: ${date}"; - static String m5(mission) => "Mission: ${mission}"; + static String m5(launchedAt) => "Gestartet am: ${launchedAt}"; - static String m6(rocketName, rocketType) => + static String m6(mission) => "Mission: ${mission}"; + + static String m7(rocketName, rocketType) => "Rakete: ${rocketName} (${rocketType})"; - static String m7(percentage) => "${percentage}% Erfolg"; + static String m8(percentage) => "${percentage}% Erfolg"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -47,6 +49,7 @@ class MessageLookup extends MessageLookupByLibrary { "allObjectivesCompleted": MessageLookupByLibrary.simpleMessage( "Alle Ziele erreicht", ), + "apoapsis": MessageLookupByLibrary.simpleMessage("Aphel"), "appTitle": MessageLookupByLibrary.simpleMessage( "flutter_bloc_app_template", ), @@ -65,6 +68,9 @@ class MessageLookup extends MessageLookupByLibrary { "Rahmenlinie", ), "coreSerial": MessageLookupByLibrary.simpleMessage("Kernseriennummer"), + "currentSpeed": MessageLookupByLibrary.simpleMessage( + "Aktuelle Geschwindigkeit", + ), "customers": MessageLookupByLibrary.simpleMessage("Kunden"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage( "Dunkles Gold-Design", @@ -100,6 +106,8 @@ class MessageLookup extends MessageLookupByLibrary { "dynamicColorSettingsItemTitle": MessageLookupByLibrary.simpleMessage( "Dynamische Farben verwenden", ), + "earthDistance": MessageLookupByLibrary.simpleMessage("Abstand zur Erde"), + "eccentricity": MessageLookupByLibrary.simpleMessage("Exzentrizität"), "emailsTitle": MessageLookupByLibrary.simpleMessage("E-Mails"), "emptyList": MessageLookupByLibrary.simpleMessage("Leere Liste"), "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Aktiviert"), @@ -115,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "iconButtonTitle": MessageLookupByLibrary.simpleMessage("Mit Icon"), "id": MessageLookupByLibrary.simpleMessage("Kennung"), + "inclination": MessageLookupByLibrary.simpleMessage("Inklination"), "itemDetailsTitle": MessageLookupByLibrary.simpleMessage("Artikeldetails"), "itemTitle": m3, "itemsTitle": MessageLookupByLibrary.simpleMessage("Beispielartikel"), @@ -124,9 +133,16 @@ class MessageLookup extends MessageLookupByLibrary { "Landung erfolgreich", ), "launch": MessageLookupByLibrary.simpleMessage("Start"), + "launchInformation": MessageLookupByLibrary.simpleMessage( + "Startinformationen", + ), + "launchMass": MessageLookupByLibrary.simpleMessage("Startmasse"), "launchSite": MessageLookupByLibrary.simpleMessage("Startplatz"), - "launchedAt": m4, + "launchVehicle": MessageLookupByLibrary.simpleMessage("Startfahrzeug"), + "launched": m4, + "launchedAt": m5, "launchesTitle": MessageLookupByLibrary.simpleMessage("Starts"), + "learnMore": MessageLookupByLibrary.simpleMessage("Mehr erfahren"), "lightGoldThemeTitle": MessageLookupByLibrary.simpleMessage( "Helles Gold-Design", ), @@ -137,9 +153,13 @@ class MessageLookup extends MessageLookupByLibrary { "linksResources": MessageLookupByLibrary.simpleMessage( "Links & Ressourcen", ), + "longitude": MessageLookupByLibrary.simpleMessage("Längengrad"), "manufacturer": MessageLookupByLibrary.simpleMessage("Hersteller"), + "marsDistance": MessageLookupByLibrary.simpleMessage("Abstand zum Mars"), "mass": MessageLookupByLibrary.simpleMessage("Masse"), "massLabel": MessageLookupByLibrary.simpleMessage("Masse"), + "millionKm": MessageLookupByLibrary.simpleMessage("Millionen km"), + "missionDetails": MessageLookupByLibrary.simpleMessage("Missionsdetails"), "missionFailed": MessageLookupByLibrary.simpleMessage( "Mission fehlgeschlagen", ), @@ -151,7 +171,7 @@ class MessageLookup extends MessageLookupByLibrary { "Mission erfolgreich", ), "missionTimeline": MessageLookupByLibrary.simpleMessage("Missionszeitplan"), - "missionTitle": m5, + "missionTitle": m6, "nationality": MessageLookupByLibrary.simpleMessage("Nationalität"), "newsScreen": MessageLookupByLibrary.simpleMessage("Nachrichten"), "noDetails": MessageLookupByLibrary.simpleMessage( @@ -166,12 +186,17 @@ class MessageLookup extends MessageLookupByLibrary { "Missionsziele nicht erreicht", ), "orbit": MessageLookupByLibrary.simpleMessage("Umlaufbahn"), + "orbitalParameters": MessageLookupByLibrary.simpleMessage( + "Orbitale Parameter", + ), + "orbitalPeriod": MessageLookupByLibrary.simpleMessage("Orbitale Periode"), "overview": MessageLookupByLibrary.simpleMessage("Übersicht"), "payload": MessageLookupByLibrary.simpleMessage("Nutzlast"), "payloadCapacity": MessageLookupByLibrary.simpleMessage( "Nutzlastkapazität", ), "payloadTitle": MessageLookupByLibrary.simpleMessage("Nutzlast"), + "periapsis": MessageLookupByLibrary.simpleMessage("Perihel"), "pressKit": MessageLookupByLibrary.simpleMessage("Pressemappe"), "propellant1Label": MessageLookupByLibrary.simpleMessage("Treibstoff 1"), "propellant2Label": MessageLookupByLibrary.simpleMessage("Treibstoff 2"), @@ -179,7 +204,11 @@ class MessageLookup extends MessageLookupByLibrary { "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), "retiredStatus": MessageLookupByLibrary.simpleMessage("Außer Dienst"), "reused": MessageLookupByLibrary.simpleMessage("Wiederverwendet"), - "rocket": m6, + "roadsterDescription": MessageLookupByLibrary.simpleMessage( + "Elon Musks Tesla Roadster", + ), + "roadsterTitle": MessageLookupByLibrary.simpleMessage("Roadster"), + "rocket": m7, "rocketBlock": MessageLookupByLibrary.simpleMessage("Block"), "rocketDetails": MessageLookupByLibrary.simpleMessage("Raketendetails"), "rocketName": MessageLookupByLibrary.simpleMessage("Raketenname"), @@ -187,6 +216,7 @@ class MessageLookup extends MessageLookupByLibrary { "rocketType": MessageLookupByLibrary.simpleMessage("Typ"), "rocketsTab": MessageLookupByLibrary.simpleMessage("Raketen"), "rocketsTitle": MessageLookupByLibrary.simpleMessage("Raketen"), + "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Große Halbachse"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Einstellungen"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("Standort-ID:"), "specifications": MessageLookupByLibrary.simpleMessage("Spezifikationen"), @@ -194,7 +224,7 @@ class MessageLookup extends MessageLookupByLibrary { "staticFireTest": MessageLookupByLibrary.simpleMessage( "Statischer Feuertest", ), - "successRate": m7, + "successRate": m8, "systemThemeTitle": MessageLookupByLibrary.simpleMessage("Systemdesign"), "tabHome": MessageLookupByLibrary.simpleMessage("Startseite"), "tabSettings": MessageLookupByLibrary.simpleMessage("Einstellungen"), @@ -203,12 +233,15 @@ class MessageLookup extends MessageLookupByLibrary { "Schub (Bodenniveau)", ), "tons": MessageLookupByLibrary.simpleMessage("Tonnen"), + "trackLive": MessageLookupByLibrary.simpleMessage("Live verfolgen"), "transparentButtonTitle": MessageLookupByLibrary.simpleMessage( "Transparent", ), "tryAgainButton": MessageLookupByLibrary.simpleMessage("Erneut versuchen"), "type": MessageLookupByLibrary.simpleMessage("Typ"), "typeLabel": MessageLookupByLibrary.simpleMessage("Typ"), + "unitDays": MessageLookupByLibrary.simpleMessage("Tage"), + "unitKph": MessageLookupByLibrary.simpleMessage("km/h"), "versionLabel": MessageLookupByLibrary.simpleMessage("Version"), "watchVideo": MessageLookupByLibrary.simpleMessage("Video ansehen"), "wikipedia": MessageLookupByLibrary.simpleMessage("Wikipedia"), diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index ba34eae..cbdfb5e 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -28,14 +28,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(id) => "Sample Item ${id}"; - static String m4(launchedAt) => "Launched at: ${launchedAt}"; + static String m4(date) => "Launched: ${date}"; - static String m5(mission) => "Mission: ${mission}"; + static String m5(launchedAt) => "Launched at: ${launchedAt}"; - static String m6(rocketName, rocketType) => + static String m6(mission) => "Mission: ${mission}"; + + static String m7(rocketName, rocketType) => "Rocket: ${rocketName} (${rocketType})"; - static String m7(percentage) => "${percentage}% success"; + static String m8(percentage) => "${percentage}% success"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -47,6 +49,7 @@ class MessageLookup extends MessageLookupByLibrary { "allObjectivesCompleted": MessageLookupByLibrary.simpleMessage( "All objectives completed", ), + "apoapsis": MessageLookupByLibrary.simpleMessage("Apoapsis"), "appTitle": MessageLookupByLibrary.simpleMessage( "flutter_bloc_app_template", ), @@ -63,6 +66,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "borderSideButtonTitle": MessageLookupByLibrary.simpleMessage("BorderSide"), "coreSerial": MessageLookupByLibrary.simpleMessage("Core Serial"), + "currentSpeed": MessageLookupByLibrary.simpleMessage("Current Speed"), "customers": MessageLookupByLibrary.simpleMessage("Customers"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage("Dark Gold"), "darkMintThemeTitle": MessageLookupByLibrary.simpleMessage("Dark Mint"), @@ -94,6 +98,8 @@ class MessageLookup extends MessageLookupByLibrary { "dynamicColorSettingsItemTitle": MessageLookupByLibrary.simpleMessage( "Use dynamic colors", ), + "earthDistance": MessageLookupByLibrary.simpleMessage("Earth Distance"), + "eccentricity": MessageLookupByLibrary.simpleMessage("Eccentricity"), "emailsTitle": MessageLookupByLibrary.simpleMessage("Emails"), "emptyList": MessageLookupByLibrary.simpleMessage("Empty list"), "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Enabled"), @@ -112,6 +118,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "iconButtonTitle": MessageLookupByLibrary.simpleMessage("With Icon"), "id": MessageLookupByLibrary.simpleMessage("ID"), + "inclination": MessageLookupByLibrary.simpleMessage("Inclination"), "itemDetailsTitle": MessageLookupByLibrary.simpleMessage("Item Details"), "itemTitle": m3, "itemsTitle": MessageLookupByLibrary.simpleMessage("Sample Items"), @@ -119,16 +126,27 @@ class MessageLookup extends MessageLookupByLibrary { "landingLegs": MessageLookupByLibrary.simpleMessage("Landing Legs"), "landingSuccess": MessageLookupByLibrary.simpleMessage("Landing Success"), "launch": MessageLookupByLibrary.simpleMessage("Launch"), + "launchInformation": MessageLookupByLibrary.simpleMessage( + "Launch Information", + ), + "launchMass": MessageLookupByLibrary.simpleMessage("Launch Mass"), "launchSite": MessageLookupByLibrary.simpleMessage("Launch Site"), - "launchedAt": m4, + "launchVehicle": MessageLookupByLibrary.simpleMessage("Launch Vehicle"), + "launched": m4, + "launchedAt": m5, "launchesTitle": MessageLookupByLibrary.simpleMessage("Launches"), + "learnMore": MessageLookupByLibrary.simpleMessage("Learn More"), "lightGoldThemeTitle": MessageLookupByLibrary.simpleMessage("Light Gold"), "lightMintThemeTitle": MessageLookupByLibrary.simpleMessage("Light Mint"), "lightThemeTitle": MessageLookupByLibrary.simpleMessage("Light Theme"), "linksResources": MessageLookupByLibrary.simpleMessage("Links & Resources"), + "longitude": MessageLookupByLibrary.simpleMessage("Longitude"), "manufacturer": MessageLookupByLibrary.simpleMessage("Manufacturer"), + "marsDistance": MessageLookupByLibrary.simpleMessage("Mars Distance"), "mass": MessageLookupByLibrary.simpleMessage("Mass"), "massLabel": MessageLookupByLibrary.simpleMessage("Mass"), + "millionKm": MessageLookupByLibrary.simpleMessage("million km"), + "missionDetails": MessageLookupByLibrary.simpleMessage("Mission Details"), "missionFailed": MessageLookupByLibrary.simpleMessage("Mission Failed"), "missionOverview": MessageLookupByLibrary.simpleMessage("Mission Overview"), "missionSuccess": MessageLookupByLibrary.simpleMessage("Mission Success"), @@ -136,7 +154,7 @@ class MessageLookup extends MessageLookupByLibrary { "Mission Successful", ), "missionTimeline": MessageLookupByLibrary.simpleMessage("Mission Timeline"), - "missionTitle": m5, + "missionTitle": m6, "nationality": MessageLookupByLibrary.simpleMessage("Nationality"), "newsScreen": MessageLookupByLibrary.simpleMessage("News"), "noDetails": MessageLookupByLibrary.simpleMessage("No details available"), @@ -149,10 +167,15 @@ class MessageLookup extends MessageLookupByLibrary { "Mission objectives not met", ), "orbit": MessageLookupByLibrary.simpleMessage("Orbit"), + "orbitalParameters": MessageLookupByLibrary.simpleMessage( + "Orbital Parameters", + ), + "orbitalPeriod": MessageLookupByLibrary.simpleMessage("Orbital Period"), "overview": MessageLookupByLibrary.simpleMessage("Overview"), "payload": MessageLookupByLibrary.simpleMessage("Payload"), "payloadCapacity": MessageLookupByLibrary.simpleMessage("Payload Capacity"), "payloadTitle": MessageLookupByLibrary.simpleMessage("Payload"), + "periapsis": MessageLookupByLibrary.simpleMessage("Periapsis"), "pressKit": MessageLookupByLibrary.simpleMessage("Press Kit"), "propellant1Label": MessageLookupByLibrary.simpleMessage("Propellant 1"), "propellant2Label": MessageLookupByLibrary.simpleMessage("Propellant 2"), @@ -160,7 +183,11 @@ class MessageLookup extends MessageLookupByLibrary { "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), "retiredStatus": MessageLookupByLibrary.simpleMessage("Retired"), "reused": MessageLookupByLibrary.simpleMessage("Reused"), - "rocket": m6, + "roadsterDescription": MessageLookupByLibrary.simpleMessage( + "Elon Musk\'s Tesla Roadster", + ), + "roadsterTitle": MessageLookupByLibrary.simpleMessage("Roadster"), + "rocket": m7, "rocketBlock": MessageLookupByLibrary.simpleMessage("Block"), "rocketDetails": MessageLookupByLibrary.simpleMessage("Rocket Details"), "rocketName": MessageLookupByLibrary.simpleMessage("Rocket Name"), @@ -168,12 +195,13 @@ class MessageLookup extends MessageLookupByLibrary { "rocketType": MessageLookupByLibrary.simpleMessage("Type"), "rocketsTab": MessageLookupByLibrary.simpleMessage("Rockets"), "rocketsTitle": MessageLookupByLibrary.simpleMessage("Rockets"), + "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Semi-major axis"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Settings"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("Site ID:"), "specifications": MessageLookupByLibrary.simpleMessage("Specifications"), "stagesLabel": MessageLookupByLibrary.simpleMessage("Stages"), "staticFireTest": MessageLookupByLibrary.simpleMessage("Static Fire Test"), - "successRate": m7, + "successRate": m8, "systemThemeTitle": MessageLookupByLibrary.simpleMessage("System Theme"), "tabHome": MessageLookupByLibrary.simpleMessage("Home"), "tabSettings": MessageLookupByLibrary.simpleMessage("Settings"), @@ -182,12 +210,15 @@ class MessageLookup extends MessageLookupByLibrary { "Thrust (Sea Level)", ), "tons": MessageLookupByLibrary.simpleMessage("tons"), + "trackLive": MessageLookupByLibrary.simpleMessage("Track Live"), "transparentButtonTitle": MessageLookupByLibrary.simpleMessage( "Transparent", ), "tryAgainButton": MessageLookupByLibrary.simpleMessage("Try Again"), "type": MessageLookupByLibrary.simpleMessage("Type"), "typeLabel": MessageLookupByLibrary.simpleMessage("Type"), + "unitDays": MessageLookupByLibrary.simpleMessage("days"), + "unitKph": MessageLookupByLibrary.simpleMessage("km/h"), "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 be39bb8..cf6af18 100644 --- a/lib/generated/intl/messages_pt.dart +++ b/lib/generated/intl/messages_pt.dart @@ -28,14 +28,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(id) => "Artigo de Exemplo ${id}"; - static String m4(launchedAt) => "Lançado em: ${launchedAt}"; + static String m4(date) => "Lançado: ${date}"; - static String m5(mission) => "Missão: ${mission}"; + static String m5(launchedAt) => "Lançado em: ${launchedAt}"; - static String m6(rocketName, rocketType) => + static String m6(mission) => "Missão: ${mission}"; + + static String m7(rocketName, rocketType) => "Foguete: ${rocketName} (${rocketType})"; - static String m7(percentage) => "${percentage}% de sucesso"; + static String m8(percentage) => "${percentage}% de sucesso"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -47,6 +49,7 @@ class MessageLookup extends MessageLookupByLibrary { "allObjectivesCompleted": MessageLookupByLibrary.simpleMessage( "Todos os objetivos concluídos", ), + "apoapsis": MessageLookupByLibrary.simpleMessage("Apoápse"), "appTitle": MessageLookupByLibrary.simpleMessage( "flutter_bloc_app_template", ), @@ -67,6 +70,7 @@ class MessageLookup extends MessageLookupByLibrary { "coreSerial": MessageLookupByLibrary.simpleMessage( "Número de Série do Núcleo", ), + "currentSpeed": MessageLookupByLibrary.simpleMessage("Velocidade Atual"), "customers": MessageLookupByLibrary.simpleMessage("Clientes"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage( "Tema Dourado Escuro", @@ -102,6 +106,8 @@ class MessageLookup extends MessageLookupByLibrary { "dynamicColorSettingsItemTitle": MessageLookupByLibrary.simpleMessage( "Usar cores dinâmicas", ), + "earthDistance": MessageLookupByLibrary.simpleMessage("Distância à Terra"), + "eccentricity": MessageLookupByLibrary.simpleMessage("Excentricidade"), "emailsTitle": MessageLookupByLibrary.simpleMessage("E-mails"), "emptyList": MessageLookupByLibrary.simpleMessage("Lista Vazia"), "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Ativado"), @@ -117,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "iconButtonTitle": MessageLookupByLibrary.simpleMessage("Com Ícone"), "id": MessageLookupByLibrary.simpleMessage("ID"), + "inclination": MessageLookupByLibrary.simpleMessage("Inclinação"), "itemDetailsTitle": MessageLookupByLibrary.simpleMessage( "Detalhes do Artigo", ), @@ -128,9 +135,18 @@ class MessageLookup extends MessageLookupByLibrary { "Pouso bem-sucedido", ), "launch": MessageLookupByLibrary.simpleMessage("Lançamento"), + "launchInformation": MessageLookupByLibrary.simpleMessage( + "Informações do lançamento", + ), + "launchMass": MessageLookupByLibrary.simpleMessage("Massa do lançamento"), "launchSite": MessageLookupByLibrary.simpleMessage("Local de Lançamento"), - "launchedAt": m4, + "launchVehicle": MessageLookupByLibrary.simpleMessage( + "Veículo de lançamento", + ), + "launched": m4, + "launchedAt": m5, "launchesTitle": MessageLookupByLibrary.simpleMessage("Lançamentos"), + "learnMore": MessageLookupByLibrary.simpleMessage("Saiba mais"), "lightGoldThemeTitle": MessageLookupByLibrary.simpleMessage( "Tema Dourado Claro", ), @@ -139,9 +155,15 @@ class MessageLookup extends MessageLookupByLibrary { ), "lightThemeTitle": MessageLookupByLibrary.simpleMessage("Tema Claro"), "linksResources": MessageLookupByLibrary.simpleMessage("Links e Recursos"), + "longitude": MessageLookupByLibrary.simpleMessage("Longitude"), "manufacturer": MessageLookupByLibrary.simpleMessage("Fabricante"), + "marsDistance": MessageLookupByLibrary.simpleMessage("Distância a Marte"), "mass": MessageLookupByLibrary.simpleMessage("Massa"), "massLabel": MessageLookupByLibrary.simpleMessage("Massa"), + "millionKm": MessageLookupByLibrary.simpleMessage("milhões km"), + "missionDetails": MessageLookupByLibrary.simpleMessage( + "Detalhes da missão", + ), "missionFailed": MessageLookupByLibrary.simpleMessage("Missão falhou"), "missionOverview": MessageLookupByLibrary.simpleMessage( "Visão geral da missão", @@ -153,7 +175,7 @@ class MessageLookup extends MessageLookupByLibrary { "missionTimeline": MessageLookupByLibrary.simpleMessage( "Cronograma da Missão", ), - "missionTitle": m5, + "missionTitle": m6, "nationality": MessageLookupByLibrary.simpleMessage("Nacionalidade"), "newsScreen": MessageLookupByLibrary.simpleMessage("Notícias"), "noDetails": MessageLookupByLibrary.simpleMessage( @@ -168,12 +190,17 @@ class MessageLookup extends MessageLookupByLibrary { "Objetivos da missão não alcançados", ), "orbit": MessageLookupByLibrary.simpleMessage("Órbita"), + "orbitalParameters": MessageLookupByLibrary.simpleMessage( + "Parâmetros orbitais", + ), + "orbitalPeriod": MessageLookupByLibrary.simpleMessage("Período Orbital"), "overview": MessageLookupByLibrary.simpleMessage("Visão Geral"), "payload": MessageLookupByLibrary.simpleMessage("Carga útil"), "payloadCapacity": MessageLookupByLibrary.simpleMessage( "Capacidade de Carga", ), "payloadTitle": MessageLookupByLibrary.simpleMessage("Carga útil"), + "periapsis": MessageLookupByLibrary.simpleMessage("Periápse"), "pressKit": MessageLookupByLibrary.simpleMessage("Kit de Imprensa"), "propellant1Label": MessageLookupByLibrary.simpleMessage("Propelente 1"), "propellant2Label": MessageLookupByLibrary.simpleMessage("Propelente 2"), @@ -183,7 +210,11 @@ class MessageLookup extends MessageLookupByLibrary { "reddit": MessageLookupByLibrary.simpleMessage("Reddit"), "retiredStatus": MessageLookupByLibrary.simpleMessage("Aposentada"), "reused": MessageLookupByLibrary.simpleMessage("Reutilizado"), - "rocket": m6, + "roadsterDescription": MessageLookupByLibrary.simpleMessage( + "Tesla Roadster de Elon Musk", + ), + "roadsterTitle": MessageLookupByLibrary.simpleMessage("Roadster"), + "rocket": m7, "rocketBlock": MessageLookupByLibrary.simpleMessage("Bloco"), "rocketDetails": MessageLookupByLibrary.simpleMessage( "Detalhes do Foguete", @@ -193,6 +224,7 @@ class MessageLookup extends MessageLookupByLibrary { "rocketType": MessageLookupByLibrary.simpleMessage("Tipo"), "rocketsTab": MessageLookupByLibrary.simpleMessage("Foguetes"), "rocketsTitle": MessageLookupByLibrary.simpleMessage("Foguetes"), + "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Eixo semi-maior"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Configurações"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("ID do Local:"), "specifications": MessageLookupByLibrary.simpleMessage("Especificações"), @@ -200,7 +232,7 @@ class MessageLookup extends MessageLookupByLibrary { "staticFireTest": MessageLookupByLibrary.simpleMessage( "Teste de Fogo Estático", ), - "successRate": m7, + "successRate": m8, "systemThemeTitle": MessageLookupByLibrary.simpleMessage("Tema do Sistema"), "tabHome": MessageLookupByLibrary.simpleMessage("Início"), "tabSettings": MessageLookupByLibrary.simpleMessage("Configurações"), @@ -209,12 +241,15 @@ class MessageLookup extends MessageLookupByLibrary { "Empuxo (nível do mar)", ), "tons": MessageLookupByLibrary.simpleMessage("toneladas"), + "trackLive": MessageLookupByLibrary.simpleMessage("Acompanhar ao vivo"), "transparentButtonTitle": MessageLookupByLibrary.simpleMessage( "Transparente", ), "tryAgainButton": MessageLookupByLibrary.simpleMessage("Tentar novamente"), "type": MessageLookupByLibrary.simpleMessage("Tipo"), "typeLabel": MessageLookupByLibrary.simpleMessage("Tipo"), + "unitDays": MessageLookupByLibrary.simpleMessage("dias"), + "unitKph": MessageLookupByLibrary.simpleMessage("km/h"), "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 0bb9322..baa8068 100644 --- a/lib/generated/intl/messages_uk.dart +++ b/lib/generated/intl/messages_uk.dart @@ -28,14 +28,16 @@ class MessageLookup extends MessageLookupByLibrary { static String m3(id) => "Приклад елементу ${id}"; - static String m4(launchedAt) => "Запущено: ${launchedAt}"; + static String m4(date) => "Запуск: ${date}"; - static String m5(mission) => "Місія: ${mission}"; + static String m5(launchedAt) => "Запущено: ${launchedAt}"; - static String m6(rocketName, rocketType) => + static String m6(mission) => "Місія: ${mission}"; + + static String m7(rocketName, rocketType) => "Ракета: ${rocketName} (${rocketType})"; - static String m7(percentage) => "${percentage}% успішних запусків"; + static String m8(percentage) => "${percentage}% успішних запусків"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -47,6 +49,7 @@ class MessageLookup extends MessageLookupByLibrary { "allObjectivesCompleted": MessageLookupByLibrary.simpleMessage( "Всі цілі досягнуті", ), + "apoapsis": MessageLookupByLibrary.simpleMessage("Апоцентр"), "appTitle": MessageLookupByLibrary.simpleMessage("шаблон_flutter_bloc_app"), "appearanceSettingsItem": MessageLookupByLibrary.simpleMessage( "Зовнішній вигляд", @@ -63,6 +66,7 @@ class MessageLookup extends MessageLookupByLibrary { "Кордона сторона", ), "coreSerial": MessageLookupByLibrary.simpleMessage("Серійний номер ядра"), + "currentSpeed": MessageLookupByLibrary.simpleMessage("Поточна швидкість"), "customers": MessageLookupByLibrary.simpleMessage("Клієнти"), "darkGoldThemeTitle": MessageLookupByLibrary.simpleMessage("Темне золото"), "darkMintThemeTitle": MessageLookupByLibrary.simpleMessage("Темна м’ята"), @@ -94,6 +98,8 @@ class MessageLookup extends MessageLookupByLibrary { "dynamicColorSettingsItemTitle": MessageLookupByLibrary.simpleMessage( "Використовувати динамічні кольори", ), + "earthDistance": MessageLookupByLibrary.simpleMessage("Відстань до Землі"), + "eccentricity": MessageLookupByLibrary.simpleMessage("Ексцентриситет"), "emailsTitle": MessageLookupByLibrary.simpleMessage("Електронні листи"), "emptyList": MessageLookupByLibrary.simpleMessage("Список порожній"), "enabledButtonTitle": MessageLookupByLibrary.simpleMessage("Увімкнено"), @@ -112,6 +118,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "iconButtonTitle": MessageLookupByLibrary.simpleMessage("З іконкою"), "id": MessageLookupByLibrary.simpleMessage("ID"), + "inclination": MessageLookupByLibrary.simpleMessage("Нахил"), "itemDetailsTitle": MessageLookupByLibrary.simpleMessage("Деталі елементу"), "itemTitle": m3, "itemsTitle": MessageLookupByLibrary.simpleMessage("Приклади елементів"), @@ -121,9 +128,16 @@ class MessageLookup extends MessageLookupByLibrary { "Приземлення успішне", ), "launch": MessageLookupByLibrary.simpleMessage("Запуск"), + "launchInformation": MessageLookupByLibrary.simpleMessage( + "Інформація про запуск", + ), + "launchMass": MessageLookupByLibrary.simpleMessage("Маса запуску"), "launchSite": MessageLookupByLibrary.simpleMessage("Місце запуску"), - "launchedAt": m4, + "launchVehicle": MessageLookupByLibrary.simpleMessage("Ракета-носій"), + "launched": m4, + "launchedAt": m5, "launchesTitle": MessageLookupByLibrary.simpleMessage("Запуски"), + "learnMore": MessageLookupByLibrary.simpleMessage("Дізнатися більше"), "lightGoldThemeTitle": MessageLookupByLibrary.simpleMessage( "Світле золото", ), @@ -132,15 +146,19 @@ class MessageLookup extends MessageLookupByLibrary { "linksResources": MessageLookupByLibrary.simpleMessage( "Посилання та ресурси", ), + "longitude": MessageLookupByLibrary.simpleMessage("Довгота"), "manufacturer": MessageLookupByLibrary.simpleMessage("Виробник"), + "marsDistance": MessageLookupByLibrary.simpleMessage("Відстань до Марса"), "mass": MessageLookupByLibrary.simpleMessage("Маса"), "massLabel": MessageLookupByLibrary.simpleMessage("Маса"), + "millionKm": MessageLookupByLibrary.simpleMessage("мільйон км"), + "missionDetails": MessageLookupByLibrary.simpleMessage("Деталі місії"), "missionFailed": MessageLookupByLibrary.simpleMessage("Місія не вдалася"), "missionOverview": MessageLookupByLibrary.simpleMessage("Огляд місії"), "missionSuccess": MessageLookupByLibrary.simpleMessage("Місія успішна"), "missionSuccessful": MessageLookupByLibrary.simpleMessage("Місія успішна"), "missionTimeline": MessageLookupByLibrary.simpleMessage("Хронологія місії"), - "missionTitle": m5, + "missionTitle": m6, "nationality": MessageLookupByLibrary.simpleMessage("Національність"), "newsScreen": MessageLookupByLibrary.simpleMessage("Новини"), "noDetails": MessageLookupByLibrary.simpleMessage("Деталі відсутні"), @@ -153,6 +171,10 @@ class MessageLookup extends MessageLookupByLibrary { "Цілі місії не досягнуті", ), "orbit": MessageLookupByLibrary.simpleMessage("Орбіта"), + "orbitalParameters": MessageLookupByLibrary.simpleMessage( + "Орбітальні параметри", + ), + "orbitalPeriod": MessageLookupByLibrary.simpleMessage("Орбітальний період"), "overview": MessageLookupByLibrary.simpleMessage("Огляд"), "payload": MessageLookupByLibrary.simpleMessage("Корисне навантаження"), "payloadCapacity": MessageLookupByLibrary.simpleMessage( @@ -161,6 +183,7 @@ class MessageLookup extends MessageLookupByLibrary { "payloadTitle": MessageLookupByLibrary.simpleMessage( "Корисне навантаження", ), + "periapsis": MessageLookupByLibrary.simpleMessage("Перицентр"), "pressKit": MessageLookupByLibrary.simpleMessage("Прес-кит"), "propellant1Label": MessageLookupByLibrary.simpleMessage("Паливо 1"), "propellant2Label": MessageLookupByLibrary.simpleMessage("Паливо 2"), @@ -170,7 +193,11 @@ class MessageLookup extends MessageLookupByLibrary { "Знято з експлуатації", ), "reused": MessageLookupByLibrary.simpleMessage("Повторне використання"), - "rocket": m6, + "roadsterDescription": MessageLookupByLibrary.simpleMessage( + "Tesla Roadster Ілона Маска", + ), + "roadsterTitle": MessageLookupByLibrary.simpleMessage("Роадстер"), + "rocket": m7, "rocketBlock": MessageLookupByLibrary.simpleMessage("Блок"), "rocketDetails": MessageLookupByLibrary.simpleMessage("Деталі ракети"), "rocketName": MessageLookupByLibrary.simpleMessage("Назва ракети"), @@ -178,6 +205,7 @@ class MessageLookup extends MessageLookupByLibrary { "rocketType": MessageLookupByLibrary.simpleMessage("Тип"), "rocketsTab": MessageLookupByLibrary.simpleMessage("Ракети"), "rocketsTitle": MessageLookupByLibrary.simpleMessage("Ракети"), + "semiMajorAxis": MessageLookupByLibrary.simpleMessage("Велика піввісь"), "settingsTitle": MessageLookupByLibrary.simpleMessage("Налаштування"), "siteIdLabel": MessageLookupByLibrary.simpleMessage("ID сайту:"), "specifications": MessageLookupByLibrary.simpleMessage( @@ -187,7 +215,7 @@ class MessageLookup extends MessageLookupByLibrary { "staticFireTest": MessageLookupByLibrary.simpleMessage( "Статичний вогневий тест", ), - "successRate": m7, + "successRate": m8, "systemThemeTitle": MessageLookupByLibrary.simpleMessage("Системна тема"), "tabHome": MessageLookupByLibrary.simpleMessage("Головна"), "tabSettings": MessageLookupByLibrary.simpleMessage("Налаштування"), @@ -196,10 +224,13 @@ class MessageLookup extends MessageLookupByLibrary { "Тяга (на рівні моря)", ), "tons": MessageLookupByLibrary.simpleMessage("тонн"), + "trackLive": MessageLookupByLibrary.simpleMessage("Слідкувати онлайн"), "transparentButtonTitle": MessageLookupByLibrary.simpleMessage("Прозора"), "tryAgainButton": MessageLookupByLibrary.simpleMessage("Спробувати ще раз"), "type": MessageLookupByLibrary.simpleMessage("Тип"), "typeLabel": MessageLookupByLibrary.simpleMessage("Тип"), + "unitDays": MessageLookupByLibrary.simpleMessage("днів"), + "unitKph": MessageLookupByLibrary.simpleMessage("км/год"), "versionLabel": MessageLookupByLibrary.simpleMessage("Версія"), "watchVideo": MessageLookupByLibrary.simpleMessage("Дивитися відео"), "wikipedia": MessageLookupByLibrary.simpleMessage("Вікіпедія"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index d1a68ba..58718be 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -1088,6 +1088,181 @@ class S { String get tons { return Intl.message('tons', name: 'tons', desc: '', args: []); } + + /// `Learn More` + String get learnMore { + return Intl.message('Learn More', name: 'learnMore', desc: '', args: []); + } + + /// `Launch Information` + String get launchInformation { + return Intl.message( + 'Launch Information', + name: 'launchInformation', + desc: '', + args: [], + ); + } + + /// `Launch Mass` + String get launchMass { + return Intl.message('Launch Mass', name: 'launchMass', desc: '', args: []); + } + + /// `Launch Vehicle` + String get launchVehicle { + return Intl.message( + 'Launch Vehicle', + name: 'launchVehicle', + desc: '', + args: [], + ); + } + + /// `Orbital Parameters` + String get orbitalParameters { + return Intl.message( + 'Orbital Parameters', + name: 'orbitalParameters', + desc: '', + args: [], + ); + } + + /// `million km` + String get millionKm { + return Intl.message('million km', name: 'millionKm', desc: '', args: []); + } + + /// `Mission Details` + String get missionDetails { + return Intl.message( + 'Mission Details', + name: 'missionDetails', + desc: '', + args: [], + ); + } + + /// `Track Live` + String get trackLive { + return Intl.message('Track Live', name: 'trackLive', desc: '', args: []); + } + + /// `Mars Distance` + String get marsDistance { + return Intl.message( + 'Mars Distance', + name: 'marsDistance', + desc: '', + args: [], + ); + } + + /// `Earth Distance` + String get earthDistance { + return Intl.message( + 'Earth Distance', + name: 'earthDistance', + desc: '', + args: [], + ); + } + + /// `Current Speed` + String get currentSpeed { + return Intl.message( + 'Current Speed', + name: 'currentSpeed', + desc: '', + args: [], + ); + } + + /// `Orbital Period` + String get orbitalPeriod { + return Intl.message( + 'Orbital Period', + name: 'orbitalPeriod', + desc: '', + args: [], + ); + } + + /// `days` + String get unitDays { + return Intl.message('days', name: 'unitDays', desc: '', args: []); + } + + /// `km/h` + String get unitKph { + return Intl.message('km/h', name: 'unitKph', desc: '', args: []); + } + + /// `Launched: {date}` + String launched(Object date) { + return Intl.message( + 'Launched: $date', + name: 'launched', + desc: '', + args: [date], + ); + } + + /// `Roadster` + String get roadsterTitle { + return Intl.message('Roadster', name: 'roadsterTitle', desc: '', args: []); + } + + /// `Elon Musk's Tesla Roadster` + String get roadsterDescription { + return Intl.message( + 'Elon Musk\'s Tesla Roadster', + name: 'roadsterDescription', + desc: '', + args: [], + ); + } + + /// `Apoapsis` + String get apoapsis { + return Intl.message('Apoapsis', name: 'apoapsis', desc: '', args: []); + } + + /// `Periapsis` + String get periapsis { + return Intl.message('Periapsis', name: 'periapsis', desc: '', args: []); + } + + /// `Semi-major axis` + String get semiMajorAxis { + return Intl.message( + 'Semi-major axis', + name: 'semiMajorAxis', + desc: '', + args: [], + ); + } + + /// `Eccentricity` + String get eccentricity { + return Intl.message( + 'Eccentricity', + name: 'eccentricity', + desc: '', + args: [], + ); + } + + /// `Inclination` + String get inclination { + return Intl.message('Inclination', name: 'inclination', desc: '', args: []); + } + + /// `Longitude` + String get longitude { + return Intl.message('Longitude', name: 'longitude', desc: '', args: []); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2034af4..65315b0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -767,6 +767,144 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'tons'** String get tons; + + /// No description provided for @learnMore. + /// + /// In en, this message translates to: + /// **'Learn More'** + String get learnMore; + + /// No description provided for @launchInformation. + /// + /// In en, this message translates to: + /// **'Launch Information'** + String get launchInformation; + + /// No description provided for @launchMass. + /// + /// In en, this message translates to: + /// **'Launch Mass'** + String get launchMass; + + /// No description provided for @launchVehicle. + /// + /// In en, this message translates to: + /// **'Launch Vehicle'** + String get launchVehicle; + + /// No description provided for @orbitalParameters. + /// + /// In en, this message translates to: + /// **'Orbital Parameters'** + String get orbitalParameters; + + /// No description provided for @millionKm. + /// + /// In en, this message translates to: + /// **'million km'** + String get millionKm; + + /// No description provided for @missionDetails. + /// + /// In en, this message translates to: + /// **'Mission Details'** + String get missionDetails; + + /// No description provided for @trackLive. + /// + /// In en, this message translates to: + /// **'Track Live'** + String get trackLive; + + /// No description provided for @marsDistance. + /// + /// In en, this message translates to: + /// **'Mars Distance'** + String get marsDistance; + + /// No description provided for @earthDistance. + /// + /// In en, this message translates to: + /// **'Earth Distance'** + String get earthDistance; + + /// No description provided for @currentSpeed. + /// + /// In en, this message translates to: + /// **'Current Speed'** + String get currentSpeed; + + /// No description provided for @orbitalPeriod. + /// + /// In en, this message translates to: + /// **'Orbital Period'** + String get orbitalPeriod; + + /// No description provided for @unitDays. + /// + /// In en, this message translates to: + /// **'days'** + String get unitDays; + + /// No description provided for @unitKph. + /// + /// In en, this message translates to: + /// **'km/h'** + String get unitKph; + + /// No description provided for @launched. + /// + /// In en, this message translates to: + /// **'Launched: {date}'** + String launched(Object date); + + /// No description provided for @roadsterTitle. + /// + /// In en, this message translates to: + /// **'Roadster'** + String get roadsterTitle; + + /// No description provided for @roadsterDescription. + /// + /// In en, this message translates to: + /// **'Elon Musk\'s Tesla Roadster'** + String get roadsterDescription; + + /// No description provided for @apoapsis. + /// + /// In en, this message translates to: + /// **'Apoapsis'** + String get apoapsis; + + /// No description provided for @periapsis. + /// + /// In en, this message translates to: + /// **'Periapsis'** + String get periapsis; + + /// No description provided for @semiMajorAxis. + /// + /// In en, this message translates to: + /// **'Semi-major axis'** + String get semiMajorAxis; + + /// No description provided for @eccentricity. + /// + /// In en, this message translates to: + /// **'Eccentricity'** + String get eccentricity; + + /// No description provided for @inclination. + /// + /// In en, this message translates to: + /// **'Inclination'** + String get inclination; + + /// No description provided for @longitude. + /// + /// In en, this message translates to: + /// **'Longitude'** + String get longitude; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8d99e35..f9986f4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -358,4 +358,75 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tons => 'Tonnen'; + + @override + String get learnMore => 'Mehr erfahren'; + + @override + String get launchInformation => 'Startinformationen'; + + @override + String get launchMass => 'Startmasse'; + + @override + String get launchVehicle => 'Startfahrzeug'; + + @override + String get orbitalParameters => 'Orbitale Parameter'; + + @override + String get millionKm => 'Millionen km'; + + @override + String get missionDetails => 'Missionsdetails'; + + @override + String get trackLive => 'Live verfolgen'; + + @override + String get marsDistance => 'Abstand zum Mars'; + + @override + String get earthDistance => 'Abstand zur Erde'; + + @override + String get currentSpeed => 'Aktuelle Geschwindigkeit'; + + @override + String get orbitalPeriod => 'Orbitale Periode'; + + @override + String get unitDays => 'Tage'; + + @override + String get unitKph => 'km/h'; + + @override + String launched(Object date) { + return 'Gestartet: $date'; + } + + @override + String get roadsterTitle => 'Roadster'; + + @override + String get roadsterDescription => 'Elon Musks Tesla Roadster'; + + @override + String get apoapsis => 'Aphel'; + + @override + String get periapsis => 'Perihel'; + + @override + String get semiMajorAxis => 'Große Halbachse'; + + @override + String get eccentricity => 'Exzentrizität'; + + @override + String get inclination => 'Inklination'; + + @override + String get longitude => 'Längengrad'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7d8ad23..e4758d8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -358,4 +358,75 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tons => 'tons'; + + @override + String get learnMore => 'Learn More'; + + @override + String get launchInformation => 'Launch Information'; + + @override + String get launchMass => 'Launch Mass'; + + @override + String get launchVehicle => 'Launch Vehicle'; + + @override + String get orbitalParameters => 'Orbital Parameters'; + + @override + String get millionKm => 'million km'; + + @override + String get missionDetails => 'Mission Details'; + + @override + String get trackLive => 'Track Live'; + + @override + String get marsDistance => 'Mars Distance'; + + @override + String get earthDistance => 'Earth Distance'; + + @override + String get currentSpeed => 'Current Speed'; + + @override + String get orbitalPeriod => 'Orbital Period'; + + @override + String get unitDays => 'days'; + + @override + String get unitKph => 'km/h'; + + @override + String launched(Object date) { + return 'Launched: $date'; + } + + @override + String get roadsterTitle => 'Roadster'; + + @override + String get roadsterDescription => 'Elon Musk\'s Tesla Roadster'; + + @override + String get apoapsis => 'Apoapsis'; + + @override + String get periapsis => 'Periapsis'; + + @override + String get semiMajorAxis => 'Semi-major axis'; + + @override + String get eccentricity => 'Eccentricity'; + + @override + String get inclination => 'Inclination'; + + @override + String get longitude => 'Longitude'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index dfc04f2..ebe59e4 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -358,4 +358,75 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tons => 'toneladas'; + + @override + String get learnMore => 'Saiba mais'; + + @override + String get launchInformation => 'Informações do lançamento'; + + @override + String get launchMass => 'Massa do lançamento'; + + @override + String get launchVehicle => 'Veículo de lançamento'; + + @override + String get orbitalParameters => 'Parâmetros orbitais'; + + @override + String get millionKm => 'milhões km'; + + @override + String get missionDetails => 'Detalhes da missão'; + + @override + String get trackLive => 'Acompanhar ao vivo'; + + @override + String get marsDistance => 'Distância a Marte'; + + @override + String get earthDistance => 'Distância à Terra'; + + @override + String get currentSpeed => 'Velocidade Atual'; + + @override + String get orbitalPeriod => 'Período Orbital'; + + @override + String get unitDays => 'dias'; + + @override + String get unitKph => 'km/h'; + + @override + String launched(Object date) { + return 'Lançado: $date'; + } + + @override + String get roadsterTitle => 'Roadster'; + + @override + String get roadsterDescription => 'Tesla Roadster de Elon Musk'; + + @override + String get apoapsis => 'Apoápse'; + + @override + String get periapsis => 'Periápse'; + + @override + String get semiMajorAxis => 'Eixo semi-maior'; + + @override + String get eccentricity => 'Excentricidade'; + + @override + String get inclination => 'Inclinação'; + + @override + String get longitude => 'Longitude'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 1f5857c..21df554 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -360,4 +360,75 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tons => 'тонн'; + + @override + String get learnMore => 'Дізнатися більше'; + + @override + String get launchInformation => 'Інформація про запуск'; + + @override + String get launchMass => 'Маса запуску'; + + @override + String get launchVehicle => 'Ракета-носій'; + + @override + String get orbitalParameters => 'Орбітальні параметри'; + + @override + String get millionKm => 'мільйон км'; + + @override + String get missionDetails => 'Деталі місії'; + + @override + String get trackLive => 'Слідкувати онлайн'; + + @override + String get marsDistance => 'Відстань до Марса'; + + @override + String get earthDistance => 'Відстань до Землі'; + + @override + String get currentSpeed => 'Поточна швидкість'; + + @override + String get orbitalPeriod => 'Орбітальний період'; + + @override + String get unitDays => 'днів'; + + @override + String get unitKph => 'км/год'; + + @override + String launched(Object date) { + return 'Запуск: $date'; + } + + @override + String get roadsterTitle => 'Роадстер'; + + @override + String get roadsterDescription => 'Tesla Roadster Ілона Маска'; + + @override + String get apoapsis => 'Апоцентр'; + + @override + String get periapsis => 'Перицентр'; + + @override + String get semiMajorAxis => 'Велика піввісь'; + + @override + String get eccentricity => 'Ексцентриситет'; + + @override + String get inclination => 'Нахил'; + + @override + String get longitude => 'Довгота'; } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 813fcb7..6a64115 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -228,5 +228,28 @@ "propellant1Label": "Treibstoff 1", "propellant2Label": "Treibstoff 2", "thrustSeaLevelLabel": "Schub (Bodenniveau)", - "tons": "Tonnen" + "tons": "Tonnen", + "learnMore": "Mehr erfahren", + "launchInformation": "Startinformationen", + "launchMass": "Startmasse", + "launchVehicle": "Startfahrzeug", + "orbitalParameters": "Orbitale Parameter", + "millionKm": "Millionen km", + "missionDetails": "Missionsdetails", + "trackLive": "Live verfolgen", + "marsDistance": "Abstand zum Mars", + "earthDistance": "Abstand zur Erde", + "currentSpeed": "Aktuelle Geschwindigkeit", + "orbitalPeriod": "Orbitale Periode", + "unitDays": "Tage", + "unitKph": "km/h", + "launched": "Gestartet: {date}", + "roadsterTitle": "Roadster", + "roadsterDescription": "Elon Musks Tesla Roadster", + "apoapsis": "Aphel", + "periapsis": "Perihel", + "semiMajorAxis": "Große Halbachse", + "eccentricity": "Exzentrizität", + "inclination": "Inklination", + "longitude": "Längengrad" } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 79ac2aa..bc01174 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -365,5 +365,28 @@ "propellant1Label": "Propellant 1", "propellant2Label": "Propellant 2", "thrustSeaLevelLabel": "Thrust (Sea Level)", - "tons": "tons" + "tons": "tons", + "learnMore": "Learn More", + "launchInformation": "Launch Information", + "launchMass": "Launch Mass", + "launchVehicle": "Launch Vehicle", + "orbitalParameters": "Orbital Parameters", + "millionKm": "million km", + "missionDetails": "Mission Details", + "trackLive": "Track Live", + "marsDistance": "Mars Distance", + "earthDistance": "Earth Distance", + "currentSpeed": "Current Speed", + "orbitalPeriod": "Orbital Period", + "unitDays": "days", + "unitKph": "km/h", + "launched": "Launched: {date}", + "roadsterTitle": "Roadster", + "roadsterDescription": "Elon Musk's Tesla Roadster", + "apoapsis": "Apoapsis", + "periapsis": "Periapsis", + "semiMajorAxis": "Semi-major axis", + "eccentricity": "Eccentricity", + "inclination": "Inclination", + "longitude": "Longitude" } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 3f2a515..8df3bcb 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -228,5 +228,28 @@ "propellant1Label": "Propelente 1", "propellant2Label": "Propelente 2", "thrustSeaLevelLabel": "Empuxo (nível do mar)", - "tons": "toneladas" + "tons": "toneladas", + "learnMore": "Saiba mais", + "launchInformation": "Informações do lançamento", + "launchMass": "Massa do lançamento", + "launchVehicle": "Veículo de lançamento", + "orbitalParameters": "Parâmetros orbitais", + "millionKm": "milhões km", + "missionDetails": "Detalhes da missão", + "trackLive": "Acompanhar ao vivo", + "marsDistance": "Distância a Marte", + "earthDistance": "Distância à Terra", + "currentSpeed": "Velocidade Atual", + "orbitalPeriod": "Período Orbital", + "unitDays": "dias", + "unitKph": "km/h", + "launched": "Lançado: {date}", + "roadsterTitle": "Roadster", + "roadsterDescription": "Tesla Roadster de Elon Musk", + "apoapsis": "Apoápse", + "periapsis": "Periápse", + "semiMajorAxis": "Eixo semi-maior", + "eccentricity": "Excentricidade", + "inclination": "Inclinação", + "longitude": "Longitude" } diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index 8cdc5b1..4ca4d98 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -336,13 +336,17 @@ "description": "Назва вкладки Ракети" }, "activeStatus": "Активна", - "@activeStatus": {"description": "Позначка активної ракети"}, - + "@activeStatus": { + "description": "Позначка активної ракети" + }, "retiredStatus": "Знято з експлуатації", - "@retiredStatus": {"description": "Позначка знятої ракети"}, - + "@retiredStatus": { + "description": "Позначка знятої ракети" + }, "successRate": "{percentage}% успішних запусків", - "@successRate": {"description": "Позначка успішності ракети з відсотком"}, + "@successRate": { + "description": "Позначка успішності ракети з відсотком" + }, "rocketsTitle": "Ракети", "@rocketsTitle": { "description": "Назва екрану Ракети" @@ -361,5 +365,28 @@ "propellant1Label": "Паливо 1", "propellant2Label": "Паливо 2", "thrustSeaLevelLabel": "Тяга (на рівні моря)", - "tons": "тонн" + "tons": "тонн", + "learnMore": "Дізнатися більше", + "launchInformation": "Інформація про запуск", + "launchMass": "Маса запуску", + "launchVehicle": "Ракета-носій", + "orbitalParameters": "Орбітальні параметри", + "millionKm": "мільйон км", + "missionDetails": "Деталі місії", + "trackLive": "Слідкувати онлайн", + "marsDistance": "Відстань до Марса", + "earthDistance": "Відстань до Землі", + "currentSpeed": "Поточна швидкість", + "orbitalPeriod": "Орбітальний період", + "unitDays": "днів", + "unitKph": "км/год", + "launched": "Запуск: {date}", + "roadsterTitle": "Роадстер", + "roadsterDescription": "Tesla Roadster Ілона Маска", + "apoapsis": "Апоцентр", + "periapsis": "Перицентр", + "semiMajorAxis": "Велика піввісь", + "eccentricity": "Ексцентриситет", + "inclination": "Нахил", + "longitude": "Довгота" } diff --git a/lib/models/roadster/roadster_ext.dart b/lib/models/roadster/roadster_ext.dart new file mode 100644 index 0000000..1d83b13 --- /dev/null +++ b/lib/models/roadster/roadster_ext.dart @@ -0,0 +1,36 @@ +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +extension RoadsterExt on NetworkRoadsterModel { + RoadsterResource toResource() { + return RoadsterResource( + name: name, + launchDateUtc: launchDateUtc, + launchDateUnix: launchDateUnix, + launchMassKg: launchMassKg, + launchMassLbs: launchMassLbs, + noradId: noradId, + epochJd: epochJd, + orbitType: orbitType, + apoapsisAu: apoapsisAu, + periapsisAu: periapsisAu, + semiMajorAxisAu: semiMajorAxisAu, + eccentricity: eccentricity, + inclination: inclination, + longitude: longitude, + periapsisArg: periapsisArg, + periodDays: periodDays, + speedKph: speedKph, + speedMph: speedMph, + earthDistanceKm: earthDistanceKm, + earthDistanceMi: earthDistanceMi, + marsDistanceKm: marsDistanceKm, + marsDistanceMi: marsDistanceMi, + flickrImages: flickrImages, + wikipedia: wikipedia, + video: video, + details: details, + id: id, + ); + } +} diff --git a/lib/models/roadster/roadster_resource.dart b/lib/models/roadster/roadster_resource.dart new file mode 100644 index 0000000..3539b1d --- /dev/null +++ b/lib/models/roadster/roadster_resource.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class RoadsterResource extends Equatable { + const RoadsterResource({ + this.name, + this.launchDateUtc, + this.launchDateUnix, + this.launchMassKg, + this.launchMassLbs, + this.noradId, + this.epochJd, + this.orbitType, + this.apoapsisAu, + this.periapsisAu, + this.semiMajorAxisAu, + this.eccentricity, + this.inclination, + this.longitude, + this.periapsisArg, + this.periodDays, + this.speedKph, + this.speedMph, + this.earthDistanceKm, + this.earthDistanceMi, + this.marsDistanceKm, + this.marsDistanceMi, + this.flickrImages, + this.wikipedia, + this.video, + this.details, + this.id, + }); + + final String? name; + final String? launchDateUtc; + final int? launchDateUnix; + final int? launchMassKg; + final int? launchMassLbs; + final int? noradId; + final double? epochJd; + final String? orbitType; + final double? apoapsisAu; + final double? periapsisAu; + final double? semiMajorAxisAu; + final double? eccentricity; + final double? inclination; + final double? longitude; + final double? periapsisArg; + final double? periodDays; + final double? speedKph; + final double? speedMph; + final double? earthDistanceKm; + final double? earthDistanceMi; + final double? marsDistanceKm; + final double? marsDistanceMi; + final List? flickrImages; + final String? wikipedia; + final String? video; + final String? details; + final String? id; + + @override + List get props => [ + name, + launchDateUtc, + launchDateUnix, + launchMassKg, + launchMassLbs, + noradId, + epochJd, + orbitType, + apoapsisAu, + periapsisAu, + semiMajorAxisAu, + eccentricity, + inclination, + longitude, + periapsisArg, + periodDays, + speedKph, + speedMph, + earthDistanceKm, + earthDistanceMi, + marsDistanceKm, + marsDistanceMi, + flickrImages, + wikipedia, + video, + details, + id, + ]; +} diff --git a/lib/repository/roadster_repository.dart b/lib/repository/roadster_repository.dart new file mode 100644 index 0000000..353a87f --- /dev/null +++ b/lib/repository/roadster_repository.dart @@ -0,0 +1,27 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_ext.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; + +abstract class RoadsterRepository { + Future getRoadster(); +} + +class RoadsterRepositoryImpl implements RoadsterRepository { + RoadsterRepositoryImpl(this._roadsterDataSource); + + final RoadsterDataSource _roadsterDataSource; + + @override + Future getRoadster() async { + final result = await _roadsterDataSource.getRoadster(); + + return ApiResultWhen(result).when( + success: (data) => data.toResource(), + error: (message) => throw Exception(message), + loading: () { + throw Exception('Loading'); + }, + ); + } +} diff --git a/lib/routes/router.dart b/lib/routes/router.dart index 9e55683..26bb2eb 100644 --- a/lib/routes/router.dart +++ b/lib/routes/router.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc_app_template/features/appearance/appearance_screen.dart'; import 'package:flutter_bloc_app_template/features/appearance/dark_theme_screen.dart'; import 'package:flutter_bloc_app_template/features/launch/launch_screen.dart'; +import 'package:flutter_bloc_app_template/features/roadster/roadster_screen.dart'; import 'package:flutter_bloc_app_template/features/rocket/rocket_screen.dart'; import 'package:flutter_bloc_app_template/index.dart'; @@ -13,6 +14,7 @@ class Routes { static const darkTheme = 'darkTheme'; static const launch = 'launch'; static const rocket = 'rocket'; + static const roadster = 'roadster'; } final GlobalKey appNavigatorKey = GlobalKey(); @@ -24,6 +26,7 @@ class NavigationService { Routes.darkTheme: (_) => const DarkThemeScreen(), Routes.launch: (_) => const LaunchScreen(), Routes.rocket: (_) => const RocketScreen(), + Routes.roadster: (_) => const RoadsterScreen(), }; final Set _animatedRoutes = { @@ -31,6 +34,7 @@ class NavigationService { Routes.darkTheme, Routes.launch, Routes.rocket, + Routes.roadster, }; // iOS: full screen routes pop up from the bottom and disappear vertically too diff --git a/integration_test/rocket_screen_screenshot_test.dart b/screenshot_test/rocket_screen_screenshot_test.dart similarity index 100% rename from integration_test/rocket_screen_screenshot_test.dart rename to screenshot_test/rocket_screen_screenshot_test.dart diff --git a/integration_test/settings_screenshot_test.dart b/screenshot_test/settings_screenshot_test.dart similarity index 100% rename from integration_test/settings_screenshot_test.dart rename to screenshot_test/settings_screenshot_test.dart diff --git a/test/data/network/fixtures/roadster/roadster.json b/test/data/network/fixtures/roadster/roadster.json new file mode 100644 index 0000000..362e152 --- /dev/null +++ b/test/data/network/fixtures/roadster/roadster.json @@ -0,0 +1,34 @@ +{ + "name": "Elon Musk's Tesla Roadster", + "launch_date_utc": "2018-02-06T20:45:00.000Z", + "launch_date_unix": 1517949900, + "launch_mass_kg": 1350, + "launch_mass_lbs": 2976, + "norad_id": 43205, + "epoch_jd": 2459914.263888889, + "orbit_type": "heliocentric", + "apoapsis_au": 1.664332332453025, + "periapsis_au": 0.986015924224046, + "semi_major_axis_au": 57.70686413577451, + "eccentricity": 0.2559348215918733, + "inclination": 1.075052357364693, + "longitude": 316.9112133411523, + "periapsis_arg": 177.75981116285, + "period_days": 557.1958197697352, + "speed_kph": 9520.88362029108, + "speed_mph": 5916.000976023889, + "earth_distance_km": 320593735.82924163, + "earth_distance_mi": 199207650.2259517, + "mars_distance_km": 395640511.90355814, + "mars_distance_mi": 245839540.52202582, + "flickr_images": [ + "https://farm5.staticflickr.com/4615/40143096241_11128929df_b.jpg", + "https://farm5.staticflickr.com/4702/40110298232_91b32d0cc0_b.jpg", + "https://farm5.staticflickr.com/4676/40110297852_5e794b3258_b.jpg", + "https://farm5.staticflickr.com/4745/40110304192_6e3e9a7a1b_b.jpg" + ], + "wikipedia": "https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster", + "video": "https://youtu.be/wbSwFU6tY1c", + "details": "Elon Musk's Tesla Roadster is an electric sports car that served as the dummy payload for the February 2018 Falcon Heavy test flight and is now an artificial satellite of the Sun. Starman, a mannequin dressed in a spacesuit, occupies the driver's seat. The car and rocket are products of Tesla and SpaceX. This 2008-model Roadster was previously used by Musk for commuting, and is the only consumer car sent into space.", + "id": "5eb75f0842fea42237d7f3f4" +} diff --git a/test/data/network/model/roadster/network_roadster_model_test.dart b/test/data/network/model/roadster/network_roadster_model_test.dart new file mode 100644 index 0000000..3bc6d21 --- /dev/null +++ b/test/data/network/model/roadster/network_roadster_model_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final json = { + 'name': "Elon Musk's Tesla Roadster", + 'launch_date_utc': '2018-02-06T20:45:00.000Z', + 'launch_date_unix': 1517949900, + 'launch_mass_kg': 1350, + 'launch_mass_lbs': 2976, + 'norad_id': 43205, + 'epoch_jd': 2459914.263888889, + 'orbit_type': 'heliocentric', + 'apoapsis_au': 1.664332332453025, + 'periapsis_au': 0.986015924224046, + 'semi_major_axis_au': 57.70686413577451, + 'eccentricity': 0.2559348215918733, + 'inclination': 1.075052357364693, + 'longitude': 316.9112133411523, + 'periapsis_arg': 177.75981116285, + 'period_days': 557.1958197697352, + 'speed_kph': 9520.88362029108, + 'speed_mph': 5916.000976023889, + 'earth_distance_km': 320593735.82924163, + 'earth_distance_mi': 199207650.2259517, + 'mars_distance_km': 395640511.90355814, + 'mars_distance_mi': 245839540.52202582, + 'flickr_images': [ + 'https://farm5.staticflickr.com/4615/40143096241_11128929df_b.jpg', + 'https://farm5.staticflickr.com/4702/40110298232_91b32d0cc0_b.jpg', + 'https://farm5.staticflickr.com/4676/40110297852_5e794b3258_b.jpg', + 'https://farm5.staticflickr.com/4745/40110304192_6e3e9a7a1b_b.jpg' + ], + 'wikipedia': 'https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster', + 'video': 'https://youtu.be/wbSwFU6tY1c', + 'details': + "Elon Musk's Tesla Roadster is an electric sports car that served as" + ' the dummy payload for the February 2018 Falcon Heavy test' + ' flight and is now an artificial satellite of the Sun.' + ' Starman, a mannequin dressed in a spacesuit, occupies ' + "the driver's seat. The car and rocket are products of Tesla" + ' and SpaceX. This 2008-model Roadster was previously used by' + ' Musk for commuting, and is the only consumer car sent into' + ' space.', + 'id': '5eb75f0842fea42237d7f3f4' + }; + + group('NetworkRoadsterModel', () { + test('can be instantiated', () { + final model = const NetworkRoadsterModel( + name: 'Roadster', + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchMassKg: 1350, + ); + + expect(model.name, 'Roadster'); + expect(model.launchDateUtc, '2018-02-06T20:45:00.000Z'); + expect(model.launchMassKg, 1350); + expect(model.launchMassLbs, isNull); + }); + + test('supports equality and copyWith', () { + final model = + const NetworkRoadsterModel(name: 'Roadster', launchMassKg: 1350); + final copy = model.copyWith(launchMassKg: 1400); + + expect(copy.name, model.name); + expect(copy.launchMassKg, 1400); + expect(model != copy, true); // Original and copy are different + }); + + test('can be serialized to JSON', () { + final model = const NetworkRoadsterModel( + name: 'Roadster', + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchMassKg: 1350, + flickrImages: ['url1', 'url2'], + ); + + final json = model.toJson(); + + expect(json['name'], 'Roadster'); + expect(json['launch_date_utc'], '2018-02-06T20:45:00.000Z'); + expect(json['launch_mass_kg'], 1350); + expect(json['flickr_images'], ['url1', 'url2']); + }); + + test('can be deserialized from JSON', () { + final json = { + 'name': 'Roadster', + 'launch_date_utc': '2018-02-06T20:45:00.000Z', + 'launch_mass_kg': 1350, + 'flickr_images': ['url1', 'url2'], + }; + + final model = NetworkRoadsterModel.fromJson(json); + + expect(model.name, 'Roadster'); + expect(model.launchDateUtc, '2018-02-06T20:45:00.000Z'); + expect(model.launchMassKg, 1350); + expect(model.flickrImages, ['url1', 'url2']); + }); + + test('handles null values', () { + final model = const NetworkRoadsterModel(); + + expect(model.name, isNull); + expect(model.launchMassKg, isNull); + expect(model.flickrImages, isNull); + }); + + test('fromJson parses correctly', () { + final model = NetworkRoadsterModel.fromJson(json); + + expect(model.name, "Elon Musk's Tesla Roadster"); + expect(model.launchDateUtc, '2018-02-06T20:45:00.000Z'); + expect(model.launchDateUnix, 1517949900); + expect(model.launchMassKg, 1350); + expect(model.launchMassLbs, 2976); + expect(model.noradId, 43205); + expect(model.epochJd, 2459914.263888889); + expect(model.orbitType, 'heliocentric'); + expect(model.apoapsisAu, 1.664332332453025); + expect(model.periapsisAu, 0.986015924224046); + expect(model.semiMajorAxisAu, 57.70686413577451); + expect(model.eccentricity, 0.2559348215918733); + expect(model.inclination, 1.075052357364693); + expect(model.longitude, 316.9112133411523); + expect(model.periapsisArg, 177.75981116285); + expect(model.periodDays, 557.1958197697352); + expect(model.speedKph, 9520.88362029108); + expect(model.speedMph, 5916.000976023889); + expect(model.earthDistanceKm, 320593735.82924163); + expect(model.earthDistanceMi, 199207650.2259517); + expect(model.marsDistanceKm, 395640511.90355814); + expect(model.marsDistanceMi, 245839540.52202582); + expect(model.flickrImages?.length, 4); + expect(model.wikipedia, + 'https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster'); + expect(model.video, 'https://youtu.be/wbSwFU6tY1c'); + expect(model.details, contains("Elon Musk's Tesla Roadster")); + expect(model.id, '5eb75f0842fea42237d7f3f4'); + }); + + test('toJson outputs correct JSON', () { + final model = NetworkRoadsterModel.fromJson(json); + final output = model.toJson(); + + expect(output['name'], json['name']); + expect(output['launch_date_utc'], json['launch_date_utc']); + expect(output['launch_mass_kg'], json['launch_mass_kg']); + expect(output['flickr_images'], json['flickr_images']); + expect(output['id'], json['id']); + }); + }); +} diff --git a/test/data/network/service/roadster/roadter_service_test.dart b/test/data/network/service/roadster/roadter_service_test.dart new file mode 100644 index 0000000..b4ade05 --- /dev/null +++ b/test/data/network/service/roadster/roadter_service_test.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart'; +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/data/network/service/roadster/roadster_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../fixtures_reader.dart'; + +class MockRoadsterService extends Mock implements RoadsterService {} + +void main() { + late RoadsterService service; + late RoadsterDataSource dataSource; + + setUp(() async { + registerFallbackValue(Uri()); + service = MockRoadsterService(); + dataSource = RoadsterNetworkDataSource(service); + }); + + group('fetchRoadster', () { + final mockResponse = NetworkRoadsterModel.fromJson( + 'roadster/roadster.json'.toFixtureObject()); + + test( + 'should perform a GET request on /roadster and return a NetworkRoadsterModel', + () async { + // arrange + when( + () => service.fetchRoadster(), + ).thenAnswer( + (_) async => Future.value(mockResponse), + ); + + // act + final call = await dataSource.getRoadster(); + // assert + verify(() => service.fetchRoadster()); + expect(call, isA>()); + verifyNoMoreInteractions(service); + }, + ); + test('should perform a GET request on /roadster and return an error', + () async { + // arrange + when(() => service.fetchRoadster()).thenThrow(Exception('Server error')); + + // act + final call = await dataSource.getRoadster(); + + // assert + expect(call, isA>()); + verify(() => service.fetchRoadster()); + verifyNoMoreInteractions(service); + }); + + test('should perform a GET request on /roadster and return an dio error', + () async { + // arrange + when(() => service.fetchRoadster()).thenThrow( + DioException( + requestOptions: RequestOptions(), + ), + ); + + // act + final call = await dataSource.getRoadster(); + + // assert + expect(call, isA>()); + verify(() => service.fetchRoadster()); + verifyNoMoreInteractions(service); + }); + }); +} diff --git a/test/features/roadster/bloc/roadster_bloc_test.dart b/test/features/roadster/bloc/roadster_bloc_test.dart new file mode 100644 index 0000000..ec82d3a --- /dev/null +++ b/test/features/roadster/bloc/roadster_bloc_test.dart @@ -0,0 +1,60 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc_app_template/features/roadster/bloc/roadster_bloc.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../mocks.mocks.dart'; +import '../../../repository/roadster_repository_test.dart'; + +void main() { + group('RoadsterBloc', () { + late RoadsterRepository repository; + late RoadsterBloc bloc; + + setUp(() { + repository = MockRoadsterRepository(); + bloc = RoadsterBloc(repository); + }); + + tearDown(() => bloc.close()); + + test('should return RoadsterLoadingState initially', () { + expect(bloc.state, const RoadsterLoadingState()); + }); + + group('fetchData', () { + blocTest( + 'should emit [loading, success] when data is fetched successfully', + build: () { + when(repository.getRoadster()).thenAnswer( + (_) async => mockRoadsterResource, + ); + return bloc; + }, + act: (bloc) => bloc.add(const RoadsterLoadEvent()), + expect: () => [ + const RoadsterState.loading(), + RoadsterState.success(roadster: mockRoadsterResource), + ], + verify: (_) => verify(repository.getRoadster()).called(1), + ); + + blocTest( + 'should emit [loading, error] when an exception is thrown', + build: () { + when(repository.getRoadster()).thenThrow( + Exception('something went wrong'), + ); + return bloc; + }, + act: (bloc) => bloc.add(const RoadsterLoadEvent()), + expect: () => [ + const RoadsterState.loading(), + const RoadsterState.error(), + ], + verify: (_) => verify(repository.getRoadster()).called(1), + ); + }); + }); +} diff --git a/test/features/roadster/helpers/simple_image_test_helpers.dart b/test/features/roadster/helpers/simple_image_test_helpers.dart new file mode 100644 index 0000000..9bd54b2 --- /dev/null +++ b/test/features/roadster/helpers/simple_image_test_helpers.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class TestImageWidget extends StatelessWidget { + const TestImageWidget({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + }); + + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image, + color: Colors.grey[600], + size: 24, + ), + const SizedBox(height: 4), + Text( + 'Test Image', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/features/roadster/helpers/test_helpers.dart b/test/features/roadster/helpers/test_helpers.dart new file mode 100644 index 0000000..99c2409 --- /dev/null +++ b/test/features/roadster/helpers/test_helpers.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_image_test_helpers.dart'; + +/// Test VSyncProvider for animation controllers in tests +class TestVSync implements TickerProvider { + const TestVSync(); + + @override + Ticker createTicker(TickerCallback onTick) => Ticker(onTick); +} + +/// Creates a mock RoadsterResource for testing +RoadsterResource createMockRoadster() { + return const RoadsterResource( + name: "Elon Musk's Tesla Roadster", + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchDateUnix: 1517949900, + launchMassKg: 1350, + launchMassLbs: 2976, + noradId: 43205, + epochJd: 2459914.263888889, + orbitType: 'heliocentric', + apoapsisAu: 1.664332332453025, + periapsisAu: 0.986015924224046, + semiMajorAxisAu: 57.70686413577451, + eccentricity: 0.2559348215918733, + inclination: 1.075052357364693, + longitude: 316.9112133411523, + periapsisArg: 177.75981116285, + periodDays: 557.1958197697352, + speedKph: 9520.88362029108, + speedMph: 5916.000976023889, + earthDistanceKm: 320593735.82924163, + earthDistanceMi: 199207650.2259517, + marsDistanceKm: 395640511.90355814, + marsDistanceMi: 245839540.52202582, + flickrImages: [ + 'url1', + 'url2', + ], + wikipedia: 'https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster', + video: 'https://youtu.be/wbSwFU6tY1c', + details: 'Some details about Roadster', + id: '5eb75f0842fea42237d7f3f4', + ); +} + +/// Creates mock image URLs for testing +List createMockImages([int count = 3]) { + return List.generate( + count, + (index) => 'https://example.com/image$index.jpg', + ); +} + +/// Wrapper widget for testing widgets that need MaterialApp context +Widget wrapWithMaterialApp(Widget child) { + return MaterialApp( + home: Scaffold(body: child), + ); +} + +/// Wrapper widget for testing widgets that need Scaffold context +Widget wrapWithScaffold(Widget child) { + return MaterialApp( + home: Scaffold(body: child), + ); +} + +/// Helper for testing widgets that need Stack context +Widget wrapWithStack(Widget child) { + return MaterialApp( + home: Scaffold( + body: Stack(children: [child]), + ), + ); +} + +/// Helper for testing sliver widgets +Widget wrapWithCustomScrollView(Widget sliver) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [sliver], + ), + ), + ); +} + +/// Creates an animation controller for testing +AnimationController createTestAnimationController({ + Duration duration = const Duration(milliseconds: 300), +}) { + return AnimationController( + duration: duration, + vsync: const TestVSync(), + ); +} + +/// Pump animation frames for testing +Future pumpAnimationFrames( + WidgetTester tester, + AnimationController controller, { + int frames = 10, + Duration frameDuration = const Duration(milliseconds: 16), +}) async { + unawaited(controller.forward()); + for (var i = 0; i < frames; i++) { + await tester.pump(frameDuration); + } +} + +/// Mock gesture helper +Future simulateSwipe( + WidgetTester tester, + Finder finder, + Offset offset, +) async { + await tester.drag(finder, offset); + await tester.pumpAndSettle(); +} + +/// Verify no exceptions occurred during test +void verifyNoExceptions(WidgetTester tester) { + expect(tester.takeException(), isNull); +} + +Widget createTestImageCarousel({ + required List images, + required PageController pageController, + required ValueChanged onPageChanged, + double scrollOffset = 0.0, +}) { + return Transform.translate( + offset: Offset(0, scrollOffset * 0.5), + child: SizedBox( + height: 300, + child: PageView.builder( + controller: pageController, + onPageChanged: onPageChanged, + itemCount: images.length, + itemBuilder: (context, index) { + return Center( + child: TestImageWidget( + imageUrl: images[index], + width: 350, + height: 250, + ), + ); + }, + ), + ), + ); +} diff --git a/test/features/roadster/model/mission_test.dart b/test/features/roadster/model/mission_test.dart new file mode 100644 index 0000000..59e9d10 --- /dev/null +++ b/test/features/roadster/model/mission_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_bloc_app_template/features/roadster/model/mission.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Mission', () { + test('two objects with same values should be equal', () { + final m1 = Mission(name: 'Apollo 11', isPrimary: true); + final m2 = Mission(name: 'Apollo 11', isPrimary: true); + + expect(m1, equals(m2)); + expect(m1.hashCode, equals(m2.hashCode)); + }); + + test('objects with different name should not be equal', () { + final m1 = Mission(name: 'Apollo 11', isPrimary: true); + final m2 = Mission(name: 'Apollo 12', isPrimary: true); + + expect(m1, isNot(equals(m2))); + }); + + test('objects with different isPrimary should not be equal', () { + final m1 = Mission(name: 'Apollo 11', isPrimary: true); + final m2 = Mission(name: 'Apollo 11', isPrimary: false); + + expect(m1, isNot(equals(m2))); + }); + + test('props should contain name and isPrimary', () { + final mission = Mission(name: 'Apollo 11', isPrimary: true); + + expect(mission.props, containsAll(['Apollo 11', true])); + }); + }); +} diff --git a/test/features/roadster/model/orbital_data_test.dart b/test/features/roadster/model/orbital_data_test.dart new file mode 100644 index 0000000..602f19d --- /dev/null +++ b/test/features/roadster/model/orbital_data_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/orbital_data.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('OrbitalData', () { + test('two objects with same values should be equal', () { + final data1 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + final data2 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + + expect(data1, equals(data2)); + expect(data1.hashCode, equals(data2.hashCode)); + }); + + test('objects with different label should not be equal', () { + final data1 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + final data2 = OrbitalData( + label: 'Perihelion', + value: '152.1 million km', + icon: Icons.public, + ); + + expect(data1, isNot(equals(data2))); + }); + + test('objects with different value should not be equal', () { + final data1 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + final data2 = OrbitalData( + label: 'Aphelion', + value: '147.1 million km', + icon: Icons.public, + ); + + expect(data1, isNot(equals(data2))); + }); + + test('objects with different icon should not be equal', () { + final data1 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + final data2 = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.star, + ); + + expect(data1, isNot(equals(data2))); + }); + + test('props should contain label, value and icon', () { + final data = OrbitalData( + label: 'Aphelion', + value: '152.1 million km', + icon: Icons.public, + ); + + expect(data.props, + containsAll(['Aphelion', '152.1 million km', Icons.public])); + }); + }); +} diff --git a/test/features/roadster/performance/widget_performance_test.dart b/test/features/roadster/performance/widget_performance_test.dart new file mode 100644 index 0000000..29519c1 --- /dev/null +++ b/test/features/roadster/performance/widget_performance_test.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_gradient_background.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_stars_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('Widget Performance Tests', () { + testWidgets('AnimatedGradientBackground performance', (tester) async { + final controller = createTestAnimationController(); + + await tester.pumpWidget( + MaterialApp( + home: AnimatedGradientBackground(pulseController: controller), + ), + ); + + // Measure rendering performance + final stopwatch = Stopwatch()..start(); + + // Run animation for multiple cycles + for (var i = 0; i < 10; i++) { + unawaited(controller.forward()); + await tester.pump(const Duration(milliseconds: 100)); + unawaited(controller.reverse()); + await tester.pump(const Duration(milliseconds: 100)); + } + + stopwatch.stop(); + + // Verify acceptable performance + expect(stopwatch.elapsedMilliseconds, lessThan(2000)); // 2 seconds max + + controller.dispose(); + }); + + testWidgets('AnimatedStarsField rendering performance', (tester) async { + final controller = createTestAnimationController(); + + final stopwatch = Stopwatch()..start(); + + await tester.pumpWidget( + MaterialApp( + home: AnimatedStarsField(pulseController: controller), + ), + ); + + stopwatch.stop(); + + // 50 stars should render quickly + expect(stopwatch.elapsedMilliseconds, lessThan(500)); + await tester.pump(); // ensure first frame + expect(tester.takeException(), isNull); + controller.dispose(); + }); + }); +} diff --git a/test/features/roadster/roadster_detail_screen.dart b/test/features/roadster/roadster_detail_screen.dart new file mode 100644 index 0000000..562584a --- /dev/null +++ b/test/features/roadster/roadster_detail_screen.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/roadster_screen.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/roadster_app_bar.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_gradient_background.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_stars_field.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/buttons/track_live_button.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/roadster_content.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +import '../../repository/roadster_repository_test.dart'; + +void main() { + late RoadsterResource mockRoadster; + late List mockImages; + + setUpAll(() => HttpOverrides.global = null); + + setUp(() { + mockRoadster = mockRoadsterResource; + mockImages = ['image1.jpg', 'image2.jpg']; + }); + + testWidgets('renders main sections and background', (tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: RoadsterDetailScreen( + roadster: mockRoadster, + images: mockImages, + ), + ), + ), + ); + // Animated background + expect(find.byType(AnimatedGradientBackground), findsOneWidget); + expect(find.byType(AnimatedStarsField), findsOneWidget); + + // Roadster content + expect(find.byType(RoadsterAppBar), findsOneWidget); + expect(find.byType(RoadsterContent), findsOneWidget); + + // Floating button + expect(find.byType(TrackLiveButton), findsOneWidget); + }); +} diff --git a/test/features/roadster/roadster_screen_test.dart b/test/features/roadster/roadster_screen_test.dart new file mode 100644 index 0000000..33adda0 --- /dev/null +++ b/test/features/roadster/roadster_screen_test.dart @@ -0,0 +1,53 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/bloc/roadster_bloc.dart'; +import 'package:flutter_bloc_app_template/features/roadster/roadster_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../bloc/utils.dart'; + +class MockRoadsterBloc extends MockBloc + implements RoadsterBloc {} + +void main() { + late RoadsterBloc roadsterBloc; + + setUp(() { + roadsterBloc = MockRoadsterBloc(); + when(() => roadsterBloc.stream) + .thenAnswer((_) => const Stream.empty()); + when(() => roadsterBloc.close()).thenAnswer((_) async {}); + addTearDown(() => roadsterBloc.close()); + }); + + group('Roadster Screen Tests', () { + testWidgets( + 'renders CircularProgressIndicator ' + 'when state is initial', (tester) async { + when(() => roadsterBloc.state).thenReturn(const RoadsterLoadingState()); + + await tester.pumpLocalizedWidgetWithBloc( + bloc: roadsterBloc, + child: const RoadsterScreenBlocContent(), + locale: const Locale('en'), + ); + + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders error', (tester) async { + when(() => roadsterBloc.state).thenReturn(const RoadsterErrorState()); + await tester.pumpLocalizedWidgetWithBloc( + bloc: roadsterBloc, + child: const RoadsterScreenBlocContent(), + locale: const Locale('en'), + ); + await tester.pumpAndSettle(); + + expect(find.text('Try Again'), findsOneWidget); + }); + }); +} diff --git a/test/features/roadster/utils/roadster_utils_test.dart b/test/features/roadster/utils/roadster_utils_test.dart new file mode 100644 index 0000000..5477d76 --- /dev/null +++ b/test/features/roadster/utils/roadster_utils_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/utils/roadster_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NumberFormatter', () { + test('formats speed_kph correctly', () { + var speed = 9520.88362029108; + final formatted = speed.formatSpeed(); + expect(formatted, '9,520.88'); + }); + + test('formats period_days correctly', () { + var period = 557.1958197697352; + final formatted = period.formatPeriod(); + expect(formatted, '557.2'); + }); + + test('formats zero correctly', () { + expect(0.0.formatSpeed(), '0.00'); + expect(0.0.formatPeriod(), '0.0'); + }); + + test('formats large numbers correctly', () { + var speed = 12345678.9012; + final formatted = speed.formatSpeed(); + expect(formatted, '12,345,678.90'); + }); + + test('formats negative numbers correctly', () { + var speed = -9876.54321; + final formatted = speed.formatSpeed(); + expect(formatted, '-9,876.54'); + }); + }); + + group('DateFormatting extension', () { + test('converts valid UTC date string to formatted date', () { + const utcDate = '2018-02-06T20:45:00.000Z'; + final formatted = utcDate.toFormattedDate(); + expect(formatted, 'Feb 6, 2018'); + }); + + test('returns original string if invalid date', () { + const invalidDate = 'not-a-date'; + final formatted = invalidDate.toFormattedDate(); + expect(formatted, invalidDate); + }); + + test('converts another valid UTC date', () { + const utcDate = '2025-09-10T01:23:45.000Z'; + final formatted = utcDate.toFormattedDate(); + expect(formatted, 'Sep 10, 2025'); + }); + }); + + group('TextSplitExt', () { + test('splits string with apostrophe correctly', () { + const input = "Elon Musk's Tesla Roadster"; + final widgets = input.toSplitTextWidgets(); + + expect(widgets.length, 2); + + final firstLine = widgets[0]; + final secondLine = widgets[1]; + + expect(firstLine.data, "Elon Musk's"); + expect(secondLine.data, 'Tesla Roadster'); + + expect(firstLine.style?.color, Colors.white70); + expect(secondLine.style?.color, Colors.white); + expect(secondLine.style?.fontSize, 36); + }); + + test('returns single Text if delimiter not found', () { + const input = 'JustOneLine'; + final widgets = input.toSplitTextWidgets(); + + expect(widgets.length, 1); + + final onlyLine = widgets[0]; + expect(onlyLine.data, 'JustOneLine'); + }); + + test('works with custom styles', () { + const input = "John Doe's SuperCar"; + final widgets = input.toSplitTextWidgets( + firstLineStyle: const TextStyle(color: Colors.red), + secondLineStyle: const TextStyle(color: Colors.green), + ); + + expect((widgets[0]).style?.color, Colors.red); + expect((widgets[1]).style?.color, Colors.green); + }); + }); + + group('DoublRoadsterExtension:toAuString', () { + test('formats to 3 decimals by default', () { + const value = 1.664332332453025; + expect(value.toAuString(), '1.664 AU'); + }); + + test('rounds correctly with 2 decimals', () { + const value = 1.664332332453025; + expect(value.toAuString(fractionDigits: 2), '1.66 AU'); + }); + + test('rounds correctly with 5 decimals', () { + const value = 1.664332332453025; + expect(value.toAuString(fractionDigits: 5), '1.66433 AU'); + }); + + test('preserves trailing zeros', () { + const value = 2.5; + expect(value.toAuString(), '2.500 AU'); + }); + + test('works with negative numbers', () { + const value = -0.98765; + expect(value.toAuString(fractionDigits: 3), '-0.988 AU'); + }); + }); + + group('DoublRoadsterExtension:toFixedString', () { + test('toAuString default 3 decimals', () { + const value = 1.664332332453025; + expect(value.toAuString(), '1.664 AU'); + }); + + test('toAuString custom decimals', () { + const value = 1.664332332453025; + expect(value.toAuString(fractionDigits: 2), '1.66 AU'); + }); + + test('toFixedString default 3 decimals', () { + const value = 0.2559348215918733; + expect(value.toFixedString(), '0.256'); + }); + + test('toFixedString custom decimals', () { + const value = 0.2559348215918733; + expect(value.toFixedString(fractionDigits: 5), '0.25593'); + }); + + test('toFixedString preserves trailing zeros', () { + const value = 2.5; + expect(value.toFixedString(), '2.500'); + }); + + test('toFixedString works with negative numbers', () { + const value = -0.98765; + expect(value.toFixedString(fractionDigits: 2), '-0.99'); + }); + }); + + group('DoublRoadsterExtension:toDegreeString', () { + test('toDegreeString default 2 decimals', () { + const value = 316.9112133411523; + expect(value.toDegreeString(), '316.91°'); + }); + + test('toDegreeString with 3 decimals', () { + const value = 1.075052357364693; + expect(value.toDegreeString(fractionDigits: 3), '1.075°'); + }); + + test('toDegreeString preserves trailing zeros', () { + const value = 45.5; + expect(value.toDegreeString(fractionDigits: 2), '45.50°'); + }); + + test('toDegreeString works with negative values', () { + const value = -12.3456; + expect(value.toDegreeString(fractionDigits: 1), '-12.3°'); + }); + }); +} diff --git a/test/features/roadster/widget/animated_counter_widget_test.dart b/test/features/roadster/widget/animated_counter_widget_test.dart new file mode 100644 index 0000000..07ee41a --- /dev/null +++ b/test/features/roadster/widget/animated_counter_widget_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_counter_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AnimatedCounterWidget displays integer with commas', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedCounterWidget( + value: 1234567, + duration: Duration(milliseconds: 300), + ), + ), + ), + ); + + // Initial value is 0 + expect(find.text('0'), findsOneWidget); + + // Let the animation run to the end + await tester.pumpAndSettle(); + + // Final value should be formatted with commas + expect(find.text('1,234,567'), findsOneWidget); + }); + + testWidgets('AnimatedCounterWidget displays decimal values', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedCounterWidget( + value: 1234.5678, + decimals: 2, + duration: Duration(milliseconds: 300), + ), + ), + ), + ); + + // Initial value is 0.00 + expect(find.text('0.00'), findsOneWidget); + + // Let the animation run to the end + await tester.pumpAndSettle(); + + // Final value should match rounded decimal format + expect(find.text('1234.57'), findsOneWidget); + }); + + testWidgets('AnimatedCounterWidget updates text as animation progresses', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedCounterWidget( + value: 100, + duration: Duration(seconds: 1), + ), + ), + ), + ); + + // Immediately after build, animation starts + expect(find.text('0'), findsOneWidget); + + // Pump half the duration + await tester.pump(const Duration(milliseconds: 500)); + + // The value should be between 0 and 100 + final textWidget = tester.widget(find.byType(Text)); + final value = int.tryParse(textWidget.data!.replaceAll(',', '')); + expect(value, isNotNull); + expect(value!, greaterThan(0)); + expect(value, lessThan(100)); + + // Complete the animation + await tester.pumpAndSettle(); + + // Final value + expect(find.text('100'), findsOneWidget); + }); +} diff --git a/test/features/roadster/widget/animated_star_widget_test.dart b/test/features/roadster/widget/animated_star_widget_test.dart new file mode 100644 index 0000000..f5c3979 --- /dev/null +++ b/test/features/roadster/widget/animated_star_widget_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_star_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AnimatedStarWidget renders correctly', + (WidgetTester tester) async { + final controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 1), + ); + + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + AnimatedStarWidget(index: 42, pulseController: controller), + ], + ), + ), + ); + + // Verify Positioned exists + expect(find.byType(Positioned), findsOneWidget); + + // Verify Container exists + final containerFinder = find.descendant( + of: find.byType(Positioned), matching: find.byType(Container)); + expect(containerFinder, findsOneWidget); + + // Verify width and height (size between 1 and 4) + final container = tester.widget(containerFinder); + expect(container.constraints?.maxWidth ?? 0, inInclusiveRange(1, 4)); + final size = tester.getSize(containerFinder); + expect(size.width, inInclusiveRange(1, 4)); + expect(size.height, inInclusiveRange(1, 4)); + // Start the animation + controller.value = 0.5; + await tester.pump(); + + // Opacity before + final before = + ((tester.widget(containerFinder).decoration as BoxDecoration) + .color)! + .a; + // Advance the animation + controller.value = 0.55; + await tester.pump(); + // Opacity after + final after = + ((tester.widget(containerFinder).decoration as BoxDecoration) + .color)! + .a; + expect(after, greaterThan(before)); + + // Verify color alpha changed + final boxDecoration = container.decoration as BoxDecoration; + final alpha = boxDecoration.color?.a ?? 0; + expect(alpha, greaterThan(0)); // should be > 0 + }); +} diff --git a/test/features/roadster/widget/animated_stat_card_widget_test.dart b/test/features/roadster/widget/animated_stat_card_widget_test.dart new file mode 100644 index 0000000..9739502 --- /dev/null +++ b/test/features/roadster/widget/animated_stat_card_widget_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_counter_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_stat_card_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AnimatedStatCardWidget renders correctly', + (WidgetTester tester) async { + const testIcon = Icons.star; + const testTitle = 'Stars'; + const testValue = '1,234'; + const testUnit = 'pts'; + const testColor = Colors.blue; + const testDelay = 100; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedStatCardWidget( + icon: testIcon, + title: testTitle, + value: testValue, + unit: testUnit, + color: testColor, + delay: testDelay, + ), + ), + ), + ); + + // Verify TweenAnimationBuilder exists + expect(find.byType(TweenAnimationBuilder), findsOneWidget); + + // Verify Card exists + expect(find.byType(Card), findsOneWidget); + + // Verify Icon is correct + final iconWidget = tester.widget(find.byType(Icon)); + expect(iconWidget.icon, testIcon); + expect(iconWidget.color, testColor); + + // Verify title Text + expect(find.text(testTitle), findsOneWidget); + + // Verify unit Text + expect(find.text(testUnit), findsOneWidget); + + // Verify AnimatedCounterWidget value + final counterWidget = tester + .widget(find.byType(AnimatedCounterWidget)); + expect(counterWidget.value, 1234.0); // value parsed correctly + + // Optionally, pump to trigger animations + await tester.pump(const Duration(seconds: 3)); + }); +} diff --git a/test/features/roadster/widget/app_bar/gradient_overlay_test.dart b/test/features/roadster/widget/app_bar/gradient_overlay_test.dart new file mode 100644 index 0000000..902a87b --- /dev/null +++ b/test/features/roadster/widget/app_bar/gradient_overlay_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/gradient_overlay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GradientOverlay', () { + testWidgets('should render positioned container with gradient', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [GradientOverlay()], + ), + ), + ); + + expect(find.byType(Positioned), findsOneWidget); + expect(find.byType(Container), findsOneWidget); + }); + + testWidgets('should have correct positioning', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [GradientOverlay()], + ), + ), + ); + + final positioned = tester.widget(find.byType(Positioned)); + expect(positioned.bottom, equals(0)); + expect(positioned.left, equals(0)); + expect(positioned.right, equals(0)); + }); + + testWidgets('should have gradient decoration', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [GradientOverlay()], + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect(container.decoration, isA()); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + }); + }); +} diff --git a/test/features/roadster/widget/app_bar/image_carousel_test.dart b/test/features/roadster/widget/app_bar/image_carousel_test.dart new file mode 100644 index 0000000..317cbff --- /dev/null +++ b/test/features/roadster/widget/app_bar/image_carousel_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/image_carousel.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +void main() { + group('ImageCarousel', () { + late PageController pageController; + late List mockImages; + var pageChangedCallCount = 0; + + setUp(() { + pageController = PageController(viewportFraction: 0.85); + mockImages = ['image1.jpg', 'image2.jpg', 'image3.jpg']; + pageChangedCallCount = 0; + }); + + tearDown(() { + pageController.dispose(); + }); + + testWidgets('should render page view with images', + (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: ImageCarousel( + images: mockImages, + pageController: pageController, + scrollOffset: 0.0, + onPageChanged: (index) => pageChangedCallCount++, + ), + ), + ), + ); + + expect(find.byType(PageView), findsOneWidget); + expect(find.byType(Card), findsAtLeastNWidgets(1)); + }); + + testWidgets('should apply parallax offset', (WidgetTester tester) async { + const scrollOffset = 100.0; + + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: ImageCarousel( + images: mockImages, + pageController: pageController, + scrollOffset: scrollOffset, + onPageChanged: (index) => pageChangedCallCount++, + ), + ), + ), + ); + + final transform = + tester.widget(find.byKey(const Key('parallax_transform'))); + + expect( + transform.transform.getTranslation().y, equals(scrollOffset * 0.5)); + }); + + testWidgets('should call onPageChanged when swiping', + (WidgetTester tester) async { + pageChangedCallCount = 0; + pageController.dispose(); + pageController = PageController( + viewportFraction: 0.85, + initialPage: 0, + ); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: ImageCarousel( + images: mockImages, + pageController: pageController, + scrollOffset: 0.0, + onPageChanged: (index) => pageChangedCallCount++, + ), + ), + ); + + // Wait for layout to stabilize + await tester.pumpAndSettle(); + + // Drag by the width of the page to trigger page change + await tester.drag(find.byType(PageView), const Offset(-400, 0)); + await tester.pumpAndSettle(); + + expect(pageChangedCallCount, greaterThan(0)); + }); + }); + }); +} diff --git a/test/features/roadster/widget/app_bar/image_indicators_test.dart b/test/features/roadster/widget/app_bar/image_indicators_test.dart new file mode 100644 index 0000000..b3bc820 --- /dev/null +++ b/test/features/roadster/widget/app_bar/image_indicators_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/image_indicators.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ImageIndicators', () { + testWidgets('should render correct number of indicators', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [ + ImageIndicators( + imageCount: 3, + currentIndex: 0, + ), + ], + ), + ), + ); + + expect(find.byType(AnimatedContainer), findsNWidgets(3)); + }); + + testWidgets('should highlight current indicator', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [ + ImageIndicators( + imageCount: 3, + currentIndex: 1, + ), + ], + ), + ), + ); + + final containers = find.byType(AnimatedContainer); + + // Check the widths using tester.getSize() + final size0 = tester.getSize(containers.at(0)); + final size1 = tester.getSize(containers.at(1)); + final size2 = tester.getSize(containers.at(2)); + + expect(size1.width, equals(32)); // current indicator + expect(size0.width, equals(16)); + expect(size2.width, equals(16)); + }); + + testWidgets('should position indicators at bottom center', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Stack( + children: [ + ImageIndicators( + imageCount: 3, + currentIndex: 0, + ), + ], + ), + ), + ); + + final positioned = tester.widget(find.byType(Positioned)); + expect(positioned.bottom, equals(10)); + expect(positioned.left, equals(0)); + expect(positioned.right, equals(0)); + + final row = tester.widget(find.byType(Row)); + expect(row.mainAxisAlignment, equals(MainAxisAlignment.center)); + }); + }); +} diff --git a/test/features/roadster/widget/app_bar/roadster_app_bar_test.dart b/test/features/roadster/widget/app_bar/roadster_app_bar_test.dart new file mode 100644 index 0000000..ad9e57a --- /dev/null +++ b/test/features/roadster/widget/app_bar/roadster_app_bar_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/image_carousel.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/image_indicators.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/app_bar/roadster_app_bar.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart' hide TestVSync; +import 'package:network_image_mock/network_image_mock.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + group('RoadsterAppBar', () { + late RoadsterResource mockRoadster; + late List mockImages; + late AnimationController fadeController; + + setUp(() { + mockRoadster = createMockRoadster(); + mockImages = ['image1.jpg', 'image2.jpg', 'image3.jpg']; + fadeController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + fadeController.dispose(); + }); + + testWidgets('should render sliver app bar', (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: Scaffold( + body: CustomScrollView( + slivers: [ + RoadsterAppBar( + roadster: mockRoadster, + images: mockImages, + scrollOffset: 0.0, + fadeController: fadeController, + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(SliverAppBar), findsOneWidget); + }); + + testWidgets('should show title when scrolled', (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: Scaffold( + body: CustomScrollView( + slivers: [ + RoadsterAppBar( + roadster: mockRoadster, + images: mockImages, + scrollOffset: 250.0, // Scrolled past threshold + fadeController: fadeController, + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Tesla Roadster'), findsAtLeast(1)); + }); + + testWidgets('should hide title when not scrolled', + (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: Scaffold( + body: CustomScrollView( + slivers: [ + RoadsterAppBar( + roadster: mockRoadster, + images: mockImages, + scrollOffset: 0.0, // Not scrolled + fadeController: fadeController, + ), + ], + ), + ), + ), + ), + ); + // Title should be transparent + final animatedOpacity = tester.widget( + find.byType(AnimatedOpacity), + ); + expect(animatedOpacity.opacity, equals(0.0)); + }); + + testWidgets('should start auto scroll for multiple images', + (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: Scaffold( + body: CustomScrollView( + slivers: [ + RoadsterAppBar( + roadster: mockRoadster, + images: mockImages, + scrollOffset: 0.0, + fadeController: fadeController, + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(ImageCarousel), findsOneWidget); + expect(find.byType(ImageIndicators), findsOneWidget); + }); + + testWidgets('should dispose controllers properly', + (WidgetTester tester) async { + await mockNetworkImagesFor( + () => tester.pumpWidget( + MaterialApp( + supportedLocales: appSupportedLocales, + localizationsDelegates: appLocalizationsDelegates, + home: Scaffold( + body: CustomScrollView( + slivers: [ + RoadsterAppBar( + roadster: mockRoadster, + images: mockImages, + scrollOffset: 0.0, + fadeController: fadeController, + ), + ], + ), + ), + ), + ), + ); + + // Remove the widget to trigger dispose + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + + // Verify no errors occurred during disposal + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/features/roadster/widget/background/animated_gradient_background_test.dart b/test/features/roadster/widget/background/animated_gradient_background_test.dart new file mode 100644 index 0000000..dce3cee --- /dev/null +++ b/test/features/roadster/widget/background/animated_gradient_background_test.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_gradient_background.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnimatedGradientBackground', () { + late AnimationController testController; + + setUp(() { + testController = AnimationController( + duration: const Duration(seconds: 2), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + testController.dispose(); + }); + + testWidgets('should render gradient container', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedGradientBackground( + pulseController: testController, + ), + ), + ); + + expect(find.byType(AnimatedBuilder), findsAtLeast(1)); + expect(find.byType(Container), findsOneWidget); + }); + + testWidgets('should animate gradient colors', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedGradientBackground( + pulseController: testController, + ), + ), + ); + + // Initial state + await tester.pump(); + + // Animate forward + unawaited(testController.forward()); + await tester.pump(const Duration(milliseconds: 500)); + + // Animate backward + unawaited(testController.reverse()); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify no errors during animation + expect(tester.takeException(), isNull); + }); + + testWidgets('should have gradient decoration', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedGradientBackground( + pulseController: testController, + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect(container.decoration, isA()); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + }); + }); +} diff --git a/test/features/roadster/widget/background/animated_stars_field_test.dart b/test/features/roadster/widget/background/animated_stars_field_test.dart new file mode 100644 index 0000000..2aa5078 --- /dev/null +++ b/test/features/roadster/widget/background/animated_stars_field_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_star_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/background/animated_stars_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnimatedStarsField', () { + late AnimationController testController; + + setUp(() { + testController = AnimationController( + duration: const Duration(seconds: 2), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + testController.dispose(); + }); + + testWidgets('should render stars field', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedStarsField( + pulseController: testController, + ), + ), + ); + + expect(find.byType(Stack), findsOneWidget); + expect(find.byType(AnimatedStarWidget), findsNWidgets(50)); + }); + + testWidgets('should pass pulse controller to each star', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedStarsField( + pulseController: testController, + ), + ), + ); + + final starWidgets = tester.widgetList( + find.byType(AnimatedStarWidget), + ); + + for (final star in starWidgets) { + expect(star.pulseController, equals(testController)); + } + }); + + testWidgets('should create 50 star widgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AnimatedStarsField( + pulseController: testController, + ), + ), + ); + + expect(find.byType(AnimatedStarWidget), findsNWidgets(50)); + + // Verify each star has a unique index + final starWidgets = tester.widgetList( + find.byType(AnimatedStarWidget), + ); + + final indices = starWidgets.map((star) => star.index).toSet(); + expect(indices.length, equals(50)); // All indices should be unique + }); + }); +} diff --git a/test/features/roadster/widget/buttons/track_live_button_test.dart b/test/features/roadster/widget/buttons/track_live_button_test.dart new file mode 100644 index 0000000..f3243b5 --- /dev/null +++ b/test/features/roadster/widget/buttons/track_live_button_test.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/buttons/track_live_button.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TrackLiveButton', () { + late AnimationController fadeController; + var pressedCount = 0; + + setUp(() { + fadeController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: const TestVSync(), + ); + pressedCount = 0; + }); + + tearDown(() { + fadeController.dispose(); + }); + + testWidgets('should render floating action button', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(ScaleTransition), findsAtLeast(1)); + }); + + testWidgets('should display rocket icon and track live text', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + + expect(find.byIcon(Icons.rocket_launch), findsOneWidget); + expect(find.byType(Text), findsOneWidget); + }); + + testWidgets('should be positioned at bottom right', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + + final positioned = tester.widget(find.byType(Positioned)); + expect(positioned.bottom, equals(20)); + expect(positioned.right, equals(20)); + }); + + testWidgets('should call onPressed when tapped', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + fadeController.value = 1.0; + await tester.pump(); + + final fabFinder = find.byType(FloatingActionButton); + await tester.tapAt(tester.getCenter(fabFinder)); + await tester.pumpAndSettle(); + expect(pressedCount, equals(1)); + }); + + testWidgets('should animate scale transition', (WidgetTester tester) async { + // Create a controller for this test + final fadeController = AnimationController( + vsync: tester, + duration: const Duration(milliseconds: 500), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + + // Start animation + unawaited(fadeController.forward()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify animation runs without errors + expect(tester.takeException(), isNull); + + // Dispose controller at the end + fadeController.dispose(); + }); + + testWidgets('should use primary color from theme', + (WidgetTester tester) async { + const primaryColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: primaryColor), + ), + home: Scaffold( + body: Stack( + children: [ + TrackLiveButton( + fadeController: fadeController, + onPressed: () => pressedCount++, + ), + ], + ), + ), + ), + ); + + final fab = tester.widget( + find.byType(FloatingActionButton), + ); + expect(fab.backgroundColor, isNotNull); + }); + }); +} diff --git a/test/features/roadster/widget/details_card_widget_test.dart b/test/features/roadster/widget/details_card_widget_test.dart new file mode 100644 index 0000000..e219915 --- /dev/null +++ b/test/features/roadster/widget/details_card_widget_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/model/mission.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/details_card_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DetailsCardWidget renders correctly', + (WidgetTester tester) async { + final missions = [ + Mission(name: 'Mission 1', isPrimary: true), + Mission(name: 'Mission 2', isPrimary: false), + ]; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Material( + child: DetailsCardWidget( + description1: 'This is description 1', + description2: 'This is description 2', + missions: missions, + ), + ), + ), + ); + + // Verify Card exists + expect(find.byType(Card), findsOneWidget); + + // Verify rotating Icon exists + expect(find.byIcon(Icons.satellite_alt), findsOneWidget); + + // Verify descriptions + expect(find.text('This is description 1'), findsOneWidget); + expect(find.text('This is description 2'), findsOneWidget); + + // Verify all mission chips exist + for (final mission in missions) { + expect(find.text(mission.name), findsOneWidget); + expect(find.byType(Chip), findsNWidgets(missions.length)); + } + + // Pump some duration to trigger animation + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/test/features/roadster/widget/distance_card_widget_test.dart b/test/features/roadster/widget/distance_card_widget_test.dart new file mode 100644 index 0000000..d7742af --- /dev/null +++ b/test/features/roadster/widget/distance_card_widget_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_card_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildTestableWidget(Widget child) { + return MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold(body: child), + ); + } + + late AnimationController controller; + + setUp(() { + // AnimationController needs a TickerProvider, we’ll fake it in tests. + }); + + testWidgets('renders title and millionKm text', (tester) async { + controller = AnimationController( + vsync: tester, + duration: const Duration(seconds: 1), + ); + + await tester.pumpWidget( + buildTestableWidget( + DistanceCardWidget( + title: 'Earth', + distance: 150000000.0, + // 150 million km + color: Colors.blue, + icon: Icons.public, + delay: 0, + pulseController: controller, + ), + ), + ); + + expect(find.text('Earth'), findsOneWidget); + expect(find.text(S.current.millionKm), findsOneWidget); + expect(find.byIcon(Icons.public), findsOneWidget); + }); + + testWidgets('pulse animation reacts to controller', (tester) async { + controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 1), + ); + + await tester.pumpWidget( + buildTestableWidget( + DistanceCardWidget( + title: 'Venus', + distance: 108000000, + color: Colors.orange, + icon: Icons.star, + delay: 0, + pulseController: controller, + ), + ), + ); + + // Initial pulse value + controller.value = 0.0; + await tester.pump(); + final initialContainer = tester.widget( + find + .byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration) + .last, + ); + final initialColor = (initialContainer.decoration as BoxDecoration).color; + + // Update pulse controller + controller.value = 1.0; + await tester.pump(); + + final updatedContainer = tester.widget( + find + .byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration) + .last, + ); + final updatedColor = (updatedContainer.decoration as BoxDecoration).color; + + expect(updatedColor != initialColor, true); + }); + + testWidgets('animates entry with opacity', (tester) async { + controller = AnimationController( + vsync: tester, + duration: const Duration(seconds: 1), + ); + + await tester.pumpWidget( + buildTestableWidget( + DistanceCardWidget( + title: 'Jupiter', + distance: 778000000, + color: Colors.brown, + icon: Icons.circle, + delay: 0, + pulseController: controller, + ), + ), + ); + + // Initially opacity may be < 1 + final initialOpacity = tester.widget(find.byType(Opacity)).opacity; + + // Advance animation + await tester.pump(const Duration(milliseconds: 1500)); + final finalOpacity = tester.widget(find.byType(Opacity)).opacity; + + expect(initialOpacity < finalOpacity, true); + expect(finalOpacity, equals(1)); + }); +} diff --git a/test/features/roadster/widget/distance_cards_test.dart b/test/features/roadster/widget/distance_cards_test.dart new file mode 100644 index 0000000..36f8d47 --- /dev/null +++ b/test/features/roadster/widget/distance_cards_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_card_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_cards.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart' hide TestVSync; + +import '../helpers/test_helpers.dart'; + +void main() { + group('DistanceCards', () { + late RoadsterResource mockRoadster; + late AnimationController pulseController; + + setUp(() { + mockRoadster = createMockRoadster(); + pulseController = AnimationController( + duration: const Duration(seconds: 2), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + pulseController.dispose(); + }); + + testWidgets('should render two distance cards', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: DistanceCards( + roadster: mockRoadster, + pulseController: pulseController, + ), + ), + ), + ); + + expect(find.byType(DistanceCardWidget), findsNWidgets(2)); + }); + + testWidgets('should display Earth and Mars distance cards', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: DistanceCards( + roadster: mockRoadster, + pulseController: pulseController, + ), + ), + ), + ); + + final distanceCards = tester.widgetList( + find.byType(DistanceCardWidget), + ); + + // First card should be Earth distance + expect(distanceCards.first.color, equals(Colors.blue)); + expect(distanceCards.first.icon, equals(Icons.public)); + + // Second card should be Mars distance + expect(distanceCards.last.color, equals(Colors.orange)); + expect(distanceCards.last.icon, equals(Icons.circle)); + }); + + testWidgets('should pass pulse controller to cards', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: DistanceCards( + roadster: mockRoadster, + pulseController: pulseController, + ), + ), + ), + ); + + final distanceCards = tester.widgetList( + find.byType(DistanceCardWidget), + ); + + for (final card in distanceCards) { + expect(card.pulseController, equals(pulseController)); + } + }); + }); +} diff --git a/test/features/roadster/widget/launch_details_section_test.dart b/test/features/roadster/widget/launch_details_section_test.dart new file mode 100644 index 0000000..027b53a --- /dev/null +++ b/test/features/roadster/widget/launch_details_section_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_details_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_section_widget.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('LaunchDetailsSection', () { + late RoadsterResource mockRoadster; + + setUp(() { + mockRoadster = createMockRoadster(); + }); + + testWidgets('should render launch section widget', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: LaunchDetailsSection(roadster: mockRoadster), + ), + ), + ); + + expect(find.byType(LaunchSectionWidget), findsOneWidget); + }); + + testWidgets('should pass mass and vehicle information', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: LaunchDetailsSection(roadster: mockRoadster), + ), + ), + ); + + final launchSection = tester.widget( + find.byType(LaunchSectionWidget), + ); + + expect( + launchSection.massKg, contains(mockRoadster.launchMassKg.toString())); + expect(launchSection.vehicle, equals('Falcon Heavy')); + }); + }); +} diff --git a/test/features/roadster/widget/launch_section_widget_test.dart b/test/features/roadster/widget/launch_section_widget_test.dart new file mode 100644 index 0000000..cdb7ff7 --- /dev/null +++ b/test/features/roadster/widget/launch_section_widget_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_section_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('LaunchSectionWidget renders correctly', + (WidgetTester tester) async { + const testMass = '10,000 kg'; + const testVehicle = 'Falcon 9'; + + await tester.pumpWidget( + const MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: LaunchSectionWidget( + massKg: testMass, + vehicle: testVehicle, + ), + ), + ), + ); + + // Verify Card exists + expect(find.byType(Card), findsOneWidget); + + // Verify Icon + expect(find.byIcon(Icons.rocket), findsOneWidget); + + // Verify headline text + final l10n = S.of(tester.element(find.byType(LaunchSectionWidget))); + expect(find.text(l10n.launchInformation), findsOneWidget); + + // Verify mass label and value + expect(find.text(l10n.launchMass), findsOneWidget); + expect(find.text(testMass), findsOneWidget); + + // Verify vehicle label and value + expect(find.text(l10n.launchVehicle), findsOneWidget); + expect(find.text(testVehicle), findsOneWidget); + }); +} diff --git a/test/features/roadster/widget/links_section_widget_test.dart b/test/features/roadster/widget/links_section_widget_test.dart new file mode 100644 index 0000000..aef473f --- /dev/null +++ b/test/features/roadster/widget/links_section_widget_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/links_section_widget.dart'; +import 'package:flutter_bloc_app_template/generated/l10n.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildTestableWidget(Widget child) { + return MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold(body: child), + ); + } + + testWidgets('renders learnMore text', (WidgetTester tester) async { + await tester.pumpWidget(buildTestableWidget(const LinksSectionWidget())); + + expect(find.text(S.current.learnMore), findsOneWidget); + }); + + testWidgets('renders wikipedia button with icon', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestableWidget(const LinksSectionWidget())); + + expect(find.text(S.current.wikipedia), findsOneWidget); + expect(find.byIcon(Icons.article), findsOneWidget); + }); + + testWidgets('renders watchVideo button with icon', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestableWidget(const LinksSectionWidget())); + + expect(find.text(S.current.watchVideo), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('buttons are tappable', (WidgetTester tester) async { + await tester.pumpWidget(buildTestableWidget(const LinksSectionWidget())); + + final wikipediaButton = find.text(S.current.wikipedia); + final watchVideoButton = find.text(S.current.watchVideo); + + await tester.tap(wikipediaButton); + await tester.pump(); + + await tester.tap(watchVideoButton); + await tester.pump(); + + // Since your onPressed handlers are empty, we just ensure taps don’t throw. + }); +} diff --git a/test/features/roadster/widget/mission_details_card_test.dart b/test/features/roadster/widget/mission_details_card_test.dart new file mode 100644 index 0000000..5881c4e --- /dev/null +++ b/test/features/roadster/widget/mission_details_card_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/details_card_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/mission_details_card.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart' hide TestVSync; + +import '../helpers/test_helpers.dart'; + +void main() { + group('MissionDetailsCard', () { + late RoadsterResource mockRoadster; + late AnimationController slideController; + + setUp(() { + mockRoadster = createMockRoadster(); + slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + slideController.dispose(); + }); + + testWidgets('should render slide transition with details card', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: MissionDetailsCard( + roadster: mockRoadster, + slideController: slideController, + ), + ), + ), + ); + + expect(find.byType(SlideTransition), findsOneWidget); + expect(find.byType(DetailsCardWidget), findsOneWidget); + }); + + testWidgets('should pass roadster details to card widget', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: MissionDetailsCard( + roadster: mockRoadster, + slideController: slideController, + ), + ), + ), + ); + + final detailsCard = tester.widget( + find.byType(DetailsCardWidget), + ); + expect(detailsCard.description1, equals(mockRoadster.details)); + }); + + testWidgets('should animate slide transition', (WidgetTester tester) async { + // Create controller for this test + final slideController = AnimationController( + vsync: tester, + duration: const Duration(milliseconds: 400), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: MissionDetailsCard( + roadster: mockRoadster, + slideController: slideController, + ), + ), + ), + ); + + // Start animation + unawaited(slideController.forward()); + await tester.pump(); // start first frame + await tester + .pump(const Duration(milliseconds: 400)); // complete animation + + // Verify animation runs without errors + expect(tester.takeException(), isNull); + + // Dispose controller at the end + slideController.dispose(); + }); + }); +} diff --git a/test/features/roadster/widget/orbital_parameters_section_test.dart b/test/features/roadster/widget/orbital_parameters_section_test.dart new file mode 100644 index 0000000..9b1a064 --- /dev/null +++ b/test/features/roadster/widget/orbital_parameters_section_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/orbital_parameters_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/orbital_section_widget.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('OrbitalParametersSection', () { + late RoadsterResource mockRoadster; + + setUp(() { + mockRoadster = createMockRoadster(); + }); + + testWidgets('should render orbital section widget', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: OrbitalParametersSection(roadster: mockRoadster), + ), + ), + ); + + expect(find.byType(OrbitalSectionWidget), findsOneWidget); + }); + + testWidgets('should pass six orbital data parameters', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: OrbitalParametersSection(roadster: mockRoadster), + ), + ), + ); + + final orbitalSection = tester.widget( + find.byType(OrbitalSectionWidget), + ); + + expect(orbitalSection.orbitalData.length, equals(6)); + }); + }); +} diff --git a/test/features/roadster/widget/roadster_content_test.dart b/test/features/roadster/widget/roadster_content_test.dart new file mode 100644 index 0000000..305cbf2 --- /dev/null +++ b/test/features/roadster/widget/roadster_content_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/distance_cards.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/launch_details_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/links_section_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/mission_details_card.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/orbital_parameters_section.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/roadster_content.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/speed_distance_cards.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart' hide TestVSync; + +import '../helpers/test_helpers.dart'; + +void main() { + group('RoadsterContent', () { + late RoadsterResource mockRoadster; + late AnimationController slideController; + late AnimationController pulseController; + + setUp(() { + mockRoadster = createMockRoadster(); + slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: const TestVSync(), + ); + pulseController = AnimationController( + duration: const Duration(seconds: 2), + vsync: const TestVSync(), + ); + }); + + tearDown(() { + slideController.dispose(); + pulseController.dispose(); + }); + + testWidgets('should render all content sections', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: RoadsterContent( + roadster: mockRoadster, + slideController: slideController, + pulseController: pulseController, + ), + ), + ), + ); + + expect(find.byType(MissionDetailsCard), findsOneWidget); + expect(find.byType(SpeedDistanceCards), findsOneWidget); + expect(find.byType(DistanceCards), findsOneWidget); + expect(find.byType(OrbitalParametersSection), findsOneWidget); + expect(find.byType(LaunchDetailsSection), findsOneWidget); + expect(find.byType(LinksSectionWidget), findsOneWidget); + }); + + testWidgets('should have proper spacing between sections', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: RoadsterContent( + roadster: mockRoadster, + slideController: slideController, + pulseController: pulseController, + ), + ), + ), + ); + + final columnFinder = find.descendant( + of: find.byType(RoadsterContent), + matching: find.byType(Column), + ); + + final column = + tester.widget(columnFinder.first); // first Column only + + final sizedBoxes = column.children.whereType().toList(); + + // Verify spacing between sections + expect(sizedBoxes.length, greaterThan(5)); + }); + + testWidgets('should pass correct controllers to child widgets', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: RoadsterContent( + roadster: mockRoadster, + slideController: slideController, + pulseController: pulseController, + ), + ), + ), + ); + + final missionCard = tester.widget( + find.byType(MissionDetailsCard), + ); + expect(missionCard.slideController, equals(slideController)); + + final distanceCards = tester.widget( + find.byType(DistanceCards), + ); + expect(distanceCards.pulseController, equals(pulseController)); + }); + }); +} diff --git a/test/features/roadster/widget/speed_distance_cards_test.dart b/test/features/roadster/widget/speed_distance_cards_test.dart new file mode 100644 index 0000000..a2b3488 --- /dev/null +++ b/test/features/roadster/widget/speed_distance_cards_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc_app_template/app/localization.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/animated_stat_card_widget.dart'; +import 'package:flutter_bloc_app_template/features/roadster/widget/speed_distance_cards.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('SpeedDistanceCards', () { + late RoadsterResource mockRoadster; + + setUp(() { + mockRoadster = createMockRoadster(); + }); + + testWidgets('should display speed and orbital period cards', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: SpeedDistanceCards(roadster: mockRoadster), + ), + ), + ); + + final statCards = tester.widgetList( + find.byType(AnimatedStatCardWidget), + ); + + // First card should be speed + expect(statCards.first.icon, equals(Icons.speed)); + expect(statCards.first.color, equals(Colors.blue)); + + // Second card should be orbital period + expect(statCards.last.icon, equals(Icons.timer_outlined)); + expect(statCards.last.color, equals(Colors.purple)); + }); + + testWidgets('should display formatted values from roadster data', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: appLocalizationsDelegates, + supportedLocales: appSupportedLocales, + home: Scaffold( + body: SpeedDistanceCards(roadster: mockRoadster), + ), + ), + ); + + final statCards = tester.widgetList( + find.byType(AnimatedStatCardWidget), + ); + + // Verify values are formatted properly (or show N/A if null) + expect(statCards.first.value, isNotNull); + expect(statCards.last.value, isNotNull); + }); + }); +} diff --git a/test/mocks.dart b/test/mocks.dart index 15c4501..7164dc8 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/roadster_repository.dart'; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart'; import 'package:mockito/annotations.dart'; @@ -9,6 +10,7 @@ export 'mocks.mocks.dart'; EmailListRepository, LaunchesRepository, RocketRepository, + RoadsterRepository, ], customMocks: [ MockSpec(onMissingStub: OnMissingStub.returnDefault) ]) diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 2b89561..a21817a 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -3,13 +3,17 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; +import 'dart:async' as _i4; -import 'package:flutter/src/widgets/navigator.dart' as _i6; +import 'package:flutter/src/widgets/navigator.dart' as _i8; import 'package:flutter_bloc_app_template/index.dart' as _i2; -import 'package:flutter_bloc_app_template/models/email.dart' as _i4; +import 'package:flutter_bloc_app_template/models/email.dart' as _i5; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart' + as _i3; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart' + as _i7; import 'package:flutter_bloc_app_template/repository/rocket_repository.dart' - as _i5; + as _i6; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -48,6 +52,17 @@ class _FakeRocketResource_1 extends _i1.SmartFake ); } +class _FakeRoadsterResource_2 extends _i1.SmartFake + implements _i3.RoadsterResource { + _FakeRoadsterResource_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [EmailListRepository]. /// /// See the documentation for Mockito's code generation for more information. @@ -58,13 +73,13 @@ class MockEmailListRepository extends _i1.Mock } @override - _i3.Future> loadData() => (super.noSuchMethod( + _i4.Future> loadData() => (super.noSuchMethod( Invocation.method( #loadData, [], ), - returnValue: _i3.Future>.value(<_i4.Email>[]), - ) as _i3.Future>); + returnValue: _i4.Future>.value(<_i5.Email>[]), + ) as _i4.Future>); } /// A class which mocks [LaunchesRepository]. @@ -77,7 +92,7 @@ class MockLaunchesRepository extends _i1.Mock } @override - _i3.Future> getLaunches({ + _i4.Future> getLaunches({ bool? hasId = true, int? limit, int? offset, @@ -99,37 +114,37 @@ class MockLaunchesRepository extends _i1.Mock }, ), returnValue: - _i3.Future>.value(<_i2.LaunchResource>[]), - ) as _i3.Future>); + _i4.Future>.value(<_i2.LaunchResource>[]), + ) as _i4.Future>); @override - _i3.Future<_i2.LaunchFullResource> getLaunch(int? flightNumber) => + _i4.Future<_i2.LaunchFullResource> getLaunch(int? flightNumber) => (super.noSuchMethod( Invocation.method( #getLaunch, [flightNumber], ), returnValue: - _i3.Future<_i2.LaunchFullResource>.value(_FakeLaunchFullResource_0( + _i4.Future<_i2.LaunchFullResource>.value(_FakeLaunchFullResource_0( this, Invocation.method( #getLaunch, [flightNumber], ), )), - ) as _i3.Future<_i2.LaunchFullResource>); + ) as _i4.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 _i5.RocketRepository { +class MockRocketRepository extends _i1.Mock implements _i6.RocketRepository { MockRocketRepository() { _i1.throwOnMissingStub(this); } @override - _i3.Future> getRockets({ + _i4.Future> getRockets({ bool? hasId = true, int? limit, int? offset, @@ -145,34 +160,60 @@ class MockRocketRepository extends _i1.Mock implements _i5.RocketRepository { }, ), returnValue: - _i3.Future>.value(<_i2.RocketResource>[]), - ) as _i3.Future>); + _i4.Future>.value(<_i2.RocketResource>[]), + ) as _i4.Future>); @override - _i3.Future<_i2.RocketResource> getRocket(String? rocketId) => + _i4.Future<_i2.RocketResource> getRocket(String? rocketId) => (super.noSuchMethod( Invocation.method( #getRocket, [rocketId], ), - returnValue: _i3.Future<_i2.RocketResource>.value(_FakeRocketResource_1( + returnValue: _i4.Future<_i2.RocketResource>.value(_FakeRocketResource_1( this, Invocation.method( #getRocket, [rocketId], ), )), - ) as _i3.Future<_i2.RocketResource>); + ) as _i4.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 { + MockRoadsterRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i3.RoadsterResource> getRoadster() => (super.noSuchMethod( + Invocation.method( + #getRoadster, + [], + ), + returnValue: + _i4.Future<_i3.RoadsterResource>.value(_FakeRoadsterResource_2( + this, + Invocation.method( + #getRoadster, + [], + ), + )), + ) as _i4.Future<_i3.RoadsterResource>); } /// A class which mocks [NavigatorObserver]. /// /// See the documentation for Mockito's code generation for more information. -class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { +class MockNavigatorObserver extends _i1.Mock implements _i8.NavigatorObserver { @override void didPush( - _i6.Route? route, - _i6.Route? previousRoute, + _i8.Route? route, + _i8.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -187,8 +228,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { @override void didPop( - _i6.Route? route, - _i6.Route? previousRoute, + _i8.Route? route, + _i8.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -203,8 +244,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { @override void didRemove( - _i6.Route? route, - _i6.Route? previousRoute, + _i8.Route? route, + _i8.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( @@ -219,8 +260,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { @override void didReplace({ - _i6.Route? newRoute, - _i6.Route? oldRoute, + _i8.Route? newRoute, + _i8.Route? oldRoute, }) => super.noSuchMethod( Invocation.method( @@ -236,8 +277,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { @override void didChangeTop( - _i6.Route? topRoute, - _i6.Route? previousTopRoute, + _i8.Route? topRoute, + _i8.Route? previousTopRoute, ) => super.noSuchMethod( Invocation.method( @@ -252,8 +293,8 @@ class MockNavigatorObserver extends _i1.Mock implements _i6.NavigatorObserver { @override void didStartUserGesture( - _i6.Route? route, - _i6.Route? previousRoute, + _i8.Route? route, + _i8.Route? previousRoute, ) => super.noSuchMethod( Invocation.method( diff --git a/test/models/roadster/roadster_ext_test.dart b/test/models/roadster/roadster_ext_test.dart new file mode 100644 index 0000000..289254c --- /dev/null +++ b/test/models/roadster/roadster_ext_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_ext.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('NetworkRoadsterModel.toResource maps correctly', () { + final model = const NetworkRoadsterModel( + name: "Elon Musk's Tesla Roadster", + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchMassKg: 1350, + flickrImages: ['url1', 'url2'], + id: '123', + ); + + final resource = model.toResource(); + + expect(resource.name, model.name); + expect(resource.launchDateUtc, model.launchDateUtc); + expect(resource.launchMassKg, model.launchMassKg); + expect(resource.flickrImages, model.flickrImages); + expect(resource.id, model.id); + }); +} diff --git a/test/models/roadster/roadster_resource_test.dart b/test/models/roadster/roadster_resource_test.dart new file mode 100644 index 0000000..c178569 --- /dev/null +++ b/test/models/roadster/roadster_resource_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RoadsterResource', () { + test('can be instantiated with all fields', () { + final resource = const RoadsterResource( + name: "Elon Musk's Tesla Roadster", + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchDateUnix: 1517949900, + launchMassKg: 1350, + launchMassLbs: 2976, + noradId: 43205, + epochJd: 2459914.263888889, + orbitType: 'heliocentric', + apoapsisAu: 1.664332332453025, + periapsisAu: 0.986015924224046, + semiMajorAxisAu: 57.70686413577451, + eccentricity: 0.2559348215918733, + inclination: 1.075052357364693, + longitude: 316.9112133411523, + periapsisArg: 177.75981116285, + periodDays: 557.1958197697352, + speedKph: 9520.88362029108, + speedMph: 5916.000976023889, + earthDistanceKm: 320593735.82924163, + earthDistanceMi: 199207650.2259517, + marsDistanceKm: 395640511.90355814, + marsDistanceMi: 245839540.52202582, + flickrImages: [ + 'url1', + 'url2', + ], + wikipedia: 'https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster', + video: 'https://youtu.be/wbSwFU6tY1c', + details: 'Some details about Roadster', + id: '5eb75f0842fea42237d7f3f4', + ); + + expect(resource.name, "Elon Musk's Tesla Roadster"); + expect(resource.launchMassKg, 1350); + expect(resource.flickrImages?.length, 2); + }); + + test('supports equality', () { + final r1 = const RoadsterResource(name: 'Roadster', launchMassKg: 1350); + final r2 = const RoadsterResource(name: 'Roadster', launchMassKg: 1350); + final r3 = const RoadsterResource(name: 'Roadster', launchMassKg: 1400); + + expect(r1, r2); // same values -> equal + expect(r1 == r3, false); // different launchMassKg -> not equal + }); + + test('handles null values', () { + final resource = const RoadsterResource(); + + expect(resource.name, isNull); + expect(resource.launchMassKg, isNull); + expect(resource.flickrImages, isNull); + }); + }); +} diff --git a/test/repository/roadster_repository_impl_test.dart b/test/repository/roadster_repository_impl_test.dart new file mode 100644 index 0000000..85173a5 --- /dev/null +++ b/test/repository/roadster_repository_impl_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_bloc_app_template/data/network/api_result.dart'; +import 'package:flutter_bloc_app_template/data/network/data_source/roadster_network_data_source.dart'; +import 'package:flutter_bloc_app_template/data/network/model/roadster/network_roadster_model.dart'; +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRoadsterDataSource extends Mock implements RoadsterDataSource {} + +void main() { + late RoadsterRepositoryImpl repository; + late MockRoadsterDataSource mockDataSource; + + setUp(() { + mockDataSource = MockRoadsterDataSource(); + repository = RoadsterRepositoryImpl(mockDataSource); + }); + + group('RoadsterRepositoryImpl', () { + group('getRoadster', () { + test('returns RoadsterResource when success', () async { + final networkRoadster = const NetworkRoadsterModel( + name: "Elon Musk's Tesla Roadster", + ); + when(() => mockDataSource.getRoadster()).thenAnswer( + (_) async => ApiResult.success(networkRoadster), + ); + + final result = await repository.getRoadster(); + + expect(result, isA()); + expect(result.name, equals("Elon Musk's Tesla Roadster")); + }); + + test('throws Exception when error', () async { + when(() => mockDataSource.getRoadster()).thenAnswer( + (_) async => const ApiResult.error('Not found'), + ); + + expect( + () => repository.getRoadster(), + throwsA(isA()), + ); + }); + + test('throws Exception when loading', () async { + when(() => mockDataSource.getRoadster()).thenAnswer( + (_) async => const ApiResult.loading(), + ); + + expect( + () => repository.getRoadster(), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/test/repository/roadster_repository_test.dart b/test/repository/roadster_repository_test.dart new file mode 100644 index 0000000..8db2101 --- /dev/null +++ b/test/repository/roadster_repository_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_bloc_app_template/models/roadster/roadster_resource.dart'; +import 'package:flutter_bloc_app_template/repository/roadster_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../mocks.mocks.dart'; + +void main() { + group('Roadster Repository Tests', () { + late RoadsterRepository repository; + + setUp(() { + repository = MockRoadsterRepository(); + }); + + group('getRoadster', () { + test('returns roadster', () { + when(repository.getRoadster()) + .thenAnswer((_) => Future.value(mockRoadsterResource)); + expect( + repository.getRoadster(), + completion(equals(mockRoadsterResource)), + ); + }); + test('returns error', () { + when(repository.getRoadster()).thenAnswer((_) => Future.error(Error())); + + expect( + repository.getRoadster(), + throwsA(isA()), + ); + }); + }); + }); +} + +final mockRoadsterResource = const RoadsterResource( + name: "Elon Musk's Tesla Roadster", + launchDateUtc: '2018-02-06T20:45:00.000Z', + launchDateUnix: 1517949900, + launchMassKg: 1350, + launchMassLbs: 2976, + noradId: 43205, + epochJd: 2459914.263888889, + orbitType: 'heliocentric', + apoapsisAu: 1.664332332453025, + periapsisAu: 0.986015924224046, + semiMajorAxisAu: 57.70686413577451, + eccentricity: 0.2559348215918733, + inclination: 1.075052357364693, + longitude: 316.9112133411523, + periapsisArg: 177.75981116285, + periodDays: 557.1958197697352, + speedKph: 9520.88362029108, + speedMph: 5916.000976023889, + earthDistanceKm: 320593735.82924163, + earthDistanceMi: 199207650.2259517, + marsDistanceKm: 395640511.90355814, + marsDistanceMi: 245839540.52202582, + flickrImages: [ + 'https://farm5.staticflickr.com/4615/40143096241_11128929df_b.jpg', + 'https://farm5.staticflickr.com/4702/40110298232_91b32d0cc0_b.jpg', + 'https://farm5.staticflickr.com/4676/40110297852_5e794b3258_b.jpg', + 'https://farm5.staticflickr.com/4745/40110304192_6e3e9a7a1b_b.jpg' + ], + wikipedia: 'https://en.wikipedia.org/wiki/Elon_Musk%27s_Tesla_Roadster', + video: 'https://youtu.be/wbSwFU6tY1c', + details: "Elon Musk's Tesla Roadster is an electric sports car that served as" + ' the dummy payload for the February 2018 Falcon Heavy test flight' + ' and is now an artificial satellite of the Sun.', + id: '5eb75f0842fea42237d7f3f4', +);