Membuat Aplikasi Movie dengan Flutter - Part 6: Data Layer Model

Kita akan membuat model untuk menampung data pada data layer. Tidak lupa kita buatkan juga unit test nya.

· 9 menit untuk membaca
Membuat Aplikasi Movie dengan Flutter - Part 6: Data Layer Model
Photo by Andrew M / Unsplash

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.

  1. Trending Movie All Week
  2. Search Movie by Keyword
  3. Movie Genre
  4. Movie by Genre
  5. Movie Detail.

Semua hasil response kita simpan ke dalam masing-masing file json di dalam folder test/fixtures/movie

Contoh struktur penyimpanan response.
Contoh hasil json.

Beberapa endpoint mempunyai hasil response yang sama, yaitu MovieListDto yang terdapat pada use case:

  1. Trending Movie
  2. Search Movie
  3. 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.

All tests passed!

Yeay semua test kita sukses, sampai jumpa di part 7 semuanyaa.