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.
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
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.
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:
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.
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/[email protected]
- 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/[email protected]
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!