bloc / flutter · August 27, 2021

Roadmap Flutter BLoC 2021

Saya menghabiskan banyak waktu di Android Native, banyak sekali library yang mempermudah pekerjaan saya setiap harinya. Salah satu library yang pasti saya gunakan adalah retrofit. Kali ini, kita akan membuat sebuah basic application menggunakan flutter dengan Flutter BLoC, Retrofit, Dio, dan JSON Serializable.

Berikut dependecy yang digunakan:

Berikut dev_dependency yang digunakan:

Sebelum membuat sebuah project pastikan flutter version kamu sudah yg terbaru dan safety null. lihat link berikut.

Step Pertama

Buatlah project flutter baru, lalu edit pubspec.yaml dengan library-library di atas, lebih lengkapnya anda bisa melihat seperti ini:

name: road_map
description: Road Map Flutter BLoc.

publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_bloc: ^7.2.0
  retrofit: ^2.0.1
  dio: ^4.0.0
  json_serializable: ^5.0.0
  equatable: ^2.0.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.1.1
  retrofit_generator: ^2.0.1+1


flutter:
  uses-material-design: true

Step Kedua

Buatlah Struktur folder kurang lebih seperti ini:

Step Ketiga

Buat routes.dart file sejajar dengan main.dart file dan contoh code sebagai berikut:

class Routes {
  Routes._();

  static const String home = '/';

}

Step Keempat

Untuk mempermudah proses development, contoh endpoint yang ingin kita consume adalah sebagai berikut:

http://api.themoviedb.org/3/search/movie?api_key=YOUR_API_KEY&query=superman&page=1

Sebelum melanjutkan code selanjutnya, untuk mempermudah proses development biasakan membuat live template jika menggunakan android studio. Kemudian buat class responsenya di folder data/response.

movie.dart

import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';

part 'movie.g.dart';

@JsonSerializable()
class Movie extends Equatable{

  @JsonKey(name: 'id')
  final int id;

  @JsonKey(name: 'title')
  final String? title;

  @JsonKey(name: 'poster_path')
  final String? path;

  @JsonKey(name: 'release_date')
  final String? releaseDate;

  @JsonKey(name: 'overview')
  final String? description;

  const Movie(this.id ,this.title, this.path, this.releaseDate, this.description);

  @override
  // TODO: implement props
  List<Object?> get props => [
    id,
    title,
    path,
    releaseDate,
    description
  ];

  factory Movie.fromJson(Map<String, dynamic> json) =>
      _$MovieFromJson(json);

  Map<String, dynamic> toJson() => _$MovieToJson(this);

}

movies.dart

import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:road_map/data/response/movie.dart';

part 'movies.g.dart';

@JsonSerializable()
class Movies extends Equatable{

  @JsonKey(name: 'results')
  final List<Movie> results;

  const Movies(this.results);

  @override
  List<Object?> get props => [
    results
  ];

  factory Movies.fromJson(Map<String, dynamic> json) =>
      _$MoviesFromJson(json);

  Map<String, dynamic> toJson() => _$MoviesToJson(this);

}
Karena kita menggunakan build_runner

cara menjalankannya ada beberapa cara
- flutter pub run build_runner watch --delete-conflicting-outputs
- flutter pub run build_runner build --delete-conflicting-outputs

Kemudian buat class servicenya di folder data/remote

movie_service.dart

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import 'package:road_map/data/response/movies.dart';

part 'movie_service.g.dart';

@RestApi(baseUrl : "http://api.themoviedb.org/3/")
abstract class MovieService {
  factory MovieService(Dio dio, {String baseUrl}) = _MovieService;

  @GET("search/movie")
  Future<Movies> searchMovie(@Query("api_key") String apiKey,
      @Query("query") String query,
      @Query("page") int page);

}

movie_service_client.dart

import 'package:dio/dio.dart';
import 'package:road_map/data/remote/movie_service.dart';

class MovieServiceClient {
  late Dio _dio;
  late MovieService _service;

  MovieServiceClient() {
    _dio = Dio();
    _dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
    _service = MovieService(_dio);
  }

  MovieService getService() {
    return _service;
  }

}

Kemudian buat class repositorynya di folder data/repository

movie_repository.dart

import 'package:road_map/data/response/movies.dart';

abstract class MovieRepository {

  Future<Movies>? searchMovie(String query, int page);

}

movie_repository_impl.dart

import 'package:road_map/data/remote/movie_service.dart';
import 'package:road_map/data/remote/movie_service_client.dart';
import 'package:road_map/data/repository/movie_repository.dart';
import 'package:road_map/data/response/movies.dart';

class MovieRepositoryImpl extends MovieRepository {

  late MovieService _service;
  var apiKey = "YOUR_API_KEY";

  MovieRepositoryImpl() {
    _service = MovieServiceClient().getService();
  }

  @override
  Future<Movies>? searchMovie(String query, int page) {
    return _service.searchMovie(apiKey, query, page);
  }

}

Kemudian pastikan buat file home_page.dart sebagai widget home atau default widget saat pertama kali aplikasi dijalankan.

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(),
    );
  }
}

Dan selanjutnya kita mengimplementasikan bloc pada code kita di folder bloc

home_page_state.dart

import 'package:equatable/equatable.dart';
import 'package:road_map/data/response/movie.dart';

abstract class HomePageState extends Equatable {
  const HomePageState();

  @override
  List<Object> get props => [];
}

class InitialHomePageState extends HomePageState {}

class LoadingHomePageState extends HomePageState {}


class ErrorHomePageState extends HomePageState {
  final String errorMessage;

  const ErrorHomePageState({this.errorMessage = "Unknown Error"});
  @override
  String toString() =>
      'Error : $errorMessage';
}

class SuccessHomePageState extends HomePageState {

  final List<Movie> movies;

  const SuccessHomePageState({this.movies = const []});
}

home_page_event.dart

import 'package:equatable/equatable.dart';

abstract class HomePageEvent extends Equatable {

  @override
  List<Object> get props => [];

}

class SearchMovieEvent extends HomePageEvent {

  final String query;

  SearchMovieEvent({required this.query});

}

home_page_bloc.dart

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_event.dart';
import 'package:road_map/bloc/home/home_page_state.dart';
import 'package:road_map/data/repository/movie_repository.dart';
import 'package:road_map/data/response/movies.dart';

class HomePageBloc
    extends Bloc<HomePageEvent, HomePageState> {

  final MovieRepository repository;

  int page = 1;

  HomePageBloc({required this.repository})
      : super(InitialHomePageState());

  @override
  Stream<HomePageState> mapEventToState(HomePageEvent event) async* {
    if (event is SearchMovieEvent) {
      try {

        yield LoadingHomePageState();

        Movies? response = await repository.searchMovie(event.query, page);

        if(response!.results.isNotEmpty) {
          page++;
        }

        yield SuccessHomePageState(movies: response.results);

      } catch (e) {
        yield ErrorHomePageState(errorMessage: e.toString());
      }
    }
  }
}

Kemudian buat body widget untuk home_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_bloc.dart';
import 'package:road_map/bloc/home/home_page_event.dart';
import 'package:road_map/bloc/home/home_page_state.dart';
import 'package:road_map/data/response/movie.dart';

class Body extends StatelessWidget {

  final List<Movie> _movies = [];

  @override
  Widget build(BuildContext context) {
    var provider = context.read<HomePageBloc>();
    searchMoviesByQuery(provider);
    return Center(
      child: BlocConsumer<HomePageBloc, HomePageState>(
        listener: (context, state) {
          if (state is ErrorHomePageState) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content:Text(state.errorMessage),
                duration: const Duration(seconds: 2),
              ),
            );
          }
          return;
        },
        builder: (context, state) {

          if (state is InitialHomePageState ||
              state is LoadingHomePageState && _movies.isEmpty) {
            return const CircularProgressIndicator();
          } else if (state is SuccessHomePageState) {
            _movies.addAll(state.movies);
          } else if (state is ErrorHomePageState && _movies.isEmpty) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                IconButton(
                  onPressed: () {
                    searchMoviesByQuery(provider);
                  },
                  icon: const Icon(Icons.refresh),
                ),
                const SizedBox(height: 15),
                Text(state.errorMessage, textAlign: TextAlign.center),
              ],
            );
          }
          return Text(
            _movies.toString()
          );

        },
      ),
    );
  }

  void searchMoviesByQuery(HomePageBloc provider,  {String query = "superman"}) {
    provider.add(SearchMovieEvent(query: query));
  }

}

Setelah itu update class home_page.dart

import 'package:flutter/material.dart';

import 'component/body.dart';

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Body(),
    );
  }
}

Karena endpoint menggunakan http maka perlu mengupdate AndroidManifest.xml di android dan info.plist di Ios:

Android

<application
       .......
       android:usesCleartextTraffic="true"
       .......>
</Aplication>

Ios

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Kemudian Setelah aplikasi di jalankan hasilnya akan seperti berikut:

Agar tampilannya lebih menarik buat folder widgets sejajar dengan main.dart dan tambahkan beberapa file berikut, dimulai dengan menambahkan 1 dependecy untuk meload image network.


dependencies:
  cached_network_image: ^3.1.0

loading_indicator.dart

import 'package:flutter/material.dart';

class LoadingIndicator extends StatelessWidget {
  const LoadingIndicator({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(child: CircularProgressIndicator());
  }
}

error_image.dart

import 'package:flutter/material.dart';

class ErrorImage extends StatelessWidget {
  const ErrorImage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(child: Icon(Icons.error));
  }
}

Kemudian tambahkan 1 folder utils untuk extension toUrlImage seperti berikut ini:

extension StringExtension on String{

  String get toUrlW92Image {
    return 'http://image.tmdb.org/t/p/w92/$this';
  }
}

dan tambahkan movie_item_widget.dart di folder widgets

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:road_map/data/response/movie.dart';
import 'package:road_map/utils/common_ext.dart';

import 'error_image.dart';
import 'loading_indicator.dart';

class MovieItemWidget extends StatelessWidget {
  final Movie movie;
  const MovieItemWidget({Key? key, required this.movie}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4.0,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Expanded(
            flex: 1,
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Hero(
                tag: '${movie.id}',
                child: CachedNetworkImage( imageUrl : movie.path.toString().toUrlW92Image,
                  placeholder: (context, url) => const Padding(
                    padding: EdgeInsets.only(top: 8.0),
                    child: LoadingIndicator(),
                  ),
                  errorWidget: (context, url, error) => const ErrorImage(),),
              ),
            ),
          ),
          Expanded(
            flex: 3,
            child: Column(
              mainAxisSize: MainAxisSize.max,
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.only(top: 8.0),
                  child: Text(
                    movie.title.toString(),
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.only(
                      bottom: 8.0, right: 8.0),
                  child: Text(
                    movie.description.toString(),
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
          )
        ],
      ),
    );
  }
}

Update class body class dari home_page.dart di folder pages/home

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_bloc.dart';
import 'package:road_map/bloc/home/home_page_event.dart';
import 'package:road_map/bloc/home/home_page_state.dart';
import 'package:road_map/data/response/movie.dart';
import 'package:road_map/widgets/movie_item_widget.dart';

class Body extends StatelessWidget {
  const Body({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final List<Movie> _movies = [];
    var provider = context.read<HomePageBloc>();
    searchMoviesByQuery(provider);
    return Center(
      child: BlocConsumer<HomePageBloc, HomePageState>(
        listener: (context, state) {
          if (state is ErrorHomePageState) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content:Text(state.errorMessage),
                duration: const Duration(seconds: 2),
              ),
            );
          }
          return;
        },
        builder: (context, state) {

          if (state is InitialHomePageState ||
              state is LoadingHomePageState && _movies.isEmpty) {
            return const CircularProgressIndicator();
          } else if (state is SuccessHomePageState) {
            _movies.addAll(state.movies);
          } else if (state is ErrorHomePageState && _movies.isEmpty) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                IconButton(
                  onPressed: () {
                    searchMoviesByQuery(provider);
                  },
                  icon: const Icon(Icons.refresh),
                ),
                const SizedBox(height: 15),
                Text(state.errorMessage, textAlign: TextAlign.center),
              ],
            );
          }
          return ListView.separated(
            itemBuilder: (context, index) {
              return MovieItemWidget(movie: _movies[index]);
            },
            separatorBuilder: (context, index) => const SizedBox(height: 20),
            itemCount: _movies.length,
          );

        },
      ),
    );
  }

  void searchMoviesByQuery(HomePageBloc provider,  {String query = "superman"}) {
    provider.add(SearchMovieEvent(query: query));
  }

}

Setelah aplikasi dijalan maka hasilnya akan seperti berikut:

Untuk menambahkan load more di list view kamu, pertama sekali update home_page_bloc.dart

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_event.dart';
import 'package:road_map/bloc/home/home_page_state.dart';
import 'package:road_map/data/repository/movie_repository.dart';
import 'package:road_map/data/response/movies.dart';

class HomePageBloc
    extends Bloc<HomePageEvent, HomePageState> {

  final MovieRepository repository;

  int page = 1;
  bool isFetching = false;

  HomePageBloc({required this.repository})
      : super(InitialHomePageState());

  @override
  Stream<HomePageState> mapEventToState(HomePageEvent event) async* {
    if (event is SearchMovieEvent) {
      try {

        yield LoadingHomePageState();

        Movies? response = await repository.searchMovie(event.query, page);

        if(response!.results.isNotEmpty) {
          page++;
        }

        yield SuccessHomePageState(movies: response.results);

      } catch (e) {
        yield ErrorHomePageState(errorMessage: e.toString());
      }
     isFetching = false;
    }
  }
}

Kemudian update body class dari home_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_bloc.dart';
import 'package:road_map/bloc/home/home_page_event.dart';
import 'package:road_map/bloc/home/home_page_state.dart';
import 'package:road_map/data/response/movie.dart';
import 'package:road_map/widgets/loading_indicator.dart';
import 'package:road_map/widgets/movie_item_widget.dart';

class Body extends StatelessWidget {

  const Body({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final List<Movie> _movies = [];
    final ScrollController _scrollController = ScrollController();
    var provider = context.read<HomePageBloc>();
    searchMoviesByQuery(provider);
    return Center(
      child: BlocConsumer<HomePageBloc, HomePageState>(
        listener: (context, state) {
          if (state is ErrorHomePageState) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content:Text(state.errorMessage),
                duration: const Duration(seconds: 2),
              ),
            );
          }
          return;
        },
        builder: (context, state) {

          if (state is InitialHomePageState ||
              state is LoadingHomePageState && _movies.isEmpty) {
            return const CircularProgressIndicator();
          } else if (state is SuccessHomePageState) {
            _movies.addAll(state.movies);
          } else if (state is ErrorHomePageState && _movies.isEmpty) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                IconButton(
                  onPressed: () {
                    searchMoviesByQuery(provider);
                  },
                  icon: const Icon(Icons.refresh),
                ),
                const SizedBox(height: 15),
                Text(state.errorMessage, textAlign: TextAlign.center),
              ],
            );
          }
          return ListView.separated(
            controller: _scrollController
              ..addListener(() {
                if (_scrollController.offset ==
                    _scrollController.position.maxScrollExtent &&
                    !context.read<HomePageBloc>().isFetching) {
                    searchMoviesByQuery(provider, isFetching: true);
                }
              }),
            itemBuilder: (context, index) {
              if (index == _movies.length) {
                return (provider.isFetching  && _movies.isNotEmpty)
                    ? const LoadingIndicator() : const SizedBox(height: 1,);
              }
              return MovieItemWidget(movie: _movies[index]);
            },
            separatorBuilder: (context, index) => const SizedBox(height: 20),
            itemCount: _movies.length+1,
          );

        },
      ),
    );
  }

  void searchMoviesByQuery(HomePageBloc provider,  {String query = "superman",
    bool isFetching = false}) {
    provider..isFetching = isFetching..add(SearchMovieEvent(query: query));
  }

}

Selanjutnya kita akan mengimplementasikan untuk membuka detail dari movie. Pertama sekali tambahkan folder detail di folder pages dan tambahkan file detail_movie_page.dart, Namun sebelum menambahkannya tambahkan satu filed di routes.dart

class Routes {
  Routes._();

  static const String home = '/';
  static const String detail = '/detail';

}

Kemudian buat file detail_movie_page.dart di folder pages/detail

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:road_map/data/response/movie.dart';
import 'package:road_map/utils/common_ext.dart';
import 'package:road_map/widgets/error_image.dart';
import 'package:road_map/widgets/loading_indicator.dart';

class DetailMoviePage extends StatefulWidget {
  const DetailMoviePage({Key? key}) : super(key: key);

  @override
  _DetailMoviePageState createState() => _DetailMoviePageState();
}

class _DetailMoviePageState extends State<DetailMoviePage> {
  @override
  Widget build(BuildContext context) {
    final Movie movie = ModalRoute.of(context)?.settings.arguments as Movie;
    return Scaffold(
        body: SingleChildScrollView(
      physics: ClampingScrollPhysics(),
      child: Column(
        children: <Widget>[
          Stack(
            children: [
              Padding(
                padding: EdgeInsets.only(bottom: MediaQuery.of(context).size.width / 3),
                child: Container(
                  width: MediaQuery.of(context).size.width,
                  height: MediaQuery.of(context).size.width / 1.8,
                  child: CachedNetworkImage(
                    fit: BoxFit.cover,
                    width: MediaQuery.of(context).size.width,
                    height: MediaQuery.of(context).size.width / 2,
                    imageUrl: movie.path.toString().toString().toUrlW92Image,
                    placeholder: (context, url) => const LoadingIndicator(),
                    errorWidget: (context, url, error) => const ErrorImage(),
                  ),
                ),
              ),
              Positioned(
                bottom: 0.0,
                left: 16.0,
                right: 16.0,
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    Hero(
                      tag: '${movie.id}',
                      child: CachedNetworkImage(
                        height : MediaQuery.of(context).size.width / 2,
                        imageUrl: movie.path.toString().toUrlW92Image,
                        placeholder: (context, url) => LoadingIndicator(),
                        errorWidget: (context, url, error) => ErrorImage(),
                      ),
                    ),
                    SizedBox(width: 2.0),
                    Expanded(child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.start,
                      children: [
                        SizedBox(height: 16.0),
                        Text(
                          movie.title.toString(),
                          style: TextStyle(
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    )),
                  ],
                ),
              ),
            ],
          ),
          Padding(
            padding: const EdgeInsets.all(12.0),
            child: Text(
              movie.description.toString(),
              textAlign: TextAlign.justify,
            ),
          ),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 12.0),
            child: ElevatedButton.icon(
              onPressed: () {
                Navigator.pop(context);
              },
              label: Text('Back', style: const TextStyle(color: Colors.white),),
              icon: const Icon(
                Icons.arrow_back,
                color: Colors.white,
                size: 24.0,
              ),
            ),
          )
        ],
      ),
    ));
  }
}

Setelah itu update app.dart di folder app

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:road_map/bloc/home/home_page_bloc.dart';
import 'package:road_map/data/repository/movie_repository_impl.dart';
import 'package:road_map/pages/detail/detail_movie_page.dart';
import 'package:road_map/pages/home/home_page.dart';

import '../routes.dart';

class App extends StatelessWidget {

  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (context) => HomePageBloc(repository: MovieRepositoryImpl()),
        ),
      ],
      child: MaterialApp(
        debugShowCheckedModeBanner: false,
        title: 'Road Map',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        routes: {
          Routes.detail: (context) => const DetailMoviePage()
        },
        home: const HomePage(),
      ),
    );
  }
}

Kemudia update event click di movie_item_widget.dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:road_map/data/response/movie.dart';
import 'package:road_map/utils/common_ext.dart';

import '../routes.dart';
import 'error_image.dart';
import 'loading_indicator.dart';

class MovieItemWidget extends StatelessWidget {
  final Movie movie;
  const MovieItemWidget({Key? key, required this.movie}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () {
        Navigator.pushNamed(context, Routes.detail,
            arguments: movie);
      },
      child: Card(
        elevation: 4.0,
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              flex: 1,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Hero(
                  tag: '${movie.id}',
                  child: CachedNetworkImage( imageUrl : movie.path.toString().toUrlW92Image,
                    placeholder: (context, url) => const Padding(
                      padding: EdgeInsets.only(top: 8.0),
                      child: LoadingIndicator(),
                    ),
                    errorWidget: (context, url, error) => const ErrorImage(),),
                ),
              ),
            ),
            Expanded(
              flex: 3,
              child: Column(
                mainAxisSize: MainAxisSize.max,
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.only(top: 8.0),
                    child: Text(
                      movie.title.toString(),
                      style: const TextStyle(fontWeight: FontWeight.bold),
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(
                        bottom: 8.0, right: 8.0),
                    child: Text(
                      movie.description.toString(),
                      maxLines: 3,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}

Setelah aplikasi dijalankan dan click salah satu item movie maka hasilnya akan seperti berikut ini: