March 31, 2022 - 10 min

Flutter – Bloc vs. Riverpod State Management for Beginners


				
				

Noa Tubic

Flutter developer

State management is simply a solution that takes care of the changes that occur in the application. There are many state management solutions, and deciding which one to use can be overwhelming, especially to someone relatively new to Flutter or without much experience.


When working on complex and large production applications, it is crucial to choose a proper state management solution to make the application respond to the user accordingly and to be able to keep track of every change inside the application.


In this blog, we will cover the basics of Bloc and Riverpod on a simple fetch data from the API example and see how they simplify state management.


The idea behind the application


Slika-zaslona-2022-03-31-u-14.02.30.png
The user interface of the application

The main idea is to compare Bloc and Riverpod and see their similarities and dierences. We will use a Random Fact API, and every time we press the buttons the API will be called. The random fact will then be returned and displayed on a card. By pressing the first button, we will demonstrate state management using Riverpod, and by pressing the second button state management using Bloc.


Project setup


Create a new Flutter application and add required dependencies in pubspec.yaml file.




name: bloc_vs_riverpod_example
description: A new Flutter project.

version: 1.0.0+1

environment:
  sdk: ">=2.15.1 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^4.0.4
  flutter_bloc: ^8.0.1
  equatable: ^2.0.3
  flutter_riverpod: ^1.0.3
  connectivity_plus: ^2.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0
  bloc_test: ^9.0.2
  state_notifier_test: ^0.0.4
  mockito: ^5.0.17
  build_runner: ^2.1.7

flutter:
  uses-material-design: true



Plugins and packages:



  • dio: A powerful HTTP client for Dart, which supports Interceptors, Global configuration, FormData, Request Cancellation, File downloading, Timeout, etc.

  • flutter_bloc: The Flutter package makes it easy to integrate blocs and cubits into Flutter.

  • equtable: Dart package that helps implement value-based equality without needing to override explicitly == and hashCode.

  • flutter_riverpod: A simple way to access state from anywhere in your application while robust and testable.

  • connectivity_plus: Flutter plugin for discovering the state of the network (WiFi & mobile/cellular) connectivity on Android and iOS.

  • bloc_test: Testing library, which makes it easy to test blocs.

  • state_notifier_test: A testing library that makes it easy to test StateNotifier.

  • mockito: A mock framework inspired by Mockito with APIs for Fakes, Mocks, behavior verification, and stubbing.

  • build_runner: A build system for Dart code generation and modular compilation.


Project Structure


Common folder contains network services and models that handle our application’s network logic and are independent, and can be easily used across the application.


Data folder contains model classes that store and convert the data fetched from the API service.


Domain folder contains repositories that connect our data and presentation layer.


Presentation folder contains blocs, providers, and cubits, responsible for the state management, and pages and widgets, responsible for the user interface design.


Test folder contains tests for testing the features of Bloc, state notifier, and cubit.


This is a basic structure of clean architecture in the Flutter community, but you can modify it according to your needs and choice, just as we did for this application. Clean architecture is significant and makes our code understandable, testable, and maintainable, so we can easily change or add something to our application in the future.


Rest API


This project will use Random Facts API from here and this endpoint.


The response is JSON in this format:




{
    "id": "0b17d5b9-79dc-4ca1-81f1-215bd259fa7a",
    "text": "Starfish haven`t got brains.",
    "source": "djtech.net",
    "source_url": "http://www.djtech.net/humor/useless_facts.htm",
    "language": "en",
    "permalink": "https://uselessfacts.jsph.pl/0b17d5b9-79dc-4ca1-81f1-215bd259fa7a"
  }



Data Model


Create a data model called fact.dart in the lib/data/models folder. You can transform the JSON to Dart using Quicktype, and paste the code into the file. In our application, we only need id and text properties so you can delete the rest.




class Fact {
    String id;
    String text;
  
    Fact({
      required this.id,
      required this.text,
    });
  
    factory Fact.fromJson(Map<String, dynamic> json) => Fact(
          id: json['id'] as String,
          text: json['text'] as String,
        );
  
    Map<String, dynamic> toJson() {
      final Map<String, dynamic> data = <String, dynamic>{};
      data['id'] = id;
      data['text'] = text;
  
      return data;
    }
  }



API service and API response


Create a file called api_service.dart in lib/common/network folder and api_response.dart in lib/common/models. In ApiService class, we use the dio plugin to get a response from the API and the ApiResponse class to wrap all the API response structures.


This way, our application’s network logic is reusable and optimized for fetching data from any API, and it’s not dependent just on a specific JSON model.




class ApiResponse<T> {
    final bool success;
    final T? data;
    final String? error;
  
    ApiResponse({this.success = true, this.data, this.error})
        : assert(
            (success && error == null && data != null) ||
                (!success && error != null && data == null),
          );
  }





import 'package:dio/dio.dart';


import '../models/api_response.dart';
import '../../data/models/fact.dart';


class ApiService {
  final Dio _dio = Dio();


  final String _url = 'https://uselessfacts.jsph.pl/random.json?language=en';


  Future<ApiResponse<Fact>> fetchData() async {
    try {
      Response response = await _dio.get(_url);
      return ApiResponse(
        data: Fact.fromJson(response.data),
      );
    } catch (error, stacktrace) {
      // ignore: avoid_print
      print("Exception occured: $error stackTrace: $stacktrace");


      return ApiResponse(
        success: false,
        error: "Data not found / Connection issue",
      );
    }
  }
}



State management


State is data that changes in the lifecycle of our application. To track all the changes happening in our application, we require some state management solution, so it can respond to the user accordingly and track data through the dierent screens.


Create a state folder in the presentation folder and bloc and state_notifier folders inside the state folder. Then create fact_bloc.dart and fact_event.dart files in bloc folder and fact_notifier.dart in state_notifier folder.


For this example application, place the fact_state.dart file outside the bloc folder since Riverpod’s state notifier also responds with the same states, so we don’t duplicate the code.


State


As already mentioned above, the state is data that changes in the lifecycle of our application. There are five states in this project: FactInitialState, FactLoadingState, FactLoadedState, FactErrorState and FactNoInternetState.



  • FactInitialState: The application is in its initial state, and nothing has happened yet.

  • FactLoadingState: defines that the repository is waiting for the response from the server or is processing data.

  • FactLoadedState: is passed, with a random fact, upon the successful data fetching.

  • FactErrorState: is passed with an error message when something goes wrong.

  • FactNoInternetState: is passed when there is no internet connection available.




part of 'bloc/fact_bloc.dart';

@immutable
abstract class FactState extends Equatable {
  const FactState();

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

class FactInitialState extends FactState {}

class FactLoadingState extends FactState {}

class FactLoadedState extends FactState {
  final Fact fact;

  const FactLoadedState(this.fact);
}

class FactErrorState extends FactState {
  final String error;

  const FactErrorState(this.error);
}

class FactNoInternetState extends FactState {}





import '../common/network/api_service.dart';
import '../common/models/api_response.dart';
import '../data/models/fact.dart';


class ApiRepository {
  final _provider = ApiService();


  Future<ApiResponse<Fact>> fetchFact() {
    return _provider.fetchData();
  }
}



Event


Event represents that something has happened in the application. We can think of events as the input of the Bloc and states as the output. When the application receives an event, business logic gets executed, modifies our application’s state, and reflects it with some changes in the user interface.


There are only two events in this application: LoadFactEvent and NoInternetEvent.



  • LoadFactEvent is triggered when the device is connected to the internet and the button is pressed.

  • NoInternetEvent is triggered when there is no internet connection.




part of 'fact_bloc.dart';

@immutable
abstract class FactEvent extends Equatable {
  const FactEvent();
}

class LoadFactEvent extends FactEvent {
  @override
  List<Object> get props => [];
}

class NoInternetEvent extends FactEvent {
  @override
  List<Object?> get props => [];
}



Bloc


In fact_bloc.dart file, we put all our business logic. On button press, LoadFactEvent is triggered, and Bloc is requesting data from ApiRepository.


While the data is getting fetched, FactLoadingState is emitted, and a circular progress indicator is displayed. If data is fetched successfully, FactLoadedState is emitted, and a random fact is displayed to the user, and if not, FactErrorState is emitted, and an Error message is displayed to the user.




import 'package:bloc/bloc.dart';
import '../../../data/models/fact.dart';
import '../../../domain/api_repository.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
part 'fact_event.dart';
part '../fact_state.dart';

class FactBloc extends Bloc<FactEvent, FactState> {
  final ApiRepository _apiRepository;

  FactBloc(
    this._apiRepository,
  ) : super(FactInitialState()) {
    on<LoadFactEvent>(
      (event, emit) async {
        emit(FactLoadingState());
        final fact = await _apiRepository.fetchFact();
        if (fact.success) {
          emit(
            FactLoadedState(fact.data!),
          );
        } else {
          emit(
            FactErrorState(fact.error!),
          );
        }
      },
    );
  }
}



Riverpod


Providers, as a crucial part of Riverpod, are objects that encapsulate a piece of state and allow listening to that state. We are using a StateNotifierProvider to listen and expose a StateNotifier. Create fact_state_notifier.dart file in presentation/state/state_notifier.


Define a state notifier provider to implement FactState so that we can expose states. Similar to our fact bloc logic, on button press, the initial state is changed to FactLoadingState, and circular progress indicator is displayed, and data is requested from ApiRepository.


If the data is fetched successfully, the state is changed to FactLoadedState, and the random fact is displayed to the user. And if not, the state is changed to FactErrorState, and an error message is displayed to the user.




import 'package:bloc_vs_riverpod_example/presentation/state/bloc/fact_bloc.dart';
import '../../../domain/api_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final factsNotifierProvider = StateNotifierProvider<FactsNotifier, FactState>(
  (ref) => FactsNotifier(
    ApiRepository(),
  ),
);

class FactsNotifier extends StateNotifier<FactState> {
  final ApiRepository _apiRepository;

  FactsNotifier(this._apiRepository) : super(FactInitialState());

  void load() async {
    state = FactLoadingState();
    final fact = await _apiRepository.fetchFact();
    if (fact.success) {
      state = FactLoadedState(fact.data!);
    } else {
      state = FactErrorState(fact.error!);
    }
  }
}



Checking internet connection continuously


Using connectivity plus, we are listening to the changes in connection. Create network_cubit.dart in the presentation/state/network folder. NetworkInfoCubit depends on the connectivity plugin and can react to connection changes, so we can track if there is an internet connection on our device and emit dierent states accordingly.




import 'package:bloc/bloc.dart';
import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkInfoCubit extends Cubit<ConnectivityResult> {
  NetworkInfoCubit(Connectivity _connectivityService)
      : super(ConnectivityResult.wifi) {
    _connectivityService.onConnectivityChanged.listen((event) {
      connectionChanged(event);
    });
  }
  connectionChanged(ConnectivityResult result) {
    emit(result);
  }
}

bool isConnected(ConnectivityResult connection) =>
    connection != ConnectivityResult.none;



Pages


The user interface is straightforward, just one screen with two buttons and two cards.


Create a custom button, LoadFactButton, which excepts these arguments:


onPressed: specifies the way of changing the state and builds the UI using Bloc or Riverpod.


connected: if there is an internet connection, the button is enabled and can be pressed, and if there is no internet connection, the button is disabled and can’t be pressed.


state: if the current state is FactLoadingState, a circular progress indicator is displayed while data is being fetched, and if the state is FactLoadedState, a random fact is displayed in the card.




class LoadFactButton extends StatelessWidget {
    final FactState state;
    final Function()? onPressed;
    final bool isConnected;
    const LoadFactButton(
        {Key? key,
        required this.state,
        this.onPressed,
        required this.isConnected})
        : super(key: key);
  
    @override
    Widget build(BuildContext context) {
      return Column(
        children: [
          ElevatedButton(
            onPressed: isConnected ? onPressed : null,
            child: const Text('Load random fact'),
          ),
          const SizedBox(height: 25),
          if (state is FactLoadingState)
            const Center(child: CircularProgressIndicator()),
          if (state is FactLoadedState)
            FactCard(
              (state as FactLoadedState).fact,
            ),
        ],
      );
    }
  }



Slika-zaslona-2022-03-31-u-14.02.13.png
Buttons appearance according to the state

Buttons appearance according to the state


After creating the Bloc and implementing all the functionalities we need to provide the fact bloc to the widget tree, so we can display random facts in the cards after pressing the buttons.


Wrap the Scaold with a BlocProvider, so our fact bloc is provided to its children. This way, we are initializing the Bloc before using it. Then wrap one of the LoadFactButtons with BlocBuilder<FactBloc, FactState> so we can rebuild the UI depending on the state and pass read<FactBloc>().add(LoadFactEvent(), as onPressed argument.


For widgets to be able to read the factsNotifierProvider, wrap the entire application with ProviderScope. This is where the state of our provider will be stored. Riverpod lets us access providers, by reference and not by type.


Replace StatelessWidget with ConsumerWidget, so our build method can receive an extra ref parameter and extend the _FactsState with ConsumerState, so the state has a ref object.


With ref.watch(factsNotifierProvider) we watch the factsNotifierProvider’s state and with passing ref.read(factsNotifierProvider.notifier).load() as onPressed argument to second LoadEventButton we can set a new state and load the random fact.


Also, wrap the entire app with a BlocProvider and pass the NetworkInfoCubit, and wrap the Scaold with BlocBuilder<NetworkInfoCubit, ConnectivityResult> so we can track network changes in our application and emit the right state accordingly.




import 'package:bloc_vs_riverpod_example/data/models/fact.dart';
import 'package:bloc_vs_riverpod_example/domain/api_repository.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/bloc/fact_bloc.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/network/network_cubit.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../state/state_notifier/facts_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class FactsPage extends ConsumerStatefulWidget {
  const FactsPage({Key? key}) : super(key: key);

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

class _FactsState extends ConsumerState<FactsPage> {
  @override
  Widget build(BuildContext context) {
    final ApiRepository repo = ApiRepository();
    final state = ref.watch(factsNotifierProvider);
    return BlocBuilder<NetworkInfoCubit, ConnectivityResult>(
      builder: (context, connectivityState) {
        return BlocProvider(
          create: (context) => FactBloc(repo),
          child: Scaold(
            appBar: AppBar(
              title: const Text('Random Facts'),
            ),
            body: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                LoadFactButton(
                  state: state,
                  onPressed: () =>
                      ref.read(factsNotifierProvider.notifier).load(),
                  isConnected: isConnected(connectivityState),
                ),
                const SizedBox(height: 35),
                BlocBuilder<FactBloc, FactState>(
                  builder: (context, state) {
                    return LoadFactButton(
                      state: state,
                      onPressed: () =>
                          context.read<FactBloc>().add(LoadFactEvent()),
                      isConnected: isConnected(connectivityState),
                    );
                  },
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

class LoadFactButton extends StatelessWidget {
  final FactState state;
  final Function()? onPressed;
  final bool isConnected;
  const LoadFactButton(
      {Key? key,
      required this.state,
      this.onPressed,
      required this.isConnected})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: isConnected ? onPressed : null,
          child: const Text('Load random fact'),
        ),
        const SizedBox(height: 25),
        if (state is FactLoadingState)
          const Center(child: CircularProgressIndicator()),
        if (state is FactLoadedState)
          FactCard(
            (state as FactLoadedState).fact,
          ),
      ],
    );
  }
}

class FactCard extends StatelessWidget {
  final Fact fact;
  const FactCard(this.fact, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          ListTile(
            leading: const Icon(Icons.lightbulb),
            title: Text(fact.text),
          ),
        ],
      ),
    );
  }
}





import 'package:bloc_vs_riverpod_example/presentation/state/network/network_cubit.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'presentation/screen/facts_page.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: BlocProvider(
        create: (context) => NetworkInfoCubit(Connectivity()),
        child: MaterialApp(
          title: 'Random Fact',
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            primarySwatch: Colors.teal,
          ),
          home: const FactsPage(),
        ),
      ),
    );
  }
}



Testing


Since we are focusing on state management, we are testing fact bloc and network info cubit using bloc_test package, fact state notifier using state_notifier_test package and using mockito package for creating mocks we need for testing.


To use Mockito’s generated mock run build_runner with this command:


dart <span class="hljs-built_in">run</span> build_runner build

Build runner generates a file with a name based on the file containing the @GenerateMocks annotation.


Write some tests to verify if the logic of the fact bloc is correct or not. For example, we are testing the logic which runs upon receiving the LoadFactEvent.




import 'package:bloc_test/bloc_test.dart';
import 'package:bloc_vs_riverpod_example/common/models/api_response.dart';
import 'package:bloc_vs_riverpod_example/data/models/fact.dart';
import 'package:bloc_vs_riverpod_example/domain/api_repository.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/bloc/fact_bloc.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'facts_bloc_test.mocks.dart';

@GenerateMocks([ApiRepository])
void main() {
  final mockApiRepo = MockApiRepository();
  final fact = Fact(id: '', text: '');

  blocTest<FactBloc, FactState>(
    'Block emits [] when no events are added.',
    build: () => FactBloc(mockApiRepo),
    expect: () => const <FactState>[],
  );

  blocTest(
    'Bloc emits [FactLoadingState, FactLoadedState] when api is fetched successfully',
    build: () => FactBloc(mockApiRepo),
    setUp: () async {
      when(mockApiRepo.fetchFact()).thenAnswer(
        (realInvocation) async {
          return ApiResponse(
            success: true,
            data: fact,
          );
        },
      );
    },
    act: (FactBloc bloc) {
      bloc.add(LoadFactEvent());
    },
    expect: () => [
      FactLoadingState(),
      FactLoadedState(fact),
    ],
    tearDown: () {
      reset(mockApiRepo);
    },
    verify: (FactBloc bloc) => verify(mockApiRepo.fetchFact()).called(1),
  );

  blocTest(
    'Bloc [FactLoadingState, FactErrorState] when api is not fetched successfully',
    build: () => FactBloc(mockApiRepo),
    setUp: () async {
      when(mockApiRepo.fetchFact()).thenAnswer(
        (realInvocation) async {
          return ApiResponse(
            success: false,
            error: 'Error',
          );
        },
      );
    },
    act: (FactBloc bloc) {
      bloc.add(LoadFactEvent());
    },
    expect: () => [
      FactLoadingState(),
      const FactErrorState('Error'),
    ],
    verify: (FactBloc bloc) => verify(mockApiRepo.fetchFact()).called(1),
  );
}



Write some tests to verify if the logic of the fact state notifier is correct or not. For example, we are testing the logic which runs upon calling the load() method.




import 'package:bloc_vs_riverpod_example/common/models/api_response.dart';
import 'package:bloc_vs_riverpod_example/data/models/fact.dart';
import 'package:bloc_vs_riverpod_example/domain/api_repository.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/bloc/fact_bloc.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/state_notifier/facts_notifier.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:state_notifier_test/state_notifier_test.dart';

import 'facts_state_notifier_test.mocks.dart';

@GenerateMocks([ApiRepository])
void main() {
  final mockApiRepo = MockApiRepository();
  final fact = Fact(id: '', text: '');

  stateNotifierTest<FactsNotifier, FactState>(
    'Emits [] when no methods are called',
    build: () => FactsNotifier(mockApiRepo),
    actions: (FactsNotifier stateNotifier) {},
    expect: () => [],
  );

  stateNotifierTest<FactsNotifier, FactState>(
      'Emits [FactLoadingState, FactLoadedState] when api is fetched successfully',
      build: () => FactsNotifier(mockApiRepo),
      setUp: () async {
        when(mockApiRepo.fetchFact()).thenAnswer(
          (realInvocation) async {
            return ApiResponse(
              data: fact,
              success: true,
            );
          },
        );
      },
      actions: (FactsNotifier stateNotifier) {
        stateNotifier.load();
      },
      expect: () => [
            FactLoadingState(),
            FactLoadedState(fact),
          ]);

  stateNotifierTest<FactsNotifier, FactState>(
      'Emits [FactLoadingState, FactLoadedState] when api is not fetched successfully',
      build: () => FactsNotifier(mockApiRepo),
      setUp: () async {
        when(mockApiRepo.fetchFact()).thenAnswer(
          (realInvocation) async {
            return ApiResponse(
              error: 'Error',
              success: false,
            );
          },
        );
      },
      actions: (FactsNotifier stateNotifier) {
        stateNotifier.load();
      },
      expect: () => [
            FactLoadingState(),
            const FactErrorState('Error'),
          ]);



Write some tests to verify if the logic of the network info cubit is correct or not. We are testing the logic which runs upon changing the connection types.




import 'dart:async';
import 'package:bloc_test/bloc_test.dart';
import 'package:bloc_vs_riverpod_example/presentation/state/network/network_cubit.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'network_cubit_test.mocks.dart';

class MockNetworkInfoCubit extends MockCubit<ConnectivityResult>
    implements NetworkInfoCubit {}

@GenerateMocks([Connectivity])
void main() {
  test('Emits dierent connection types when connection is changed', () {
    final _connectivityService = MockConnectivity();
    final _cubit = MockNetworkInfoCubit();

    whenListen(
      _cubit,
      Stream.fromIterable([
        ConnectivityResult.mobile,
        ConnectivityResult.none,
        ConnectivityResult.wifi,
      ]),
      initialState: ConnectivityResult.wifi,
    );

    when(_connectivityService.onConnectivityChanged)
        .thenAnswer((realInvocation) {
      return Stream.fromIterable([
        ConnectivityResult.mobile,
        ConnectivityResult.none,
        ConnectivityResult.wifi,
      ]);
    });

    expectLater(
      _cubit.stream,
      emitsInOrder(
        <ConnectivityResult>[
          ConnectivityResult.mobile,
          ConnectivityResult.none,
          ConnectivityResult.wifi
        ],
      ),
    );
  });
}



Run the tests using a Testing menu or use a terminal to run the tests by executing the following commands:




flutter test test/bloc/fact_bloc_test.dart





flutter test test/state_notifier/fact_state_notifier.dart





flutter test test/cubit/network_cubit_test.dart



Conclusion


Both Bloc and Riverpod are powerful and advanced state management solutions. Every state management solution has its pros and cons, and as we stated earlier, there isn’t a solution that is best for every single application.


It’s important to choose the one that works best for you or your team and the project. If you are new to Bloc or Riverpod or don’t have much experience in state management in Flutter, I hope this blog helped you understand the basics and how they work. Here is the GitHub link to the project.


Give Kudos by sharing the post!

Share:

ABOUT AUTHOR

Noa Tubic

Flutter developer

Creative and open to new challenges, making mistakes, and trying again, Noa is working as a Flutter developer. If not coding, you can find him making music in his bedroom studio. In addition, he enjoys skiing, hiking, and many other sports activities. Also, to mention, he is a big sneaker fan.