Halo semuanya, tahap selanjutnya, ini kita akan mengimplementasikan MovieContract
yang ada ada pada lapisan Domain
ke dalam MovieRepository
yang ada pada lapisan Data
. Tapi untuk menampung datanya kita harus membuat model terlebih dahulu.
Sebelum kita mulai, agar lebih gampang binding
antara interface / abstract class
dengan implementasi nya, kita perlu menggunakan dependency injection
. Pada flutter, kita bisa menggunakan get_it
dan injectable
.
Memasang Dependency Injection.
Untuk di
kita akan menggunakan package get_it
dan injectable
. Kamu bisa menambahkan ini pada pubspec.yaml
.
dependencies:
# add injectable to your dependencies
injectable: ^1.5.2
# add get_it
get_It: ^7.2.0
dev_dependencies:
# add the generator to your dev_dependencies
injectable_generator: ^1.5.2
Setelah ditambahkan, lalu jalankan perintah:
$ flutter pub get
Inisialisasi Dependency Injection
Pertama kita buat file lib/injector.dart
.
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:movie_flutter/injector.config.dart';
final getIt = GetIt.instance;
@InjectableInit(generateForDir: ['lib', 'test'])
void configureDependencies() => $initGetIt(getIt);
Kemudian jalankan build_runner
$ flutter pub run build_runer build --delete-conflicting-outputs
Selanjutnya pada file lib/bootstrap.dart
tambahkan pada method bootstrap()
.
Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
WidgetsFlutterBinding.ensureInitialized();
configureDependencies();
...
...
}
Jangan lupa tambahkan **.config.dart
ke dalam .gitignore
.
Dan Setup di
telah selesai.
Menambahkan anotasi pada setiap usecase.
Tambahkan anotasi @injectable
pada setiap usecase
yang ada. Dan jalankan kembali build_runner
nya.
Contoh:
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:injectable/injectable.dart';
import 'package:movie_flutter/core/domain/failures/failure.dart';
import 'package:movie_flutter/core/domain/use_case.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/search_query.dart';
import 'package:movie_flutter/features/movie/domain/repositories/movie_contract.dart';
@injectable
class SearchMovies extends UseCase<List<Movie>, SearchMoviesParams> {
SearchMovies(this._contract);
final MovieContract _contract;
@override
Future<Either<Failure, List<Movie>>> call(SearchMoviesParams params) async {
return _contract.searchMovies(params.searchQuery);
}
}
Dan jalankan build_runner
.
$ flutter pub run build_runner build --delete-conflicting-outputs
Membuat Model pada layer Data.
Pada layer data, model bertugas untuk menampung data yang diambil dari eksternal baik berupa REST API maupun dari DATABASE. Oleh sebab itu, model biasanya hanya berupa tipe data primitif ataupun class lain yang di dalamnya juga berisi tipe data primitif. Untuk convension biasanya saya membedakan menjadi 2 yaitu Request
dan DTO
.
Untuk membuat Request
atau DTO
kita akan mengirim json
dan menerima json
, oleh sebab itu kita membutuhkan library untuk mengkonversi dari dan ke json
, ayo kita tambahkan library json_serializable
.
dependencies:
json_serializable: ^6.1.5
Langsung kita mulai saja pembuatan model nya.
lib/features/movie/data/models/genre_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
part 'genre_dto.freezed.dart';
part 'genre_dto.g.dart';
@freezed
class ResultGenreDto with _$ResultGenreDto {
const factory ResultGenreDto({required List<GenreDto>? genres}) =
_ResultGenreDto;
factory ResultGenreDto.fromJson(Map<String, dynamic> json) =>
_$ResultGenreDtoFromJson(json);
}
extension ResultGenreDtoX on ResultGenreDto {
List<Genre> toDomain() => genres!.map((e) => e.toDomain()).toList();
}
@freezed
class GenreDto with _$GenreDto {
const factory GenreDto({
required int? id,
required String? name,
}) = _GenreDto;
factory GenreDto.fromJson(Map<String, dynamic> json) =>
_$GenreDtoFromJson(json);
}
extension GenreDtoX on GenreDto {
Genre toDomain() => Genre(
id: UniqueId(id),
name: StringSingleLine(name),
);
}
Karena kita membutuhkan testing untuk parsing dari Json
maka kita akan membuat sebuah helper
untuk parsing file json
menjadi String
.
test/helpers/fixture_reader.dart
import 'dart:io';
String fixture(String path, String name) =>
File('$path/$name').readAsStringSync();
Lalu untuk lokasi penyimpanan file json
akan kita simpan di test/fixtures
.
test/features/movie/data/model/genre_dto_test.dart
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/data/models/genre_dto.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import '../../../../helpers/fixture_reader.dart';
void main() {
late GenreDto genreDto;
late Genre genre;
late List<Genre> listOfGenre;
setUp(() {
genreDto = const GenreDto(id: 28, name: 'Action');
genre = Genre(id: UniqueId(28), name: StringSingleLine('Action'));
listOfGenre = [genre];
});
group('genre_dto entity', () {
test(
'should return GenreDto after calling fromJson method',
() async {
// arrange
final rawString = fixture('movie', 'genre.json');
// act
final result = ResultGenreDto.fromJson(
jsonDecode(rawString) as Map<String, dynamic>,
);
// assert
expect(result.genres?.first, genreDto);
expect(result.genres?.first.id, genreDto.id);
expect(result.genres?.first.name, genreDto.name);
},
);
test(
'should return Genre when calling toDomain method',
() async {
// arrange
final rawString = fixture('movie', 'genre.json');
// act
final result = ResultGenreDto.fromJson(
jsonDecode(rawString) as Map<String, dynamic>,
);
final domain = result.toDomain();
// assert
expect(domain.first, listOfGenre.first);
expect(domain.first.name, listOfGenre.first.name);
expect(domain.first.id, listOfGenre.first.id);
},
);
});
}
Dimana fixture_reader.dart
merupakan helper yang kita gunakan untuk memparsing json response yg kita simpan menjadi String
.
test/helpers/fixture_reader.dart
import 'dart:io';
String fixture(String path, String name) =>
File('test/fixtures/$path/$name').readAsStringSync();
Dan contoh-contoh json telah didapatkan dari testing hit API TMDB menggunakan Postman.
- Trending Movie All Week
- Search Movie by Keyword
- Movie Genre
- Movie by Genre
- Movie Detail.
Semua hasil response kita simpan ke dalam masing-masing file json
di dalam folder test/fixtures/movie
Beberapa endpoint mempunyai hasil response yang sama, yaitu MovieListDto
yang terdapat pada use case:
- Trending Movie
- Search Movie
- List Movie by Genre
Oleh sebab itu untu test nya akan kita satukan saja.
test/features/movie/data/models/movie_list_dto_test.dart
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/core/data/models/base_response.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/data/models/movie_list_dto.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import '../../../../helpers/fixture_reader.dart';
void main() {
late MovieListDto movieListDtoMatcher;
late Movie movieMatcher;
setUp(() {
movieListDtoMatcher = const MovieListDto(
id: 634649,
overview:
'Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to discover what it truly means to be Spider-Man.',
releaseDate: '2021-12-15',
genreIds: [28, 12, 878],
voteAverage: 8.2,
posterPath: '/1g0dhYtq4irTY1GPXvft6k4YLjm.jpg',
video: false,
title: 'Spider-Man: No Way Home',
mediaType: 'movie',
);
movieMatcher = Movie(
id: UniqueId(movieListDtoMatcher.id),
title: Title(movieListDtoMatcher.title),
poster: Poster(movieListDtoMatcher.posterPath),
releaseDate: DateTime.parse(movieListDtoMatcher.releaseDate!),
rating: Rating(movieListDtoMatcher.voteAverage),
trailer: Trailer(null),
);
});
group('MovieListDto entity', () {
test(
'should return BaseResponse<List<MovieListDto>> when called fromJson',
() async {
// arrange
final rawTrending = fixture('movie', 'trending.json');
final jsonDecoded = jsonDecode(rawTrending) as Map<String, dynamic>;
// act
final result =
BaseResponse<List<MovieListDto>>.fromJson(jsonDecoded, (x) {
final result = x as List<dynamic>?;
return result!
.map(
(dynamic each) => MovieListDto.fromJson(
each as Map<String, dynamic>,
),
)
.toList();
});
// assert
expect(result.results?.first, movieListDtoMatcher);
},
);
});
test(
'should return Movie when toDomain',
() async {
// arrange
final rawTrending = fixture('movie', 'trending.json');
final jsonDecoded = jsonDecode(rawTrending) as Map<String, dynamic>;
final dtos = BaseResponse<List<MovieListDto>>.fromJson(jsonDecoded, (x) {
final result = x as List<dynamic>?;
return result!
.map(
(dynamic each) => MovieListDto.fromJson(
each as Map<String, dynamic>,
),
)
.toList();
});
// act
final result = dtos.results?.map((e) => e.toDomain());
// assert
expect(result?.first, movieMatcher);
},
);
}
lib/core/data/models/base_response.dart
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'base_response.g.dart';
@JsonSerializable(
genericArgumentFactories: true,
)
class BaseResponse<T> extends Equatable {
const BaseResponse({
required this.page,
required this.results,
required this.totalPages,
required this.totalResults,
});
factory BaseResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) {
return _$BaseResponseFromJson<T>(json, fromJsonT);
}
final int? page;
final T? results;
@JsonKey(name: 'total_pages')
final int? totalPages;
@JsonKey(name: 'total_results')
final int? totalResults;
@override
List<Object?> get props => [
page,
results,
totalPages,
totalResults,
];
}
lib/features/movie/data/models/movie_list_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
part 'movie_list_dto.freezed.dart';
part 'movie_list_dto.g.dart';
@freezed
class MovieListDto with _$MovieListDto {
const factory MovieListDto({
required int? id,
required String? overview,
@JsonKey(name: 'release_date') required String? releaseDate,
@JsonKey(name: 'genre_ids') required List<int>? genreIds,
@JsonKey(name: 'vote_average') required double? voteAverage,
@JsonKey(name: 'poster_path') required String? posterPath,
required bool? video,
required String? title,
@JsonKey(name: 'media_type') required String? mediaType,
}) = _MovieListDto;
factory MovieListDto.fromJson(Map<String, dynamic> json) =>
_$MovieListDtoFromJson(json);
}
extension MovieListDtoX on MovieListDto {
Movie toDomain() => Movie(
title: Title(title),
releaseDate: DateTime.parse(releaseDate!),
trailer: Trailer(null),
id: UniqueId(id),
rating: Rating(voteAverage),
poster: Poster(posterPath),
);
}
Berikutnya kita akan membuat test dan implementasi dari MovieDetailDto
test/features/movie/data/models/movie_detail_dto_test.dart
// ignore_for_file: lines_longer_than_80_chars
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/core/data/models/base_response.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/data/models/genre_dto.dart';
import 'package:movie_flutter/features/movie/data/models/movie_detail_dto.dart';
import 'package:movie_flutter/features/movie/data/models/movie_list_dto.dart';
import 'package:movie_flutter/features/movie/data/models/video_dto.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie_detail.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
import '../../../../helpers/fixture_reader.dart';
void main() {
late MovieDetailDto movieDetailDtoMatcher;
late List<GenreDto> genreDtoListMatcher;
late VideoDto videoDtoMatcher;
late List<VideoResultDto> videoResultDtoMatcher;
late MovieListDto similarMatcher;
late MovieDetail movieDetailMatcher;
setUp(() {
genreDtoListMatcher = const [
GenreDto(id: 28, name: 'Action'),
GenreDto(id: 12, name: 'Adventure'),
GenreDto(id: 35, name: 'Comedy'),
GenreDto(id: 878, name: 'Science Fiction')
];
videoResultDtoMatcher = const [
VideoResultDto(
name: 'Official Trailer',
key: 'IE8HIsIrq4o',
site: 'YouTube',
type: 'Trailer',
isOfficial: true,
)
];
videoDtoMatcher = VideoDto(results: videoResultDtoMatcher);
similarMatcher = const MovieListDto(
id: 634649,
overview:
'Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to discover what it truly means to be Spider-Man.',
releaseDate: '2021-12-15',
genreIds: [28, 12, 878],
voteAverage: 8.2,
posterPath: '/1g0dhYtq4irTY1GPXvft6k4YLjm.jpg',
video: false,
title: 'Spider-Man: No Way Home',
mediaType: 'movie',
);
movieDetailDtoMatcher = MovieDetailDto(
genres: genreDtoListMatcher,
id: 696806,
title: 'The Adam Project',
overview:
'After accidentally crash-landing in 2022, time-traveling fighter pilot Adam Reed teams up with his 12-year-old self on a mission to save the future.',
posterPath: '/wFjboE0aFZNbVOF05fzrka9Fqyx.jpg',
releaseDate: '2022-03-11',
runtime: 106,
voteAverage: 7,
videos: videoDtoMatcher,
similar: BaseResponse<List<MovieListDto>>(
page: 1,
results: [similarMatcher],
totalPages: 500,
totalResults: 10000,
),
);
movieDetailMatcher = MovieDetail(
id: UniqueId(696806),
title: Title('The Adam Project'),
poster: Poster('/wFjboE0aFZNbVOF05fzrka9Fqyx.jpg'),
duration: const Duration(minutes: 106),
rating: Rating(7),
releaseDate: DateTime.parse('2022-03-11'),
genres: [
Genre(id: UniqueId(28), name: StringSingleLine('Action')),
Genre(id: UniqueId(12), name: StringSingleLine('Adventure')),
Genre(id: UniqueId(35), name: StringSingleLine('Comedy')),
Genre(id: UniqueId(878), name: StringSingleLine('Science Fiction'))
],
synopsis:
'After accidentally crash-landing in 2022, time-traveling fighter pilot Adam Reed teams up with his 12-year-old self on a mission to save the future.',
trailer: Trailer('https://www.youtube.com/watch?v=IE8HIsIrq4o'),
relatedMovies: [
Movie(
id: UniqueId(similarMatcher.id),
title: Title(similarMatcher.title),
poster: Poster(similarMatcher.posterPath),
releaseDate: DateTime.parse(similarMatcher.releaseDate!),
rating: Rating(similarMatcher.voteAverage),
trailer: Trailer(null),
)
],
);
});
group('MovieDetailDto model', () {
test(
'should return MovieDetailDto when called fromJson',
() async {
// arrange
final rawDetail = fixture('movie', 'movie_detail.json');
final jsonDecoded = jsonDecode(rawDetail) as Map<String, dynamic>;
// act
final result = MovieDetailDto.fromJson(jsonDecoded);
// assert
expect(result, movieDetailDtoMatcher);
},
);
});
test(
'should return MovieDetail when toDomain',
() async {
// arrange
final rawDetail = fixture('movie', 'movie_detail.json');
final jsonDecoded = jsonDecode(rawDetail) as Map<String, dynamic>;
final dtos = MovieDetailDto.fromJson(jsonDecoded);
// act
final result = dtos.toDomain();
// assert
expect(result, movieDetailMatcher);
},
);
}
lib/features/movie/data/models/movie_detail_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/data/models/base_response.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/data/models/genre_dto.dart';
import 'package:movie_flutter/features/movie/data/models/movie_list_dto.dart';
import 'package:movie_flutter/features/movie/data/models/video_dto.dart';
import 'package:movie_flutter/features/movie/domain/entities/movie_detail.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
part 'movie_detail_dto.freezed.dart';
part 'movie_detail_dto.g.dart';
@freezed
class MovieDetailDto with _$MovieDetailDto {
const factory MovieDetailDto({
required List<GenreDto>? genres,
required int? id,
required String? title,
required String? overview,
@JsonKey(name: 'poster_path') required String? posterPath,
@JsonKey(name: 'release_date') required String? releaseDate,
required int? runtime,
@JsonKey(name: 'vote_average') required double? voteAverage,
required VideoDto? videos,
required BaseResponse<List<MovieListDto>>? similar,
}) = _MovieDetailDto;
factory MovieDetailDto.fromJson(Map<String, dynamic> json) =>
_$MovieDetailDtoFromJson(json);
}
extension MovieDetailDtoX on MovieDetailDto {
MovieDetail toDomain() => MovieDetail(
releaseDate: DateTime.parse(releaseDate!),
rating: Rating(voteAverage),
synopsis: overview!,
title: Title(title),
poster: Poster(posterPath),
genres: genres!.map((e) => e.toDomain()).toList(),
duration: Duration(minutes: runtime!),
relatedMovies: similar!.results!.map((e) => e.toDomain()).toList(),
id: UniqueId(id),
trailer: Trailer(
'https://www.youtube.com/watch?v=${videos?.results?.where(
(element) =>
element.isOfficial! &&
element.type == 'Trailer' &&
(element.site == 'Youtube' ||
element.site == 'youtube' ||
element.site == 'YouTube'),
).first.key}',
),
);
}
Kita jalankan keseluruhan test nya.
Yeay semua test kita sukses, sampai jumpa di part 7 semuanyaa.