Application architecture
To build a maintainable app you need to start right, here I will describe the structure I used to build e-commerce app.
I will talk about the main concepts and why I have chosen this way of building it.
The Flutter app
This is the simplest step, just run flutter create architecture_app
, now open the project in you favourite editor and run it.
Add packaged
There are few packages to add to the dependencies
and dev_dependencies
open your ./pubspec.yaml
and add the following
dependencies:
bloc: ^3.0.0 # to manage state
flutter_bloc: ^3.2.0 # to make bloc pattern fun
graphql_flutter: ^3.0.0 # a graphql client
dev_dependencies:
artemis: ^5.1.0 # to generate dart classes from graphql queries
build_runner: ^1.5.0 # required by artemis
json_serializable: ^3.0.0 # required by artemis
Don’t forget to run flutter pub get
.
Setup build phase
get the schema
Go to https://api.graphqlplaceholder.com/ download the schema and put the file in the root of your project, it should be called schema.graphql
.
setup the build.yaml
Create a new file in the project root build.yaml
and paste the following
targets:
$default:
sources:
- lib/**
- graphql/**
- schema.graphql
builders:
artemis:
options:
generate_helpers: true
schema_mapping:
- schema: schema.graphql
queries_glob: graphql/*.graphql
output: lib/graphql/graphql_api.dart
The settings are self explanatory, we tell the build script to look for files in ./graphql/*.graphql
and the generated dart classes will be placed in ./lib/graphql/graphql_api.dart
.
Now try the build is working by running flutter pub run build_runner build
.
Once done you will get new files in ./lib/graphql
, do not worry about the errors for now!
Write the query & automagically generate dart classes
Create a new file ./graphql/post.graphql
and paste this simple query
query getPosts {
posts {
id
title
author {
id
name
}
}
}
And then run the build command flutter pub run build_runner build
.
You should get the new generated classes in ./lib/graphql/graphql_api.graphql.dart
have a look at the file, do not edit it.
Prepare GraphQL client
Create a file ./lib/client.dart
import 'package:flutter/foundation.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
final HttpLink _httpLink = HttpLink(
uri: 'https://api.graphqlplaceholder.com/',
);
final Link _link = _httpLink;
ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
cache: InMemoryCache(),
link: _link,
),
);
Post repository
This is the class that will handle all the networking, this will make your app modular,
easy to test, and easy to reason about, create the file in ./lib/repositories/post.dart
.
import 'package:flutter/foundation.dart';
import 'package:architecture_app/graphql/graphql_api.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
class PostRepository {
final GraphQLClient client;
PostRepository({
@required this.client,
});
Future<List<GetPosts$Query$Post>> getPosts() async {
final results = await client.query(
QueryOptions(
documentNode: GetPostsQuery().document,
),
);
print(results);
if (results.hasException) {
throw results.exception;
} else {
return GetPosts$Query.fromJson(results.data).posts;
}
}
}
When I told you we will do magic I meant it, the Artemis package did what it says on the tin and now you do not have to even worry about parsing the results from json.
BLoC files
I will have a BLoC for each screen that will handle all the business logic, the UI part will just render according to the state of the BLoC.
Home events
./lib/screens/home/home_event.dart
.
class HomeEvent {}
class HomeLoadEvent extends HomeEvent {}
We have just one event the home screen can send which is telling the BLoC to load the required data.
Home state
./lib/screens/home/home_state.dart
.
import 'package:flutter/foundation.dart';
import 'package:architecture_app/graphql/graphql_api.dart';
class HomeState {}
class HomeInitialState extends HomeState {}
class HomeLoadingState extends HomeState {}
class HomeLoadedState extends HomeState {
final List<GetPosts$Query$Post> posts;
HomeLoadedState({
@required this.posts,
});
}
class HomeErrorState extends HomeState {}
Beside the initial state, we have three possible cases: loading, error, or loaded state.
Home bloc
./lib/screens/home_bloc.dart
.
import 'package:flutter/foundation.dart';
import 'package:bloc/bloc.dart';
import 'bloc.dart';
import 'package:architecture_app/repositories/post.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final PostRepository postRepository;
HomeBloc({
@required this.postRepository,
});
@override
get initialState => HomeInitialState();
@override
Stream<HomeState> mapEventToState(event) async* {
if (event is HomeLoadEvent) {
try {
yield HomeLoadingState();
final posts = await postRepository.getPosts();
yield HomeLoadedState(
posts: posts,
);
} catch (error) {
yield HomeErrorState();
}
}
}
}
The BLoC code is supper simple when using this way, just get the posts from the repository and yield a new state according to the results.
Home screen
Create one file in ./lib/screens/home_screen.dart
.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc.dart';
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Architecture demo'),
),
body: Container(
child: BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
if (state is HomeLoadingState) {
return Container(
child: Center(
child: CircularProgressIndicator(),
),
);
} else if (state is HomeLoadedState) {
final posts = state.posts;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
key: Key(post.id),
title: Text(post.title),
subtitle: Text('By: ${post.author.name}'),
);
},
);
} else {
return Container(
child: Center(
child: Text('You have an error'),
),
);
}
},
),
),
);
}
}
Connect the home screen in main.dart
Open ./main.dart
and change all the code to the following.
import 'package:architecture_app/client.dart';
import 'package:architecture_app/screens/home/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:architecture_app/screens/home/bloc.dart';
import 'package:architecture_app/repositories/post.dart';
GraphQLClient _client = client.value;
PostRepository postRepository = PostRepository(
client: _client,
);
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Architecture demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: BlocProvider(
create: (context) => HomeBloc(
postRepository: postRepository,
)..add(HomeLoadEvent()),
child: HomeScreen(),
),
);
}
}
Conclusion
We have build a modular app, even thought it has one screen, you can add many screens and each screen will have its own BLoC. Using a repositories will separate the networking logic from the app logic, you can simply call different api endpoints, log every network request, stub the whole api when doing testing without touching any of the app code.
Full source code at https://github.com/agent3bood/Flutter-architecture-app/tree/starter-kit .