The idea behind this article is to present you guys and girls a project that might be usefull as a basic organization architecture with lots of resources that are used on real development on Flutter, taking into consideration a more professional approach during development, mainly to work in a team environment. Therefore, it would take a simple fork and some adaptations to use it in any project. So, let’s just get to the point.

Index:

At companies we usually work as a team, we have a well defined procecss lifecycle of building a software, where the new features go through the most different stages, as the development team, the test team, the QA team [depending on the company, naming might change], and finally goes the production. And each one of these stages might need something different, like a URL from an API used for development, other one to QA and consequently another to production.

This is the concept of Android Flavors (or schemes on iOS)! Where we would make available a flavor (a version) of our App that in some situations behaves differently. Imagine the situation where our app has a free and a paid version, each one could be a flavor. But in our case we’ll take into consideration only the app lifecycle stages.

The Flutter SDK by itself has the build modes, in which we could do something different based only on the current running mode, they are:

Debug: It is the only mode we can run on emulators, are known by the ‘debug’ banner shown while running the app. The assertions are enabled, as well as the observatory. It has a bigger final package size, once it is optimized just for development. To run it only takes a flutter run .

It is the only mode we can run on emulators, are known by the ‘debug’ banner shown while running the app. The assertions are enabled, as well as the observatory. It has a bigger final package size, once it is optimized just for development. To run it only takes a . Profile: It still keeps some debug functionalities, but it can only be executed on physical devices to maintain a real performance. To it, we can use flutter run --profile .

It still keeps some debug functionalities, but it can only be executed on physical devices to maintain a real performance. To it, we can use . Release: It also runs only on physical devices, it is the mode we use to generate the final package to publish at the stores, after all it’s optimized to execution and final binary size, it can be used with flutter run --release or flutter build .

It turns out that when we want to make the app available to tests, or the QA team, it is interesting for it to be on release mode, so that the performance will be optimized and the same as the final user would experience. So a combination of flavors and build modes would fit perfectly in this case.

Flavors in Dart:

To define our flavors in dart and make it available everywhere in our code, we’ll start with flavor_config.dart :

enum Flavor { DEV, QA, PRODUCTION } class FlavorValues { FlavorValues({@required this.baseUrl}); final String baseUrl; //Add other flavor specific values, e.g database name } class FlavorConfig { final Flavor flavor; final String name; final Color color; final FlavorValues values; static FlavorConfig _instance; factory FlavorConfig({ @required Flavor flavor, Color color: Colors.blue, @required FlavorValues values}) { _instance ??= FlavorConfig._internal( flavor, StringUtils.enumName(flavor.toString()), color, values); return _instance; } FlavorConfig._internal(this.flavor, this.name, this.color, this.values); static FlavorConfig get instance { return _instance;} static bool isProduction() => _instance.flavor == Flavor.PRODUCTION; static bool isDevelopment() => _instance.flavor == Flavor.DEV; static bool isQA() => _instance.flavor == Flavor.QA; }

In Flavor enum we declare the flavors we’ll use, and FlavorValues will be responsible of keeping all the specific values from each flavor, like urls or database names, I do not recommend you to use it to store sensitive data as access tokens, for that you may use something like flutter_secure_storage.

The last one, FlavorConfig is the one we’ll use to configure our flavor. It could be defined as an InheritedWidget , but the idea is to be able to easily access it from anywhere of our app to validate which one is the current flavor, in that case, we would be breaking an abstraction rule by accessing a widget on bloc, or even difficult more our lives having to pass it through parameter to every layer of the app. That’s why I set up it to be a singleton, easily accessible everywhere.

Now for each flavor, we create the main file, used to start up the app

main_dev.dart : Our development flavor.

: Our flavor. main_qa.dart : Our flavor to qa team.

: Our flavor to team. main_production.dart : Our flavor to the final product.

void main() { FlavorConfig(flavor: Flavor.QA, color: Colors.deepPurpleAccent, values: FlavorValues(baseUrl: "https://raw.githubusercontent.com/JHBitencourt/ready_to_go/master/lib/json/person_qa.json")); runApp(MyApp()); }

In main_qa.dart above we can use the FlavorConfig created previously to define a flavor, a custom color, and some values. The same must be done with main_dev.dart and main_production.dart .

class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Ready to Go', home: HomePage(), ); } }

class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Flutter Ready to Go')), body: Center(child: Text("Flavor: ${FlavorConfig.instance.name}")), ); } }

In app.dart we create MyApp that simply calls the HomePage . And this one uses FlavorConfig to print the current flavor. To run our app we must choose the flavor desired:

flutter run -t lib/main_qa.dart

flutter run -t lib/main_dev.dart

flutter run -t lib/main_production.dart

The command above will run our app in debug mode, with -t or --target we define the starter point, the main file.

Visually identifying each flavor:

Wouldn’t be amazing to visually identify which is the current flavor running, without the need of a text printing its name, occupying space on screen? Well, we’ve got a really good example with debug build mode, where a banner is shown on the very top-right corner of our app. Why not use the same concept with our flavors? Let’s get our hands dirty.

class FlavorBanner extends StatelessWidget { final Widget child; BannerConfig bannerConfig; FlavorBanner({@required this.child}); @override Widget build(BuildContext context) { if(FlavorConfig.isProduction()) return child; bannerConfig ??= _getDefaultBanner(); return Stack( children: <Widget>[ child, _buildBanner(context) ], ); } BannerConfig _getDefaultBanner() { return BannerConfig( bannerName: FlavorConfig.instance.name, bannerColor: FlavorConfig.instance.color ); } Widget _buildBanner(BuildContext context) { return Container( width: 50, height: 50, child: CustomPaint( painter: BannerPainter( message: bannerConfig.bannerName, textDirection: Directionality.of(context), layoutDirection: Directionality.of(context), location: BannerLocation.topStart, color: bannerConfig.bannerColor ), ), ); } } class BannerConfig { final String bannerName; final Color bannerColor; BannerConfig({ @required String this.bannerName, @required Color this.bannerColor}); }

Our FlavorBanner uses a Stack to position the banner above a child widget, and in case we’re running on production, obviously, it won’t show a banner. BannerConfig has a label and color, and with the help of a CustomPaint , we can use BannerPainter (the same one used on debug banner) to draw our banner at the very top-left corner of our app.

Widget build(BuildContext context) { return FlavorBanner( child: Scaffold( appBar: AppBar(title: Text('Flutter Ready to Go')), body: Center(child: Text("Flavor: ${FlavorConfig.instance.name}")), ), ); }

Enveloping the Scaffold from Homepage with the banner, we’ll get the result:

Identifying device info with the Banner

Wouldn’t it be even more amazing, if we clicked our banner, and it opens a dialog with some info of the device running our app? Of course, it would! With that our test or QA team can gather more precise information in case of any different behavior among the smartphones used. For that, we’ll use a plugin called device_info.

enum BuildMode { DEBUG, PROFILE, RELEASE } class DeviceUtils { static BuildMode currentBuildMode() { if (const bool.fromEnvironment('dart.vm.product')) { return BuildMode.RELEASE; } var result = BuildMode.PROFILE; //Little trick, since assert only runs on DEBUG mode assert(() { result = BuildMode.DEBUG; return true; }()); return result; } static Future<AndroidDeviceInfo> androidDeviceInfo() async { DeviceInfoPlugin plugin = DeviceInfoPlugin(); return plugin.androidInfo; } static Future<IosDeviceInfo> iosDeviceInfo() async { DeviceInfoPlugin plugin = DeviceInfoPlugin(); return plugin.iosInfo; } }

Our class DeviceUtils has 3 utility methods, androidDeviceInfo() and iosDeviceInfo() will basically get the device information based on the platform with the plugin help. And currentBuildMode() will help us identify in which build mode we’re running, so, if the environment has a flag dart.vm.product it means we’re on RELEASE, if we’re able to run and validate an assert() , then, we’re on debug, on the contrary, we’ll be running on PROFILE.

class DeviceInfoDialog extends StatelessWidget { DeviceInfoDialog(); @override Widget build(BuildContext context) { return AlertDialog( contentPadding: EdgeInsets.only(bottom: 10.0), title: Container( padding: EdgeInsets.all(15.0), color: FlavorConfig.instance.color, child: Text( 'Device Info', style: TextStyle(color: Colors.white), ), ), titlePadding: EdgeInsets.all(0), content: _getContent(), ); } Widget _getContent() { if (Platform.isAndroid) { return _androidContent(); } if (Platform.isIOS) { return _iOSContent(); } return Text("You're not on Android neither iOS"); } //Widget _iOSContent() {} omitted for simplicity Widget _androidContent() { return FutureBuilder( future: DeviceUtils.androidDeviceInfo(), builder: (context, AsyncSnapshot<AndroidDeviceInfo> snapshot) { if (!snapshot.hasData) return Container(); AndroidDeviceInfo device = snapshot.data; return SingleChildScrollView( child: Column( children: <Widget>[ _buildTile('Flavor:', '${FlavorConfig.instance.name}'), _buildTile('Build mode:', '${StringUtils.enumName(DeviceUtils.currentBuildMode().toString())}'), _buildTile('Physical device?:', '${device.isPhysicalDevice}'), _buildTile('Manufacturer:', '${device.manufacturer}'), _buildTile('Model:', '${device.model}'), _buildTile('Android version:', '${device.version.release}'), _buildTile('Android SDK:', '${device.version.sdkInt}') ], ), ); }); } Widget _buildTile(String key, String value) { return Padding( padding: EdgeInsets.all(5.0), child: Row( children: <Widget>[ Text(key, style: TextStyle(fontWeight: FontWeight.bold), ), Text(value) ], ), ); } }

With DeviceInfoDialog we’ll use Platform.isAndroid and Platform.isIOS to validate which one we’re running, based on the result we can use the corresponding method from DeviceUtils to load the device info. It’s worth mentioning that, the info we get from the Android platform, is different from iOS, in the code above I omitted the iOS part for simplicity, but you can see the full source code on github.

Now the setup is done, we can add a GestureDetector on our FlavorBanner to identify a longPress and open our dialog.

Widget _buildBanner(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, child: Container( width: 50, height: 50, child: CustomPaint( painter: BannerPainter( message: bannerConfig.bannerName, textDirection: Directionality.of(context), layoutDirection: Directionality.of(context), location: BannerLocation.topStart, color: bannerConfig.bannerColor ), ), ), onLongPress: () { showDialog(context: context, builder: (BuildContext context) { return DeviceInfoDialog(); }); }, ); }

And here comes the magic:

FlavorBanner Dialog.

That’s it, with a simple long touch on the banner, we can identify if it is a physical device, the platform, model, manufacturer, OS version, among other stuff. This may not seem so useful, but remember, our app can be tested by a complete different QA team, the way they want it, and in any case, we as devs can only request a print to see de dialog info.

Flavors in android:

Even if the dart flavors are quite handy, it may be interesting to also define platform flavors, this way we could set a personalized icon or app name, based only on the flavor. So let’s change android/app/build.gradle to add the productFlavors :

flavorDimensions "flavors" productFlavors { dev { dimension "flavors" applicationIdSuffix ".dev" versionNameSuffix "-dev" } qa { dimension "flavors" applicationIdSuffix ".qa" versionNameSuffix "-qa" } prod { dimension "flavors" } }

The dimension name can be anyone, as long as it is the same to all flavors, in dev and qa we can also define an id and version suffix. To run our app now we must use the following commands:

Running each flavor on DEBUG mode: flutter run –flavor qa -t lib/main_qa.dart flutter run –flavor dev -t lib/main_dev.dart flutter run –flavor prod -t lib/main_production.dart

Running each flavor on PROFILE mode: flutter run –profile –flavor qa -t lib/main_qa.dart flutter run –profile –flavor dev -t lib/main_dev.dart flutter run –profile –flavor prod -t lib/main_production.dart

Running each flavor on RELEASE mode: flutter run –release –flavor qa -t lib/main_qa.dart flutter run –release –flavor dev -t lib/main_dev.dart flutter run –release –flavor prod -t lib/main_production.dart



Sometimes between changing flavors is necessary a flutter clean to clean our app build files.

App icons based on flavor:

To dynamically change the app icon is simple, just go to android/app/src and create a directory to each flavor, like {flavor}/res , except for the production one, this will use the default main directory. Inside each one’s res folder, create the mipmaps just like the ones from main/res , now inside each mipmap folder we put the corresponding flavor icon. All icons must have the same names, I recommend to keep the default name provided, ic_launcher. The final structure should look like this:

When running the different flavors, we’ll have:

Different icons to each flavor.

App name based on flavor:

We can use the same logic to naming, inside each res directory we create a values folder with one file named strings.xml inside. To each one of these, we define a string app_name with a custom name.

-- android/app/src/main/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">Ready to Go</string> </resources> -- android/app/src/dev/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">[dev] Ready to Go</string> </resources> -- android/app/src/qa/res/values/strings.xml <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">[qa] Ready to Go</string> </resources>

Now inside android/app/src/main/AndroidManifest.xml , we reference this new string as a parameter to android:label :

<application android:name="io.flutter.app.FlutterApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher">

With that done, each flavor will have a custom name:

Different names to each flavor

To generate icons of different sizes, you can use a package called flutter_launcher_icons

Running flavors with the IDE:

Even though I still prefer to run the app using the IDE’s terminal, in case you use IntelliJ or Android Studio, it may be interesting to create custom running configurations to each build mode + flavor. For example:

Edit the running configurations and create a new one for Flutter.

Then just give it a name, choose the corresponding main file, the initialization args (for profile and release only), and the flavor defined on build.gradle . With this, we could achieve something like the following:

Using flavor values:

Now we’ll simulate the following scenario:

In case we’re running on dev: I want my repository to use a local json, to develop and test my app quickly, without depending on an API return.

I want my repository to use a local json, to develop and test my app quickly, without depending on an API return. In case we’re running on qa: I want my repository to make a real request REST, in an API used to testing.

I want my repository to make a real request REST, in an API used to testing. In case we’re running on production: I want my repository to make a real request REST, in an API optimized to production.

So let’s create some files:

class PersonJson { static PersonJson _instance = new PersonJson.internal(); PersonJson.internal(); factory PersonJson() => _instance; static String getJson() { return ''' { "person": { "apiUrl": "Local", "name": "Julio", "lastName": "Bitencourt", "github": "github.com/JHBitencourt", "twitter": "@JuliooHB", "website": "juliobitencourt.com" } } '''; } }

PersonJson is a Singleton that will contain our json to be used locally, in case we’re running on dev. To qa and production, we’ll use the GitHub’s raw URLs from person_qa.json and person_production.json, simulating and API address.

library person; import 'package:json_annotation/json_annotation.dart'; part 'person.g.dart'; @JsonSerializable() class Person { String apiUrl; String name; String lastName; String github; String twitter; String website; Person(); factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json); Map<String, dynamic> toJson() => _$PersonToJson(this); }

On our Person bean, with the help of json_serializable we can generate a person.g.dart to transform our json into an object.

class BaseApi { static const int TIMEOUT_SECONDS = 5; final String baseUrl = FlavorConfig.instance.values.baseUrl; Future<dynamic> get(String url) async { final response = await http.get(url) .timeout(const Duration(seconds: TIMEOUT_SECONDS), onTimeout: _onTimeout); final responseJson = json.decode(response.body); return responseJson; } Future<http.Response> _onTimeout() { throw new SocketException("Timeout during request"); } }

The BaseApi will help us doing the REST calls, in this class, we get the baseUrl defined to the flavor, and also put a timeout of 5 seconds on each call, throwing an exception if it takes longer. We could add other methods in this class, like post, put, among others, even private calls with authentication headers.

class ApiProvider extends BaseApi { Future<Person> fetchData() async { var responseJson; if(FlavorConfig.isDevelopment()) { responseJson = json.decode(PersonJson.getJson()); await new Future.delayed(new Duration(seconds: 2)); } else { String url = "${baseUrl}"; responseJson = await get(url); } return Person.fromJson(responseJson['person']); } }

Our provider will extend from BaseApi , make the API call and deserialize the json into a Person . Notice that if we’re on dev, instead of making a request, we take the local json and simulate a delay of 2 seconds.

class Repository { static Repository _instance = new Repository.internal(); Repository.internal(); factory Repository() => _instance; final ApiProvider _api = new ApiProvider(); Future<Person> fetchData() async { return _api.fetchData(); } }

Repository is also a singleton, responsible for knowing where to get the data from. In our example we only make an API call, but we could create some cache strategies for example, getting data from a database.

Now it’s time to create a Bloc, to fully understanding it (including the implementation I used), I really recommend reading Didier.

abstract class BlocBase { void dispose(); } class BlocProvider<T extends BlocBase> extends StatefulWidget { BlocProvider({ Key key, @required this.child, @required this.bloc, }) : super(key: key); final T bloc; final Widget child; @override State<StatefulWidget> createState() => _BlocProviderState<T>(); static T of<T extends BlocBase>(BuildContext context) { final type = _typeOf<_BlocProviderInherited<T>>(); _BlocProviderInherited<T> provider = context .ancestorInheritedElementForWidgetOfExactType(type) ?.widget; return provider?.bloc; } static Type _typeOf<T>() => T; } class _BlocProviderState<T extends BlocBase> extends State<BlocProvider<T>> { @override Widget build(BuildContext context) { return new _BlocProviderInherited( child: widget.child, bloc: widget.bloc ); } @override void dispose() { widget.bloc?.dispose(); super.dispose(); } } class _BlocProviderInherited<T> extends InheritedWidget { _BlocProviderInherited({ Key key, @required Widget child, @required this.bloc }) : super(key: key, child: child); final T bloc; @override bool updateShouldNotify(InheritedWidget oldWidget) => false; }

The BlocProvider is a combination of StatefulWidget and InheritedWidget to give us the bloc instance directly from the widget tree.

class HomeBloc implements BlocBase { final Repository _repository = Repository(); bool _dataLoaded = false; final StreamController<Person> _personController = new StreamController<Person>(); Stream<Person> get personStream => _personController.stream; bool get dataLoaded => _dataLoaded; void fetchData() async { try { final person = await _repository.fetchData(); _personController.sink.add(person); } catch(e) { print("Temporarily printing errors " + e); } } @override void dispose() { _personController.close(); } }

HomeBloc is our bloc with business logic from HomePage , we make available a stream to be listened to, getting a person, so, in fetchData() we call Repository and add the result into streamController . If any error occurs by now, we’re only printing on the console.

Now we modify MyApp to add a Bloc, as well as the HomePage to print some info on the person returned by request.

class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider<HomeBloc>( bloc: HomeBloc(), child: MaterialApp( title: 'Ready to Go', onGenerateRoute: _routes, ), ); } Route _routes(RouteSettings settings) { if (settings.isInitialRoute) return MaterialPageRoute( builder: (context) { var homePage = HomePage(); final homebloc = BlocProvider.of<HomeBloc>(context); homebloc.fetchData(); return FlavorBanner( child: homePage, ); } ); } }

class HomePage extends StatelessWidget { HomeBloc _bloc; @override Widget build(BuildContext context) { _bloc = BlocProvider.of<HomeBloc>(context); return Scaffold( appBar: AppBar(title: Text('Flutter Ready to Go')), body: StreamBuilder( stream: _bloc.personStream, builder: (context, AsyncSnapshot<Person> snapshot) { if(!snapshot.hasData) return Center(child: CircularProgressIndicator()); final person = snapshot.data; return _buildPersonInfo(person); } ), ); } Widget _buildPersonInfo(Person person) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ _buildText("Api URL: ${person.apiUrl}", 22.0) ], ), _buildText("Name: ${person.name} ${person.lastName}", 16.0), _buildText("Github: ${person.github}", 16.0), _buildText("Twitter: ${person.twitter}", 16.0), _buildText("Website: ${person.website}", 16.0), ], ); } Widget _buildText(String text, double fontSize) { return Padding( padding: EdgeInsets.only(top: 20.0, right: 10.0, left: 10.0), child: Text(text, style: TextStyle( fontWeight: FontWeight.bold, color: Colors.grey[800], fontSize: fontSize ) ), ); }

With only that we achieved the desired result, our API behavior is changing based on the flavor.

Controlling connection errors:

For those apps that have the necessity of making API requests (or any type of internet connection), it is essential we handle any possible errors on these calls, and of course, have the knowledge of what kind of connection our user is currently using, or even if they are really connected, mainly to make decisions based on this information, ensuring the best experience while using our app, preventing the user from any unexpected error.

To help us with this task, let’s use a plugin from flutter team called connectivity, that will allow us to identify the user’s connectivity status. It’s worth emphasizing that the user may be using a VPN or a hotel WiFi, which does not guarantee internet connection, that’s why we’ll also guard our code against timeouts.

abstract class ConnectivityHandler { void onError(String error); void onConnectivityChanged(ConnectivityResult connectivity); }

ConnectivityHandler is an abstract class that we’ll use to trigger connection callbacks.

void fetchData(ConnectivityHandler delegate) async { try { final person = await _repository.fetchData(); _dataLoaded = true; _personController.sink.add(person); } catch(e) { print("Temporarily printing errors " + e); delegate.onError(e.toString()); } }

We changed fetchData() from HomeBloc to receive a delegate (a class that implements ConnectivityHandler ), and in case of any error during a request, we trigger it trough delegate.onError() . But the thing is, besides identifying possible errors, it would be interesting to be warned when the user changes his connection status, but lucky for us, the plugin already provides a stream for us to listen to these changes.

class ApplicationBloc implements BlocBase { final List<ConnectivityHandler> _delegates = List(); StreamSubscription<ConnectivityResult> _connectivity; ConnectivityResult _currentConnectivity; ApplicationBloc() { _init(); } void _init() { _connectivity = Connectivity().onConnectivityChanged.listen((ConnectivityResult result) { if(_currentConnectivity != null) { for (var delegate in _delegates) { delegate.onConnectivityChanged(result); } } _currentConnectivity = result; }); } void listenConnectivity(ConnectivityHandler handler) => _delegates.add(handler); ConnectivityResult get currentConnectivity => _currentConnectivity; @override void dispose() { _connectivity.cancel(); } }

This time we created the ApplicationBloc , this one will keep a list of _delegates and trigger a delegate.onConnectivityChanged() to them, on each connectivity’s status change. I chose to use a different Bloc because this one can manage more general application rules, like the business logic to login and authentication, for example.

class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider<ApplicationBloc>( bloc: ApplicationBloc(), child: MaterialApp( title: 'Ready to Go', onGenerateRoute: _routes, ), ); } Route _routes(RouteSettings settings) { if (settings.isInitialRoute) return MaterialPageRoute( builder: (context) { final homePage = HomePage(); final applicationBloc = BlocProvider.of<ApplicationBloc>(context); applicationBloc.listenConnectivity(homePage); final homebloc = HomeBloc(); homebloc.fetchData(homePage); return BlocProvider<HomeBloc>( bloc: homebloc, child: FlavorBanner( child: homePage, ), ); } ); } }

We changed MyApp to include the ApplicationBloc above all the app’s widget tree. In our rout, we’ve registered the homePage to listen to the bloc’s connectivity stream.

Now the HomePage needs to implement ApplicationBloc to be able to manage connectivity errors and status.

class HomePage extends StatelessWidget implements ConnectivityHandler { final _keyScaffold = new GlobalKey<ScaffoldState>(); HomeBloc _bloc; @override Widget build(BuildContext context) { _bloc = BlocProvider.of<HomeBloc>(context); return Scaffold( key: _keyScaffold, appBar: AppBar(title: Text('Flutter Ready to Go')), body: StreamBuilder( stream: _bloc.personStream, builder: (context, AsyncSnapshot<Person> snapshot) { if(!snapshot.hasData) return Center(child: CircularProgressIndicator()); final person = snapshot.data; return _buildPersonInfo(person); } ), ); } ... omitted data @override void onConnectivityChanged(ConnectivityResult connectivity) { showSnackBar("Connectivity changed to ${connectivity}"); if (!_bloc.dataLoaded && (connectivity == ConnectivityResult.wifi || connectivity == ConnectivityResult.mobile)) { _bloc.fetchData(this); } } @override void onError(String error) { showSnackBar(error); } void showSnackBar(String text, [int time = 2000]){ final snackbar = new SnackBar( content: new Text(text), duration: Duration(milliseconds: time), ); _keyScaffold?.currentState.showSnackBar(snackbar); } }

Now in case of any error or change of connectivity status, we’ll show a snackBar on screen with the info.

Notification working when changing the connection

If any timeout occurs, this would be the behavior:

Error notification on request’s timeout

Notice by the gif that after the error, if the status is changed, we’ll make a new API request.

That’s it, in this article we’ve seen some aspects of code architecture and organization that for sure, will help our Flutter codes to be more clean and professional. The final package structure will be this one:

Final considerations:

I didn’t include iOS schemes on this article, because at the moment I’m not able to compile to iOS, but as soon as I get my hands on a Mac I’ll update this article. It is on my roadmap to also include on the project a complete authentication flow.

Have you found it useful? Leave a ⭐ on the Github repo 😉