Personal blog
preface
Since the development of the OpenGit_Flutter project, it has been difficult to decide which architecture to choose in the project. Recently, the two architectures BloC and Redux have been tried in the project respectively, and the appropriate scheme has been found through the problems encountered in the development. In order to demonstrate convenience, I chose the login process of the project to demonstrate for you. The login process is disassembled below.
- Login first need to enter the account and password, only when the account and password are entered, the login button at the bottom can be clicked, so need to monitor the account and password input box input state, used to control the click state of the login button;
- The account input box must support the one-click deletion function.
- The password input box must support the function of visible password.
- Click the login button to trigger the login logic. The loading interface needs to be displayed during the login. If the login fails, cancel the loading interface and propose a toast message. After a successful login, the main screen displays basic user information.
- The saving of user profiles and tokens is not mentioned in this article. To view this code, click on OpenGit_Flutter.
The final demo looks like this
Login interface layout code, not too much introduction, if you need to know more, you can view the source code, the address will be posted at the end of this article.
Engineering structure
The flutter_architecture root directory is a Flutter Package, under which four projects bloc, MVC, MVP and Redux are created respectively. The lib directory is the common module of the four projects, such as network request, log printing, toast prompt, home page information display, etc. As shown in the figure below
MVC
This architecture was added last when I wrote the Flutter_Architecture example because it is often compared to MVP during Android development.
Architectural view
Program entrance
Dart is the entry point of the program, and the login interface is started. The relevant code is shown below
void main() => runApp(MVCApp());
class MVCApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
returnMaterialApp( theme: ThemeData( primaryColor: Colors.black, ), home: LoginPage(), ); }}Copy the code
The login process
Because the login state involves the refresh of the interface related controls, the StatefulWidget inherits.
The text to monitor
Text listeners need to listen for the input state of the account and password fields by declaring two TextEditingController objects, as shown below
final TextEditingController _nameController = new TextEditingController();
final TextEditingController _passwordController = new TextEditingController();
Copy the code
InitState handles the listening event of the input box, and when the input state changes, it refreshes the page and updates the login button state, as shown below
@override
void initState() {
super.initState();
_nameController.addListener(() {
setState(() {});
});
_passwordController.addListener(() {
setState(() {});
});
}
Copy the code
The status of the login button is determined by the length of the input string of the account and password. The button can be clicked only when the length of the input string is greater than 0
_isValidLogin() {
String name = _nameController.text;
String password = _passwordController.text;
return name.length > 0 && password.length > 0;
}
Copy the code
The login button UI layer code is shown below
Align _buildLoginButton(BuildContext context) {
return Align(
child: SizedBox(
height: 45.0,
width: 270.0,
child: RaisedButton(
child: Text(
'login',
style: Theme.of(context).primaryTextTheme.headline,
),
color: Colors.black,
onPressed: _isValidLogin()
? () {
_login();
}
: null,
shape: StadiumBorder(side: BorderSide()),
),
),
);
}
Copy the code
Clear the account input box
To clear the input field, simply call the TextEditingController Clear method, as shown in the code below
TextFormField _buildNameTextField() {
return new TextFormField(
controller: _nameController,
decoration: new InputDecoration(
labelText: 'making account:,
suffixIcon: new GestureDetector(
onTap: () {
_nameController.clear();
},
child: new Icon(_nameController.text.length > 0 ? Icons.clear : null),
),
),
maxLines: 1,); }Copy the code
Password visible
Whether the password is visible is mainly realized by updating variable _obscureText. The click event processing logic is very simple, just reverse operation of _obscureText and refresh the page, the code is shown as follows
TextFormField _buildPasswordTextField(BuildContext context) {
return new TextFormField(
controller: _passwordController,
decoration: new InputDecoration(
labelText: 'making password:,
suffixIcon: newGestureDetector( onTap: () { setState(() { _obscureText = ! _obscureText; }); }, child:new Icon(_obscureText ? Icons.visibility_off : Icons.visibility),
),
),
maxLines: 1,
obscureText: _obscureText,
);
}
Copy the code
Trigger the login
Click the login button in the View layer to trigger the login logic in the Control layer. State controls the display and hiding of loading interface in the Control layer, and the final state of loading is determined by the loading state in the Model layer. The relevant codes of loading UI are shown as follows:
Offstage( offstage: ! Con.isLoading, child:new Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
color: Colors.black54,
child: new Center(
child: SpinKitCircle(
color: Theme.of(context).primaryColor,
size: 25.0(), (), (), (Copy the code
Define the Control layer
Firstly, the singleton object is created, and the Model layer data is initialized to provide the interface of login, loading state, user information and other states to the View layer. The relevant code is shown as follows
class Con {
factory Con() => _getInstance();
static Con get instance => _getInstance();
static Con _instance;
Con._internal();
static Con _getInstance() {
if (_instance == null) {
_instance = new Con._internal();
}
return _instance;
}
static final model = Model();
static bool get isLoading => model.isLoading;
static UserBean get userBean => model.userBean;
Future login(State state, String name, String password) async {
state.setState(() {
_showLoading();
});
await model.login(name, password);
state.setState(() {
_hideLoading();
});
}
void _showLoading() {
model.showLoading();
}
void_hideLoading() { model.hideLoading(); }}Copy the code
Define the Model layer
The Model layer mainly performs network requests for login, obtaining user data, and saving the loading state and user data. The relevant codes are shown as follows
class Model {
bool get isLoading => _isLoading;
bool _isLoading = false;
UserBean get userBean => _userBean;
UserBean _userBean;
Future login(String name, String password) async {
final login = await LoginManager.instance.login(name, password);
// Authorization succeeded
if(login ! =null) {
final user = await LoginManager.instance.getMyUserInfo();
_userBean = user;
}
return;
}
void showLoading() {
_isLoading = true;
}
void hideLoading() {
_isLoading = false; }}Copy the code
The network layer code is no longer posted, but you can download the source code at the end of this article.
As can be seen from the above code, when the View layer triggers login, the login interface of the Control layer is invoked. In this interface, the loading state is displayed and the network request for login is waited. When the request is completed, the loading state is cancelled and the data is finally handed over to the View layer for processing
_login() async {
String name = _nameController.text;
String password = _passwordController.text;
await Con.instance.login(this, name, password);
if(Con.userBean ! =null) {
NavigatorUtil.goHome(context, Con.userBean);
} else {
ToastUtil.showToast('Login failed, please log in again'); }}Copy the code
At this point, the MVC framework login process is complete.
MVP
This architecture is a common architecture in Android development.
Architectural view
Program entrance
Dart is the entry point of the program, and the login interface is started. The relevant code is shown below
void main() => runApp(MVPApp());
class MVPApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
returnMaterialApp( theme: ThemeData( primaryColor: Colors.black, ), home: LoginPage(), ); }}Copy the code
The login process
Consistent with MVC, you can refer to MVC.
The text to monitor
Consistent with MVC, you can refer to MVC.
Clear the account input box
Consistent with MVC, you can refer to MVC.
Password visible
Consistent with MVC, you can refer to MVC.
Trigger the login
Click the login button in the View layer to trigger the login logic in the Presenter layer. In the Presenter layer, the loading interface can be displayed and hidden through the interface provided by the View layer. The relevant codes of loading UI are shown as follows
Offstage( offstage: ! isLoading, child:new Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
color: Colors.black54,
child: new Center(
child: SpinKitCircle(
color: Theme.of(context).primaryColor,
size: 25.0(), (), (), (Copy the code
The isLoading state in the code above has been uniformly encapsulated in the base class. The MVP definition is encapsulated in the following.
Encapsulate the View layer
To trigger a network request, you need to show and hide the loading interface, so the View needs to provide the two basic interfaces, as shown in the following code
abstract class IBaseView {
showLoading();
hideLoading();
}
Copy the code
Encapsulation Presenter layer
The public interface for the Presenter layer only provides registration and unregistration to the View layer, as shown in the code below
abstract class IBasePresenter<V extends IBaseView> {
void onAttachView(V view);
void onDetachView();
}
Copy the code
The code below implements the interface provided above for the Presenter layer, as shown below
abstract class BasePresenter<V extends IBaseView> extends IBasePresenter<V> {
V view;
@override
void onAttachView(IBaseView view) {
this.view = view;
}
@override
void onDetachView() {
this.view = null; }}Copy the code
Encapsulate the State base class
In the State base class, you need to provide initialization methods for Presenter, loading State, data initialization, and view construction, as shown in the code below
abstract class BaseState<T extends StatefulWidget.P extends BasePresenter<V>,
V extends IBaseView> extends State<T> implements IBaseView {
P presenter;
bool isLoading = false;
P initPresenter();
Widget buildBody(BuildContext context);
void initData() {
}
@override
void initState() {
super.initState();
presenter = initPresenter();
if(presenter ! =null) {
presenter.onAttachView(this);
}
initData();
}
@override
void dispose() {
super.dispose();
if(presenter ! =null) {
presenter.onDetachView();
presenter = null; }}@override
@mustCallSuper
Widget build(BuildContext context) {
return new Scaffold(
body: buildBody(context),
);
}
@override
void showLoading() {
setState(() {
isLoading = true;
});
}
@override
void hideLoading() {
setState(() {
isLoading = false; }); }}Copy the code
At this point, the MVP framework has been packaged, the login interface to do the corresponding implementation.
Implementing logon logic
When logging in, the Presenter layer needs to provide a login interface to the View layer. After logging in, the View layer needs to provide feedback on the login status. Therefore, the View layer needs to provide two interfaces: successful and failed login, as shown in the following code
abstract class ILoginPresenter<V extends ILoginView> extends BasePresenter<V> {
void login(String name, String password);
}
abstract class ILoginView extends IBaseView {
void onLoginSuccess(UserBean userBean);
void onLoginFailed();
}
Copy the code
When the relevant interfaces are defined, first implement the code for the Presenter layer of the login, as shown below
class LoginPresenter extends ILoginPresenter {
@override
void login(String name, String password) async {
if(view ! =null) {
view.showLoading();
}
final login = await LoginManager.instance.login(name, password);
// Authorization succeeded
if(login ! =null) {
final user = await LoginManager.instance.getMyUserInfo();
if(user ! =null) {
if(view ! =null) {
view.hideLoading();
view.onLoginSuccess(user);
} else{ view.hideLoading(); view.onLoginFailed(); }}}else {
if(view ! =null) { view.hideLoading(); view.onLoginFailed(); }}}}Copy the code
The code to log in to State is then implemented, as shown below
class _LoginPageState extends BaseState<LoginPage.LoginPresenter.ILoginView>
implements ILoginView {
@override
void initData() {
super.initData();
}
@override
Widget buildBody(BuildContext context) {
return null;
}
@override
LoginPresenter initPresenter() {
return LoginPresenter();
}
@override
void onLoginSuccess(UserBean userBean) {
NavigatorUtil.goHome(context, userBean);
}
@override
void onLoginFailed() {
ToastUtil.showToast('Login failed, please log in again'); }}Copy the code
The relevant code has been wrapped, and you only need to invoke the logon-related logic, as shown in the following code
_login() {
if(presenter ! =null) {
String name = _nameController.text;
Stringpassword = _passwordController.text; presenter.login(name, password); }}Copy the code
BloC
About what is a BloC, can refer to [Flutter Package] the BloC of state management of encapsulation and Flutter | state management to explore – BloC (3).
Architectural view
Program entrance
Dart is the entry point of the program, and the login interface is started. The relevant code is shown below
void main() => runApp(BlocApp());
class BlocApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
returnMaterialApp( theme: ThemeData( primaryColor: Colors.black, ), home: BlocProvider<LoginBloc>( child: LoginPage(), bloc: LoginBloc(), ), ); }}Copy the code
The above code differs from MVC and MVP in that the object passed to home is BlocProvider, which contains instances of Child and Bloc. As shown in the code below
class BlocProvider<T extends BaseBloc> extends StatefulWidget {
final T bloc;
final Widget child;
BlocProvider({
Key key,
@required this.child,
@required this.bloc,
}) : super(key: key);
@override
_BlocProviderState<T> createState() {
return _BlocProviderState<T>();
}
static T of<T extends BaseBloc>(BuildContext context) {
final type = _typeOf<BlocProvider<T>>();
BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
return provider.bloc;
}
static Type _typeOf<T>() => T;
}
class _BlocProviderState<T> extends State<BlocProvider<BaseBloc>> {
static final String TAG = "_BlocProviderState";
@override
void initState() {
super.initState();
LogUtil.v('initState ' + T.toString(), tag: TAG);
}
@override
Widget build(BuildContext context) {
LogUtil.v('build ' + T.toString(), tag: TAG);
return widget.child;
}
@override
void dispose() {
super.dispose();
LogUtil.v('dispose '+ T.toString(), tag: TAG); widget.bloc.dispose(); }}Copy the code
The login process
BLoC allows us to separate the business logic without worrying about when we need to refresh the screen, leaving it all to The StreamBuilder and BLoC, so the login page inherits the StatelessWidget. As shown in the code below
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
returnStreamBuilder( stream: bloc.stream, initialData: initialData(), builder: (BuildContext context, AsyncSnapshot<LoadingBean<LoginBlocBean>> snapshot) { } ); }}Copy the code
stream
Represents the stream that the Stream Builder is listening for, in this caseLoginBloc
The stream;initData
Represents the initial value, because at the time of the first rendering, there is no interaction with the user, so no events will flow out of the stream, so the first rendering needs an initial value;builder
The function takes a location argument, BuildContext, and a snapshot. Snapshot is a snapshot of the output data of this stream. We can access the data in the snapshot through snapshot.data. The Builder in StreamBuilder is an AsyncWidgetBuilder that can asynchronously build widgets that will be rebuilt when data is detected coming out of the stream.
Create a BloC
First, the BloC base class is encapsulated. The base class only needs to meet the login status, as shown in the code below
class LoadingBean<T> {
bool isLoading;
T data;
LoadingBean({this.isLoading, this.data});
@override
String toString() {
return 'LoadingBean{isLoading: $isLoading, data: $data}'; }}abstract class BaseBloc<T extends LoadingBean> {
static final String TAG = "BaseBloc";
BehaviorSubject<T> _subject = BehaviorSubject<T>();
Sink<T> get sink => _subject.sink;
Stream<T> get stream => _subject.stream;
voiddispose() { _subject.close(); sink.close(); }}Copy the code
Create BloC instance
In the logged BloC instance, to complete the whole login process, we need to monitor the input status of the account and password, whether the password is visible, and the login status, as shown in the code below
class LoginBloc extends BaseBloc<LoadingBean<LoginBlocBean>> {
LoadingBean<LoginBlocBean> bean;
LoginBloc() {
bean = LoadingBean<LoginBlocBean>(
isLoading: false,
data: LoginBlocBean(
name: ' ',
password: ' ',
obscure: true,),); } changeObscure() { } changeName(String name) {
}
changePassword(String password) {
}
login(BuildContext context) async{}void _showLoading() {
bean.isLoading = true;
sink.add(bean);
}
void _hideLoading() {
bean.isLoading = false; sink.add(bean); }}Copy the code
The text to monitor
Create two instances of TextEditingController, account and password, and complete their event listening, as shown in the code below
final TextEditingController _nameController = new TextEditingController();
final TextEditingController _passwordController = new TextEditingController();
LoginBloc bloc = BlocProvider.of<LoginBloc>(context);
_nameController.addListener(() {
bloc.changeName(_nameController.text);
});
_passwordController.addListener(() {
bloc.changePassword(_passwordController.text);
});
Copy the code
When the text is changed, the corresponding change method in LoginBloc will be called, and the corresponding text will be re-complicated in the update interface through sink.add(), as shown in the code below
changeName(String name) {
bean.data.name = name;
sink.add(bean);
}
changePassword(String password) {
bean.data.password = password;
sink.add(bean);
}
Copy the code
Clear the account input box
Consistent with MVC, you can refer to MVC.
Password visible
To change the visible state, call the changeObscure method in LoginBloc, as shown in the code below
changeObscure() { bean.data.obscure = ! bean.data.obscure; sink.add(bean); }Copy the code
Trigger the login
Network request is required to control the display and hiding of loading. Login method in LoginBloc needs to be called here. If the login is successful, the main page will be switched to show basic information; if the login is unsuccessful, toast will be given, as shown in the code below
login(BuildContext context) async {
_showLoading();
final login =
await LoginManager.instance.login(bean.data.name, bean.data.password);
// Authorization succeeded
if(login ! =null) {
final user = await LoginManager.instance.getMyUserInfo();
if(user ! =null) {
NavigatorUtil.goHome(context, user);
} else {
ToastUtil.showToast('Login failed, please log in again'); }}else {
ToastUtil.showToast('Login failed, please log in again');
}
_hideLoading();
}
Copy the code
Redux
Redux is a widely used design pattern for web development, such as in react.js. An introduction to this topic can be found in the article “Flutter Redux”, which switches to the topic of Flutter.
Architectural view
Program entrance
Dart is the entry point of the program, and the login interface is started. The relevant code is shown below
void main() {
final store = new Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [
LoginMiddleware(),
],
);
runApp(
ReduxApp(
store: store,
),
);
}
class ReduxApp extends StatelessWidget {
final Store<AppState> store;
const ReduxApp({Key key, this.store}) : super(key: key);
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: StoreConnector<AppState, _ViewModel>(
converter: _ViewModel.fromStore,
builder: (context, vm) {
returnMaterialApp( theme: ThemeData( primaryColor: Colors.black, ), home: LoginPage(), ); },),); }}class _ViewModel {
_ViewModel();
static _ViewModel fromStore(Store<AppState> store) {
return_ViewModel(); }}Copy the code
At the entrance of the program, Store was initialized, and reducer, state and Middleware were initialized.
Define the action
To complete the login, there are several states, such as request login, request loading, request error, request success, as shown in the following code
class FetchLoginAction {
final BuildContext context;
final String userName;
final String password;
FetchLoginAction(this.context, this.userName, this.password);
}
class ReceivedLoginAction {
ReceivedLoginAction(
this.token,
this.userBean,
);
final String token;
final UserBean userBean;
}
class RequestingLoginAction {}
class ErrorLoadingLoginAction {}
Copy the code
Initialize the state
Currently there is only one login function, so only one login state is required, as shown in the code below
class AppState {
final LoginState loginState;
AppState({
this.loginState,
});
factory AppState.initial() => AppState(
loginState: LoginState.initial(),
);
}
class LoginState {
final bool isLoading;
final String token;
LoginState({this.isLoading, this.token});
factory LoginState.initial() {
return LoginState(
isLoading: false,
token: ' ',); } LoginState copyWith({bool isLoading, String token}) {
return LoginState(
isLoading: isLoading ?? this.isLoading,
token: token ?? this.token, ); }}Copy the code
Initialize the reducer
Currently there is only one login function, so there is only one Reducer for the login, as shown in the code below
AppState appReducer(AppState state, action) {
return AppState(
loginState: loginReducer(state.loginState, action),
);
}
final loginReducer = combineReducers<LoginState>([
TypedReducer<LoginState, RequestingLoginAction>(_requestingLogin),
TypedReducer<LoginState, ReceivedLoginAction>(_receivedLogin),
TypedReducer<LoginState, ErrorLoadingLoginAction>(_errorLoadingLogin),
]);
Copy the code
Initialize the middleware
The logged-in middleware does a simple initialization for now, as shown in the code below
class LoginMiddleware extends MiddlewareClass<AppState> {
static final String TAG = "LoginMiddleware";
@override
void call(Store store, action, NextDispatcher next) {
}
}
Copy the code
The login process
Redux allows us to separate the business logic without worrying about when we need to refresh the screen, leaving it to StoreConnector so that the login page inherits the StatelessWidget. As shown in the code below
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, LoginPageViewModel>(
distinct: true, converter: (store) => LoginPageViewModel.fromStore(store, context), builder: (_, viewModel) => LoginPageContent(viewModel), ); }}Copy the code
LoginPageViewModel is only responsible for login status and login behavior, as shown in the following code
typedef OnLogin = void Function(String name, String password);
class LoginPageViewModel {
static final String TAG = "LoginPageViewModel";
final OnLogin onLogin;
final bool isLoading;
LoginPageViewModel({this.onLogin, this.isLoading});
static LoginPageViewModel fromStore(
Store<AppState> store, BuildContext context) {
return LoginPageViewModel(
isLoading: store.state.loginState.isLoading,
onLogin: (String name, String password) {
LogUtil.v('name is $name, password is $password', tag: TAG); store.dispatch(FetchLoginAction(context, name, password)); }); }}Copy the code
The text to monitor
Consistent with MVC, you can refer to MVC.
Clear the account input box
Consistent with MVC, you can refer to MVC.
Password visible
Consistent with MVC, you can refer to MVC.
Trigger the login
To log in, we simply call the onLogin method inside LoginPageViewModel, which distributes the FetchLoginAction through the Store, at which point the middleware LoginMiddleware receives the action and processes it.
@override
void call(Store store, action, NextDispatcher next) {
next(action);
if (action isFetchLoginAction) { _doLogin(next, action.context, action.userName, action.password); }}Copy the code
If it is an action that you are interested in, you can proceed to handle the FetchLoginAction, as shown in the code below
Future<void> _doLogin(NextDispatcher next, BuildContext context,
String userName, String password) async {
next(RequestingLoginAction());
try {
LoginBean loginBean =
await LoginManager.instance.login(userName, password);
if(loginBean ! =null) {
String token = loginBean.token;
LoginManager.instance.setToken(loginBean.token, true);
UserBean userBean = await LoginManager.instance.getMyUserInfo();
if(userBean ! =null) {
next(ReceivedLoginAction(token, userBean));
NavigatorUtil.goHome(context, userBean);
} else {
ToastUtil.showToast('Login failed please login again');
LoginManager.instance.setToken(null.true); }}else {
ToastUtil.showToast('Login failed please login again'); next(ErrorLoadingLoginAction()); }}catch (e) {
LogUtil.v(e, tag: TAG);
ToastUtil.showToast('Login failed please login again'); next(ErrorLoadingLoginAction()); }}Copy the code
During login, the action that is being requested is initially issued as the RequestingLoginAction, the action ReceivedLoginAction is also issued when the login is successful, and the action ErrorLoadingLoginAction is issued when the login fails. These sent behaviors will be received by reducer, and the data will be processed and updated in the notification UI. The loginReducer processing logic is shown in the following code
LoginState _requestingLogin(LoginState state, action) {
LogUtil.v('_requestingLogin', tag: TAG);
return state.copyWith(isLoading: true);
}
LoginState _receivedLogin(LoginState state, action) {
LogUtil.v('_receivedLogin', tag: TAG);
return state.copyWith(isLoading: false, token: action.token);
}
LoginState _errorLoadingLogin(LoginState state, action) {
LogUtil.v('_errorLoadingLogin', tag: TAG);
return state.copyWith(isLoading: false);
}
Copy the code
conclusion
Local state and global state
In the login example above, any validation type of the login form can be considered a local state because the rules only apply to this component and the rest of the App does not need to know about the type. However, tokens and user data obtained from the background need to be considered global because it affects the scope of the entire app (unlogged and logged in) and other components may depend on it.
choose
Comparing the above four architectures, it comes back to state management. The state management of MVC and MVP adopts the setState approach, while BloC and Redux have their own state management.
It is ok to update data using setState when the project is not very complex initially. However, as functionality increases, your project will have dozens or even hundreds of states, and the number of setStates will increase significantly. Each time setState is called again, the build method will have an impact on performance and code readability. So we abandoned the MVC and MVP architectures.
Redux was used in the initial architectural reconstruction of OpenGit_Flutter. When multiple page reuse was involved, such as the project page of a project, a list of variables needed to be defined in state for each page reuse. This was a painful process, so we gave up using Redux later. But Redux has the advantage of saving global state, such as topics, languages, user profiles, and so on. BloC was later tried, and there was no Redux problem in the multi-page reuse of this architecture.
So the architecture I finally adopted was Bloc+Redux, with Bloc controlling the local state and Redux controlling the global state. Let me help you understand and choose a Flutter state management solution
The project address
- Architecture Sample: Flutter_architecture
- OpenGit_Flutter project: OpenGit_Flutter
- OpenGit_Flutter project BloC Attempt: OpenGit_Flutter
- OpenGit_Flutter project Redux attempts: OpenGit_Flutter