Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
For this project, we are going to use auto_route for routing between screens as named routes. So to begin first add it on your "pubspec.yaml"
dependencies:
auto_route: ^0.6.9
dev_dependencies:
auto_route_generator: ^0.6.10
build_runner: ^1.11.1
So auto route uses code generation if you're new to code generation in dart check out this article.
After that let's go ahead and create our first page and create our router.
Welcome to Tech-camp Ethiopia.
A tech boot camp is exactly what it sounds like – an encompassing program where we learn to either cultivate new tech skills or improve our current strengths. In a way, boot camps are like extended workshops. It is an intensive coding training course that prepares people with little or no prior experience in software development to work as junior developers.
"Learning to write programs stretches your mind, and helps you think better, creates a way of thinking about things that I think is helpful in all domains.” Bill Gates
The Tech Camp Ethiopia 2020 will be a series of 6 Bootcamp programs that encourage all of the youths – makers, engineers, do-gooders, executives, computer scientists, inventors, innovators – are making things that are not just nice to have, but that people need.
Tech Camp Ethiopia creates connections, sparks, innovations, and empowers young tech-savvy society to solve real-world pressing challenges through technology.
This program is organized by the U.S. Embassy in Ethiopia in collaboration with GDG Addis (as an implementing partner) and CAWEE (a financial administrator). It is one part of #USInvestsInEthiopians programs.
The goal of Tech Camp Ethiopia is to guide youths where to get started in the trending tech world and raise their technical, business, and other soft skills through one-to-one learning, hands-on training, and workshops in record time. It aims to pave the way for the participants how to convert ideas to prototype and then prototype into business by letting them enjoy their new skills with project-based practices.
Involve as many underrepresented communities as possible by implementing DIVERSITY & INCLUSION principles.
Let the participants find the best teams to work together and probably to form a startup.
Elevate the business and digital marketing skills of the youth
Give the youths the habit of how to get the job done through hands-on practices and project-based exercises.
Create a fun way of learning experience through different gamification and engaging activities.
Create networking opportunities with tech companies who may potentially find skill power from the participants.
Learn more about the program at https://techcamp.gdgaddis.dev
Alright so let's begin assuming you created the project and can currently run the hello world app, first thing to do is remove all the boilerplate and keep only the main method.
void main() async {
runApp(Application());
}
After this, you need to create the "Application" widget. In order to do that create a new folder under src and create a new file called "application.dart"
import 'package:flutter/material.dart';
class Application extends StatelessWidget {
const Application({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tech camp Flutter',
debugShowCheckedModeBanner: false,
home: Scaffold()
);
}
}
So all of this is straightforward. The next step is to define our routes.
So the last step is adding an instance of the router class we just generated to the material app to manage its routes.
MaterialApp(
title: 'Tech camp Flutter',
debugShowCheckedModeBanner: false,
//TODO: Add this line
builder: ExtendedNavigator.builder(router: Router()),
),
While doing this step flutter may cause an error that the router class is imported from two libraries. So in order to overcome that change your imports like this.
import 'package:flutter/material.dart' hide Router;
In order to create the first page create products_screen.dart under src/product/view/product with the following basic screen.
import 'package:flutter/material.dart';
class ProductsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Product Screen"),
),
);
}
}
That's enough for now let's go ahead and create the router setup.
Before heading off to explain the project details let's take a step back and discuss about the project structure we are going to follow.
So basically folders are going to be grouped based on the feature under src as you can see from the image above.
Apart from the feature, there is an application folder for putting the main app or the root widget, there is also a di folder for dependency injection and finally the router folder for putting all our page routes.
Let's take a closer look at the product feature for instance.
So each of the folders represent a layer of the feature to be coded. If the names don't make sense we will cover them later don't worry.
What are we building?
The project that we are going to build for this tech camp is a simple shopping app with the following features:
Get my profile
Get all products
Filter products by category
Add products to the cart
Increment or decrement products on the cart
The purpose of the project and its limited features is to introduce you basics of coding a standard app in a way that is scalable and easy to manage in the future.
These are what the screens of the application we are going to build would look like
That being said here's a couple of things to expect to learn with this project:
Bloc (Cubit) state management
Local state caching using hydrated cubit
Using DIO for Rest-API requests
Freezed for sealed class generation
JSON annotation for model generation
Unit testing with mockito
Widget testing
Auto Route for route management
Dependency injection using GetIt
First, create the "products_state.dart" file under src/product/cubit/products/ with the following code.
part of 'products_cubit.dart';
@freezed
abstract class ProductsState with _$ProductsState {
const factory ProductsState.error({@required String error}) =
ProductsErrorState;
const factory ProductsState.refreshing() = ProductsRefreshingState;
const factory ProductsState.initial() = InitialProductsState;
const factory ProductsState.loaded({
@required List<Product> products,
}) = ProductsLoadedState;
const factory ProductsState.loading() = ProductsLoadingState;
}
After that let's create the configuration needed for the router. First, create the "router.dart" file under src/router/ with the following code.
import 'package:auto_route/auto_route.dart';
import 'package:auto_route/auto_route_annotations.dart';
import 'package:flutter/cupertino.dart';
// Import your products_screen.dart
export 'router.gr.dart';
@MaterialAutoRouter(routes: [
MaterialRoute(page: ProductsScreen, initial: true),
])
class $Router {}
After this run, the command "pub run build_runner build" on the terminal of the project to generate the router source code. After running it you should see a "router.gr.dart" file next to it where the generated source code is written.
What the above code basically does is register a new route for "ProductsScreen" and make it the initial route.
In flutter, state management is like a life support system where the whole App depends on, there are different state management techniques like Redux, MobX, bloc, flutter bloc, provider, and cubit. Actually Cubit is a cocktail of Flutter Bloc and Provider where we use some features of both techniques and we are going to learn how to use and implement Cubit, So before we go for its implementation firstly let’s have some cursory knowledge of it.
Cubit is a lightweight state management solution. It is a subset of the bloc package that does not rely on events and instead uses methods to emit new states. Every cubit
requires an initial state which will be the state of the cubit
before emit
has been called. The current state of a cubit
can be accessed via the state
getter.
Add this to your package's pubspec.yaml file:
# pubspec.yaml
dependencies:
json_annotation: ^4.0.0
dev_dependencies:
build_runner:
json_serializable 4.0.2
You can then install packages from the command line:
$ dart pub get
Now you can use it in your Dart code by importing it like this:
import 'package:json_annotation/json_annotation.dart';
part 'my_model.g.dart';
Write the following code
import 'package:json_annotation/json_annotation.dart';
part 'my_model.g.dart';
@freezed
class Person with _$Person {
const factory Person({
@required String firstName,
@required String lastName,
}) = _Person;
factory Person.fromJson(Map<String, dynamic> json) =>
_$PersonFromJson(json);
}
To generate the code type the following command
$ flutter pub run build_runner build
It is likely that the code generated by Json_Serializer will cause your linter to report warnings.
The solution to this problem is to tell the linter to ignore generated files, by modifying your analysis_options.yaml
:
analyzer:
exclude:
- "**/*.g.dart"
Import
import 'package:dio/dio.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
Create an abstract class of ProductRepository
abstract class ProductRepository {
Future<List<Product>> getAll();
}
Implement the abstract class
class ProductRepositoryImpl implements ProductRepository {
final Dio _dio;
const ProductRepositoryImpl(this._dio) : assert(_dio != null);
@override
Future<List<Product>> getAll() async {
try {
final response = await _dio.get('products');
final List productsJson = response.data;
return productsJson.map((json) => Product.fromJson(json)).toList();
} on DioError catch (_) {
throw const AppError('Network error');
} on dynamic catch (_) {
throw const AppError('Something went wrong.');
}
}
}
After installing and experimenting on the freezed package, now let's create producs model.
First, create the "product.dart" file under src/product/model/product/ with the following code.
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
part 'product.freezed.dart';
part 'product.g.dart';
@freezed
abstract class Product with _$Product {
const factory Product({
@required int id,
@required String title,
@required String image,
@required double price,
@required String description,
@required @CategoryConverter() ProductCategory category,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
}
After this run, the command "pub run build_runner build" on the terminal of the project to generate the product source code. After running it you should see "product.freezed.dart" and "product.g.dart" files next to it where the generated source code is written.
Now let's create the "products_cubit.dart" file under src/product/cubit/products/ with the following code.
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
part 'products_cubit.freezed.dart';
part 'products_state.dart';
class ProductsCubit extends Cubit<ProductsState> {
final ProductRepository _productRepository;
ProductsCubit(this._productRepository)
: assert(_productRepository != null),
super(const InitialProductsState());
Future<void> getAll() {
emit(const ProductsLoadingState());
return _getAllProducts();
}
Future<void> refresh() async {
emit(const ProductsRefreshingState());
return _getAllProducts();
}
Future<void> _getAllProducts() async {
try {
final products = await _productRepository.getAll();
emit(ProductsLoadedState(products: products));
} on AppError catch (error) {
emit(ProductsErrorState(error: error.message));
}
}
}
It is hard to think of a mobile app that doesn’t need to communicate with a web server or easily store structured data at some point. When making network-connected apps, the chances are that it needs to consume some good old JSON, sooner or later.
In case you're not familiar with the term data class, it's simply a class with value equality, copyWith
method, its fields are immutable and it usually easily supports serialization. Also, if you're familiar with Kotlin, you know that you can heavily cut down on the boilerplate by defining fields directly in the constructor like this:
data class User(val name: String, val age: Int)
instead of this
@immutable
class User {
final String name;
final int age;
User(this.name, this.age);
User copyWith({
String name,
int age,
}) {
return User(
name ?? this.name,
age ?? this.age,
);
}
@override
bool operator ==(Object o) {
if (identical(this, o)) return true;
return o is User && o.name == name && o.age == age;
}
@override
int get hashCode => name.hashCode ^ age.hashCode;
}
instead of coding boilerplate freezed does all this with a few keystrokes
import 'package:meta/meta.dart';
part 'freezed_classes.freezed.dart';
@immutable
abstract class User with _$User {
const factory User(String name, int age) = _User;
}
In this section we will be Working on Creating the models of the Products features and the following will be covered.
In this section we are going to be setting up our UI.
import 'package:dio/dio.dart';
Making your first http request with DIO
var dio = Dio();
Response response = await dio.get('https://google.com');
print(response.data);
The above example will make a request to http://google.com and print the result.
Performing a GET
request:
import 'package:dio/dio.dart';
Response response;
Dio dio = new Dio();
response = await dio.get("/test?id=12&name=wendu");
print(response.data.toString());
// Optionally the request above could also be done as
response = await dio.get("/test", queryParameters: {"id": 12, "name": "wendu"});
print(response.data.toString());
Performing a POST
request:
response = await dio.post("/test", data: {"id": 12, "name": "wendu"});
Performing multiple concurrent requests:
response = await Future.wait([dio.post("/info"), dio.get("/token")]);
Downloading a file:
response = await dio.download("https://www.google.com/", "./xx.html");
You can create an instance of Dio with an optional BaseOptions
object:
Dio dio = new Dio(); // with default Options
// Set default configs
dio.options.baseUrl = "https://www.xx.com/api";
dio.options.connectTimeout = 5000; //5s
dio.options.receiveTimeout = 3000;
// or new Dio with a BaseOptions instance.
BaseOptions options = new BaseOptions(
baseUrl: "https://www.xx.com/api",
connectTimeout: 5000,
receiveTimeout: 3000,
);
Dio dio = new Dio(options);
A repository is nothing flutter specific. It's a design pattern with many implementations in many languages and for our case we are going to be using DIO. The Repository pattern implements separation of concerns by abstracting the data persistence logic in your applications. Design patterns are used as a solution to recurring problems in your applications, and the Repository pattern is one of the most widely used design patterns.
Dio is a networking library developed by Flutter. It is powerful Http client for Dart, which supports Interceptors, Global configuration, FormData, Request Cancellation, File downloading, ConnectionTimeout etc. Things that dio supports can be done with normal http library which we get in flutter sdk too but its not that easy to learn or understand so dio can be better.
# pubspec.yaml
dependencies:
dio: ^3.0.10
Code generation for immutable classes that has a simple syntax/API without compromising on the features.
While there are many code-generators available to help you deal with immutable objects, they usually come with a trade-off. Either they have a simple syntax but lack features, or they have very advanced features but with complex syntax.
Add this to your package's pubspec.yaml file:
# pubspec.yaml
dependencies:
freezed_annotation:
dev_dependencies:
build_runner:
freezed:
You can then install packages from the command line:
$ dart pub get
Now you can use it in your Dart code by importing it like this:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_demo.freezed.dart';
Write the following code
import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_demo.freezed.dart';
@freezed
class Person with _$Person {
factory Person({ String name, int age }) = _Person;
}
To generate the code type the following command
$ flutter pub run build_runner build
It is likely that the code generated by Freezed will cause your linter to report warnings.
The solution to this problem is to tell the linter to ignore generated files, by modifying your analysis_options.yaml
:
analyzer:
exclude:
- "**/*.freezed.dart"
In order to use the blocs in your Flutter UI, you need to be able to access the blocs through-out your app. For example, you want to be able to do something like this in a widget. Provider is a term you'll see a lot in the Flutter world. In Flutter, it's used to describe a class that is used to inject other objects throughout your widget tree or scope, even when you're much further down the widget tree than where the objects are created.
Dependency Injection (DI) is a technique used to reduce tight coupling between classes thus achieving greater re-usability of your code. Instead of your class instantiating/creating the dependent classes/objects, the dependent objects are injected or supplied to your class; thus maintaining an external library of sorts to create and manage object creation in your project.
Some of the state management libraries such as , incorporate dependency injection by means of the Inherited Widget model.
Inside the product_screen.dart file under src/product/view/products/ add the following code:
Inside the product_screen.dart file under src/product/view/products/ add the following code:
Inside the product_screen.dart file we created earlier put the following code:
The _CartButton is a stateless widget placed inside product_screen.dart that builds a clickable widget we will use to navigate to the cartScreen
Widget testing is otherwise called component testing. As its name proposes, it is utilized for testing a single widget, and the objective of this test is to check whether the widget works and looks true to form.
Widget testing proves to be useful when we are trying the particular widgets. On the off chance that the Text widget doesn’t contain the text, at that point, Widget testing throws an error expressing that the Text widget doesn’t have a text inside it. We likewise don’t host to install any third-party dependencies for widget testing in Flutter.
Implementation:
Steps to Implement Widget Testing
Step 1: Add the dependencies
Add the
flutter_test
dependency to pubspec — yaml file.
Step 2: Create a file "product_screen_test.dart" in the directory test/product/view/products/ with the following code.
Now let's edit ProductScreen class in product_screen.dart
class _ProductsGridView extends StatelessWidget {
final List<Product> products;
const _ProductsGridView({Key key, @required this.products})
: assert(products != null),
super(key: key);
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1 / 1.2,
),
padding: const EdgeInsets.all(8),
itemCount: products.length,
physics: const BouncingScrollPhysics(),
itemBuilder: (_, index) => _ProductItem(product: products[index]),
);
}
}
class _ProductItem extends StatelessWidget {
final Product product;
const _ProductItem({Key key, @required this.product})
: assert(product != null),
super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.navigator.push(
Routes.productScreen,
arguments: ProductScreenArguments(product: product),
),
child: Card(
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(8),
child: Image.network(product.image),
),
),
Container(
height: 48,
color: Colors.grey[200],
padding: const EdgeInsets.all(4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text('${product.price} ETB'),
],
),
),
),
IconButton(
onPressed: () =>
context.read<CartCubit>().addProduct(product),
icon: const Icon(Icons.add_shopping_cart_rounded),
),
],
),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
class ProductsScreen extends StatelessWidget {
const ProductsScreen({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<ProductsCubit>()..getAll(),
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.person, color: Colors.white),
onPressed: () => context.navigator.push(Routes.profileScreen),
),
),
floatingActionButton: const _CartButton(),
body: Container(),
),
);
}
}
class _CartButton extends StatelessWidget {
const _CartButton({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<CartCubit, CartState>(
builder: (_, state) {
final count = state.carts.totalProductsQuantity;
return Stack(
overflow: Overflow.visible,
children: [
FloatingActionButton(
onPressed: () => context.navigator.push(Routes.cartScreen),
child: const Icon(Icons.shopping_cart),
),
if (count > 0)
Positioned(
top: -4,
right: -2,
child: CircleAvatar(
radius: 12,
backgroundColor: Colors.white,
child: Text('$count'),
),
)
],
);
},
);
}
}
dev_dependencies:
flutter_test:
sdk: flutter
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
void main() {
group('ProductsScreen', () {
ProductsCubit productsCubit;
setUp(() {
productsCubit = _MockProductsCubit();
getIt.registerFactory(() => productsCubit);
});
tearDown(() {
productsCubit?.close();
getIt.reset();
});
testWidgets('should show progress indicator on initial view',
(tester) async {
when(productsCubit.state).thenReturn(const InitialProductsState());
await tester.pumpWidget(
const MaterialApp(
home: ProductsScreen(),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('should show products when loaded successfully',
(tester) async {
const products = [
Product(
id: 1,
title: 'Fjallraven',
image: 'https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg',
price: 109.95,
description: 'Your perfect pack for everyday use.',
category: MenClothingProductCategory(),
),
Product(
id: 2,
title: 'Mens Casual Premium T-Shirts',
image:
'https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg',
price: 22.5,
description: 'Slim-fitting style.',
category: MenClothingProductCategory(),
)
];
when(productsCubit.state)
.thenReturn(const ProductsLoadedState(products: products));
// Fixes Image.network network exception
HttpOverrides.global = null;
await tester.pumpWidget(
const MaterialApp(
home: ProductsScreen(),
),
);
expect(find.text(products.first.title), findsOneWidget);
expect(find.text('${products.first.price} ETB'), findsOneWidget);
expect(find.text(products.last.title), findsOneWidget);
expect(find.text('${products.last.price} ETB'), findsOneWidget);
});
});
}
class _MockProductsCubit extends Mock implements ProductsCubit {}
class ProductsScreen extends StatelessWidget {
const ProductsScreen({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<ProductsCubit>()..getAll(),
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.person, color: Colors.white),
onPressed: () => context.navigator.push(Routes.profileScreen),
),
),
floatingActionButton: const _CartButton(),
body: SafeArea(
child: Builder(
builder: (context) => RefreshIndicator(
onRefresh: context.read<ProductsCubit>().refresh,
child: BlocBuilder<ProductsCubit, ProductsState>(
buildWhen: (previous, current) => previous.maybeWhen(
loaded: (products) => false,
orElse: () => true,
),
builder: (context, state) => state.maybeWhen(
error: (error) => _ProductsErrorWidget(error: error),
loaded: (products) => _ProductsGridView(products: products),
orElse: () => const Center(
child: CircularProgressIndicator(),
),
),
),
),
),
),
),
);
}
}
import 'package:flutter_test/flutter_test.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
void main() {
group('Product', () {
test('fromJson should return Product with correct values', () {
final json = {
'id': 1,
'price': 109.95,
'title': 'Fjallraven',
'description': 'description',
'category': 'men clothing',
'image': 'image',
};
final actual = Product.fromJson(json);
expect(actual.id, 1);
expect(actual.price, 109.95);
expect(actual.image, 'image');
expect(actual.description, 'description');
expect(actual.category, const MenClothingProductCategory());
});
});
}
Add this to your package's pubspec.yaml file:
dependencies:
get_it: ^6.0.0
First, create the "di.dart" file under src/di/ with the following code.
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'package:techamp_flutter_shopping_app/app.dart';
GetIt getIt = GetIt.instance;
void registerDependencies() {
_registerConfigurations();
_registerProduct();
_registerProfile();
_registerCart();
}
void _registerCart() {
getIt.registerFactory<CartCubit>(() => CartCubit());
}
void _registerConfigurations() {
getIt.registerSingleton(
Dio(
BaseOptions(
baseUrl: 'https://fakestoreapi.com/',
connectTimeout: 20000,
receiveTimeout: 30000,
sendTimeout: 30000,
),
),
);
}
void _registerProduct() {
getIt
..registerFactory<ProductRepository>(() => ProductRepositoryImpl(getIt()))
..registerFactory(() => ProductsCubit(getIt()));
}
void _registerProfile() {
getIt
..registerFactory<ProfileRepository>(() => ProfileRepositoryImpl(getIt()))
..registerFactory(() => ProfileCubit(getIt()));
}
Inside the product_screen.dart file under src/product/view/products/ add the following code:
class _ProductsErrorWidget extends StatelessWidget {
final String error;
const _ProductsErrorWidget({Key key, @required this.error})
: assert(error != null),
super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
error,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
),
const SizedBox(height: 16),
Align(
child: RaisedButton(
onPressed: context.read<ProductsCubit>().refresh,
child: const Text('Retry'),
),
),
],
);
}
}