Selamat datang di bagian 3 dari perjalanan kita! Pada kesempatan ini, saya akan membahas tentang mengatur struktur project, core domain, menambahkan feature movie, dan membuat entities dari domain movie berdasarkan Class Diagram yang sudah kita buat pada part sebelumnya.

Membuat Aplikasi Movie dengan Flutter - Part 2 Analisis Domain
Kita akan melakukan analisis domain terhadap aplikasi yang akan dibuat, yaitu aplikasi Movie

Set up project structure

Kita mulai dengan membuat branch part3 di repository kita. Lalu kita jalankan perintah

$ flutter pub get

Lalu kita buat folder features di folder lib dan test

Buat folder features pada lib dan test

Setelah itu, kita akan membuat feature movie dengan klik kanan pada folter features dan pilih Bloc: New Feature. Hasilnya adalah struktur folder yang sesuai dengan architecture yang kita buat.

Membuat fitur baru pada folder lib dan test.

Selanjutnya, hapus isi dari folder bloc yang ada pada features/presentation/bloc. Lakukan pada folder lib dan test.

Lalu, kita hapus folder counter yang ada pada di lib dan test, dan bersihkan juga reference yang ada terhadap counter tersebut.

Dan setup struktur folder selesai.


Membuat Core Domain

Disini kita akan membuat beberapa file yang akan digunakan di berbagai tempat di program kita.

lib/core/domain/value_object.dart

import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/domain/error.dart';
import 'package:movie_flutter/core/domain/value_failure.dart';
import 'package:movie_flutter/core/domain/value_validator.dart';

// ignore: one_member_abstracts
abstract class IValidatable {
  bool isValid();
}

@immutable
abstract class ValueObject<T> implements IValidatable {
  const ValueObject();
  Either<ValueFailure<T>, T> get value;

  T getOrCrash() => value.fold((l) => throw UnexpectedValueError(l), id);

  T getOrElse(T dflt) => value.getOrElse(() => dflt);

  Either<ValueFailure<dynamic>, Unit> get failureOrUnit =>
      value.fold(left, (r) => right(unit));

  @override
  bool isValid() => value.isRight();

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is ValueObject<T> && other.value == value;
  }

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => 'Value($value)';
}

class UniqueId extends ValueObject<int> {
  factory UniqueId(int? input) {
    assert(input != null, 'id cannot be null');
    // karena inputan langsung berupa int, tidak ada yg perlu divalidasi
    return UniqueId._(right(input!));
  }
  const UniqueId._(this.value);
  @override
  final Either<ValueFailure<int>, int> value;
}

class StringSingleLine extends ValueObject<String> {
  factory StringSingleLine(String? input) {
    assert(input != null, 'input value cannot be null');
    return StringSingleLine._(
      validateStringNotEmpty(input!).flatMap(validateSingleLine),
    );
  }

  const StringSingleLine._(this.value);

  @override
  final Either<ValueFailure<String>, String> value;
}

lib/core/domain/value_validator.dart

import 'package:dartz/dartz.dart';
import 'package:movie_flutter/core/domain/value_failure.dart';

Either<ValueFailure<String>, String> validateStringNotEmpty(String input) {
  if (input.isEmpty) {
    return left(ValueFailure.empty(failedValue: input));
  } else {
    return right(input);
  }
}

Either<ValueFailure<String>, String> validateSingleLine(String input) {
  if (input.contains('\n')) {
    return left(ValueFailure.multiLine(failedValue: input));
  } else {
    return right(input);
  }
}

Either<ValueFailure<T>, T> validateNumberRange<T extends num>({
  required T minimum,
  required T maximum,
  required T number,
}) {
  if (number > maximum || number < minimum) {
    return left(
      ValueFailure.notInRange(
        failedValue: number,
        minimum: minimum,
        maximum: maximum,
      ),
    );
  } else {
    return right(number);
  }
}

lib/core/domain/value_failure.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'value_failure.freezed.dart';

@freezed
class ValueFailure<T> with _$ValueFailure<T> {
  const factory ValueFailure.empty({required T failedValue}) =
      ValueFailureEmpty<T>;
  const factory ValueFailure.multiLine({required T failedValue}) =
      ValueFailureMultiLine<T>;

  const factory ValueFailure.notInRange({
    required T failedValue,
    required T minimum,
    required T maximum,
  }) = ValueNotInRange<T>;
}

lib/core/domain/error.dart

import 'package:movie_flutter/core/domain/value_failure.dart';

class UnexpectedValueError extends Error {
  UnexpectedValueError(this.valueFailure);
  final ValueFailure valueFailure;

  @override
  String toString() {
    const explanation =
        'Encountered a ValueFailure at an unrecoverable point. Terminating.';
    return Error.safeToString('$explanation Failure was: $valueFailure');
  }
}

Setelah kita tambahkan beberapa file di atas, kita jalankan build_runner dengan perintah

# One time Run
flutter pub run build_runner build --delete-conflicting-outputs

# atau 

# Watch File change and rebuild
flutter pub run build runner watch --delete-conflicting-outputs

Lalu kita tambahkan file yang sudah di-generate dari analysis_options.yaml dan juga pada .gitignore.

include: package:very_good_analysis/analysis_options.2.4.0.yaml
linter:
  rules:
    public_member_api_docs: false
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"
    - lib/injector.config.dart
    - lib/env_config.dart
  errors:
    invalid_annotation_target: ignore
**.freezed.dart
**.g.dart

Maka setup core domain selesai.


Test-Driven Development

Sekarang kita akan membuat entity berdasarkan prinsip TDD.

Pertama kita buat di folder test/features/movie/domain/entities file genre_test.dart.

import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/genre.dart';

void main() {
  group('genre entity', () {
    test('create with full value', () {
      final genre = Genre(
        id: UniqueId(1),
        name: StringSingleLine('Action'),
      );

      expect(genre.id.getOrCrash(), 1);
      expect(genre.name.getOrCrash(), 'Action');
    });
  });
}

Kita akan mendapatkan error yang perlu kita perbaiki.

Pertama kita buat dulu class Genre pada folder lib/features/movie/domain/entities.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/domain/value_object.dart';

part 'genre.freezed.dart';

@freezed
class Genre with _$Genre {
  const factory Genre({
    required UniqueId id,
    required StringSingleLine name,
  }) = _Genre;
}

Dan kita jalankat test nya dan akan mendapatkan hasil:

Test genre success

Selanjutnya kita akan membuat test untuk Movie entity.

import 'package:flutter_test/flutter_test.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';

void main() {
  group('movie entity', () {
    test('create with full value', () {
      //prepare
      final now = DateTime.now();
      final movie = Movie(
        id: UniqueId(1),
        title: Title('Hero'),
        rating: Rating(4.5),
        poster: Poster('/2d'),
        releaseDate: now,
        trailer: Trailer('https://youtube.com/watch?v=29wadk2ll1302'),
      );
      //act

      //validate
      expect(movie.id.getOrCrash(), 1);
      expect(movie.title.getOrCrash(), 'Hero');
      expect(movie.rating.getOrCrash(), 4.5);
      expect(movie.poster.getOrCrash(), 'https://image.tmdb.org/t/p/w185/2d');
      expect(movie.releaseDate, now);
      expect(
        movie.trailer.getOrCrash(),
        'https://youtube.com/watch?v=29wadk2ll1302',
      );
    });
    test('create with some value missing', () {
      final now = DateTime.now();
      final movie = Movie(
        id: UniqueId(1),
        title: Title('Hero'),
        rating: Rating(4.5),
        poster: Poster(null),
        releaseDate: now,
        trailer: Trailer(null),
      );
      //act

      //validate
      expect(movie.id.getOrCrash(), 1);
      expect(movie.title.getOrCrash(), 'Hero');
      expect(movie.rating.getOrCrash(), 4.5);
      expect(movie.poster.getOrCrash(), 'https://via.placeholder.com/200');
      expect(movie.releaseDate, now);
      expect(
        movie.trailer.getOrCrash(),
        'https://www.youtube.com/watch?v=PWbRleMGagU&list=RDjZhW9pupZfI',
      );
    });
  });
}

Lalu buat entity nya,

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
part 'movie.freezed.dart';

@freezed
class Movie with _$Movie {
  const factory Movie({
    required UniqueId id,
    required Title title,
    required Poster poster,
    required DateTime releaseDate,
    required Rating rating,
    required Trailer trailer,
  }) = _Movie;
}

Terus buat Value Object nya.

import 'package:dartz/dartz.dart';
import 'package:movie_flutter/core/domain/value_failure.dart';
import 'package:movie_flutter/core/domain/value_object.dart';
import 'package:movie_flutter/core/domain/value_validator.dart';

class Title extends ValueObject<String> {
  factory Title(String? input) {
    // we just want the title cannot be null
    assert(input != null, 'title cannot be null');
    return Title._(right(input!));
  }
  const Title._(this.value);
  @override
  final Either<ValueFailure<String>, String> value;
}

class Poster extends ValueObject<String> {
  factory Poster(String? path) {
    //if the poster is null, or empty, change to default poster.
    if (path == null || path.isEmpty) {
      return Poster._(right(defaultPoster));
    }

    return Poster._(
      right(
        Uri(
          scheme: 'https',
          host: baseUrl,
          path: basePath + path,
        ).toString(),
      ),
    );
  }

  const Poster._(this.value);

  static const size = 'w185';
  static const baseUrl = 'image.tmdb.org';
  static const basePath = '/t/p/$size';
  static const defaultPoster = 'https://via.placeholder.com/200';
  @override
  final Either<ValueFailure<String>, String> value;
}

class Trailer extends ValueObject<String> {
  factory Trailer(String? url) {
    if (url == null || url.isEmpty) {
      return Trailer._(right(defaultTrailer));
    }
    return Trailer._(right(url));
  }

  const Trailer._(this.value);
  static const defaultTrailer =
      'https://www.youtube.com/watch?v=PWbRleMGagU&list=RDjZhW9pupZfI';
  @override
  final Either<ValueFailure<String>, String> value;
}

class Rating extends ValueObject<double> {
  factory Rating(double? input) {
    assert(input != null, 'rating cannot be empty');
    return Rating._(
      validateNumberRange<double>(
        number: input!,
        minimum: minimum,
        maximum: maximum,
      ),
    );
  }
  const Rating._(this.value);
  static const minimum = 0.0;
  static const maximum = 10.0;
  @override
  final Either<ValueFailure<double>, double> value;
}

dan jalankan test nya.

Test movie entity

Dan saatnya untuk MovieDetail

import 'package:flutter_test/flutter_test.dart';
import 'package:movie_flutter/core/domain/value_object.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';

void main() {
  group('movie_detail entity', () {
    final genres = <Genre>[
      Genre(
        id: UniqueId(1),
        name: StringSingleLine('Action'),
      ),
    ];
    final relatedMovies = <Movie>[];
    test('create with full value', () {
      //prepare
      final now = DateTime.now();

      final movie = MovieDetail(
        id: UniqueId(1),
        title: Title('Hero'),
        rating: Rating(4.5),
        poster: Poster('/2d'),
        releaseDate: now,
        trailer: Trailer('https://youtube.com/watch?v=29wadk2ll1302'),
        genres: genres,
        relatedMovies: relatedMovies,
        synopsis: 'A synosis',
        duration: const Duration(minutes: 147),
      );
      //act

      //validate
      expect(movie.id.getOrCrash(), 1);
      expect(movie.title.getOrCrash(), 'Hero');
      expect(movie.rating.getOrCrash(), 4.5);
      expect(movie.poster.getOrCrash(), 'https://image.tmdb.org/t/p/w185/2d');
      expect(movie.releaseDate, now);
      expect(
        movie.trailer.getOrCrash(),
        'https://youtube.com/watch?v=29wadk2ll1302',
      );
      expect(movie.genres, genres);
      expect(movie.relatedMovies, relatedMovies);
      expect(movie.duration.inMinutes, 147);
    });
    test('create with some value missing', () {
      final now = DateTime.now();
      final movie = MovieDetail(
        id: UniqueId(1),
        title: Title('Hero'),
        rating: Rating(4.5),
        poster: Poster(null),
        releaseDate: now,
        trailer: Trailer(null),
        genres: genres,
        relatedMovies: relatedMovies,
        synopsis: 'A synosis',
        duration: const Duration(minutes: 147),
      );
      //act

      //validate
      expect(movie.id.getOrCrash(), 1);
      expect(movie.title.getOrCrash(), 'Hero');
      expect(movie.rating.getOrCrash(), 4.5);
      expect(movie.poster.getOrCrash(), 'https://via.placeholder.com/200');
      expect(movie.releaseDate, now);
      expect(
        movie.trailer.getOrCrash(),
        'https://www.youtube.com/watch?v=PWbRleMGagU&list=RDjZhW9pupZfI',
      );
      expect(movie.genres, genres);
      expect(movie.relatedMovies, relatedMovies);
      expect(movie.duration.inMinutes, 147);
    });
  });
}

Entity Movie Detail

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';
import 'package:movie_flutter/features/movie/domain/entities/movie.dart';
import 'package:movie_flutter/features/movie/domain/entities/value_objects.dart';
part 'movie_detail.freezed.dart';

@freezed
class MovieDetail with _$MovieDetail {
  const factory MovieDetail({
    required UniqueId id,
    required Title title,
    required Poster poster,
    required Duration duration,
    required Rating rating,
    required DateTime releaseDate,
    required List<Genre> genres,
    required String synopsis,
    required Trailer trailer,
    required List<Movie> relatedMovies,
  }) = _MovieDetail;
}

Terakhir kita update github CI agar project tidak gagal di build.

name: movie_flutter

on:
  pull_request:
    # paths:
    #   - "**.dart"
  push:
    # paths:
    #   - "**.dart"
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - uses: subosito/flutter-action@v1.5.3

      - name: Install Dependencies
        run: flutter packages get

      # - name: Generate Environment Config
      #   run: flutter pub run environment_config:generate

      - name: Generate Build Runners
        run: flutter pub run build_runner build --delete-conflicting-outputs

      - name: Format
        run: flutter format --set-exit-if-changed lib test

      - name: Analyze
        run: flutter analyze lib test

      - name: Run tests
        run: flutter test --no-pub --coverage --test-randomize-ordering-seed random

      - name: Check Code Coverage
        uses: VeryGoodOpenSource/very_good_coverage@v1.2.0
        with:
          min_coverage: 70
          exclude: "**/*.g.dart **/*.freezed.dart **/*.config.dart lib/env_config.dart"

Jalankan test nya.

Terakhir sebelum di push akan kita jalankan

dart fix --apply
dart analyze

Lalu kita commit dan push ke repo.

Sampai jumpa di bagian selanjutnya!