A Practical Guide to Clean Architecture in Mobile Development
In our previous article, we explored the SOLID principles and how they can be applied to improve the design of object-oriented software. In this article, we will dive into clean architecture, a software design pattern that complements SOLID principles by promoting separation of concerns and loose coupling. We will also explore how clean architecture can be implemented in Flutter development using the Nani Agritech project as a case study.
Clean architecture and SOLID principles share a common goal: to create software that is maintainable, testable, and scalable. Clean architecture achieves this by dividing the application into distinct layers, while SOLID principles focus on designing individual classes and modules.
Clean Architecture is a software design pattern that promotes separation of concerns. It divides an application into layers, each with a specific responsibility. The layers are arranged in a way that dependencies only flow inwards, from the outer layers to the inner layers. This means inward modules are neither aware nor dependent on outer modules. However, outer modules are both aware and dependent on inner modules. The more you move inward, the more abstraction is present. The more you move outward, the more concrete implementations are present. This makes the application more modular and easier to maintain.
This article will provide a practical guide to implementing clean architecture in Flutter development, using Nani Agritech project as a case study.
Clean Architecture Structure
As we can see in the diagram above, we have 3 main layers of the architecture: Data, Domain, and Presentation. We also have one optional layer called Core layer that contains the code for common utilities and services used by the other layers.
The arrows in the diagram shows the typical flow of data within a clean architecture implementation. Ideally, the arrows only point inwards, meaning higher-level layers should not depend on implementation details of lower layers. This reinforces loose coupling and promotes a more flexible and adaptable codebase.
Layers
Presentation layer acts as the bridge between the user and the core functionalities of the application. It’s responsible for everything the user interacts with visually and how they navigate through the app. This layer is the most framework-dependent layer, as it contains the UI and the event handlers of the UI. Common Components in the Presentation Layer:
- Widgets: The building blocks of the UI. Each screen is composed of various widgets that are nested together.
- Screens: Represent individual views within the application. They are composed of widgets and handle user interactions specific to that view.
- State Management: (Provider, BLoC, Riverpod) Manage the application state and facilitate communication between UI and business logic.
- Navigation: Handles transitions between different screens within the application.
Domain layer sits at the heart of the application architecture. It acts as a core, independent layer free from external dependencies like UI frameworks or data storage specifics. This isolation ensures the domain logic remains unaffected by changes in these areas. The domain layer primarily consists of three key components:
- Entities: These represent the core business objects of the application. They encapsulate data relevant to your domain and define its behavior. Entities are typically pure Dart classes with minimal dependencies.
- Use Cases: These encapsulate the application’s specific business rules. They orchestrate the domain logic, interacting with entities and repositories to define how tasks are accomplished.
- Repository Interfaces: These define contracts for accessing data. They act as abstractions, outlining the methods needed to retrieve or manipulate data without specifying the concrete implementation details. By using interfaces, the domain layer remains independent to how data is actually stored or retrieved.
Data layer acts as a bridge between the domain layer’s business logic and the application’s persistence mechanisms. It’s responsible for fetching, storing, and manipulating data, ensuring the domain layer remains independent of specific data sources. This layer primarily consists of two key components:
- Repositories: These are concrete implementations of the repository interfaces defined in the domain layer. They encapsulate data access logic, coordinating retrieval and manipulation of data from various data sources based on domain needs.
- Data Sources: These represent the origin or destination of data. They can be remote data sources that responsible for making network requests (e.g., HTTP calls) to APIs or external servers, or local data sources that responsible for caching or persisting data locally, using mechanisms like databases or file systems.
Nani Project Structure
In this project, I’ve adopted Clean Architecture to organize the code into distinct layers: core, data, domain, and presentation. Additionally, I’ve included a dedicated constants
folder to store fixed values like colors, themes, asset paths, strings, and more. Furthermore, a di
(dependency injection) folder centralizes dependency management and separates configuration concerns for each layer (data, domain, and presentation).
Each layer’s di
folder specifically configures the dependencies needed by that layer. These configurations use the GetIt container to register services (classes or objects) and their dependencies. This allows for centralized management and easier access to needed functionalities within each layer.
Let’s delve into how this architecture manages user flow, as demonstrated in the example below
Data Layer
user_repository_impl.dart
provides concrete implementations of the user repository interface defined in the domain layer. This separation allows us to define the user management logic (what needs to be done) in the domain layer, while keeping the details of data access (how it’s done) encapsulated in the data layer. This approach promotes loose coupling, meaning changes or additions to specific data sources (e.g., Firebase, local storage) can be made within the data layer without affecting the domain layer or the rest of the application.
# data/repository/user/user_repository_impl.dart
class UserRepositoryImpl extends UserRepository {
// shared pref object
final SharedPreferenceHelper _sharedPrefsHelper;
// api objects
final GoogleAuthApi _googleAuthApi;
final UserApi _userApi;
final RegisterApi _registerApi;
final LoginApi _loginApi;
// constructor
UserRepositoryImpl(this._sharedPrefsHelper, this._googleAuthApi,
this._userApi, this._registerApi, this._loginApi);
// Login:---------------------------------------------------------------------
@override
Future<User?> login(LoginParams params) async {
return await _loginApi.login(params).then((authToken) {
_sharedPrefsHelper.saveAuthToken(authToken);
return User();
}).catchError((error) {
throw error;
});
}
// Logout:--------------------------------------------------------------------
@override
Future<User?> logout() async {
return await Future.delayed(const Duration(seconds: 2), () => User());
}
// Register:------------------------------------------------------------------
@override
Future<User?> register(RegisterParams params) async {
return await _registerApi.registerUser(params).then((data) {
_sharedPrefsHelper.saveAuthToken(data['token']);
return User.fromJson(data['user']);
}).catchError((error) {
throw error;
});
}
@override
Future<User?> googleAuth(GoogleAuthParams params) async {
return await _googleAuthApi.googleAuth(params.idToken).then((authToken) {
_sharedPrefsHelper.saveAuthToken(authToken);
return User();
});
}
@override
Future<User> getUser() async {
return await _userApi.getUser().then((user) {
return user;
}).catchError((error) {
throw error;
});
}
@override
Future<UpdateUserParams> updateUser(UpdateUserParams params) async {
return await _userApi.updateUser(params).then((data) {
return data;
}).catchError((error) {
throw error;
});
}
@override
Future<void> saveAuthToken(String authToken) =>
_sharedPrefsHelper.saveAuthToken(authToken);
@override
Future<String?> get authToken => _sharedPrefsHelper.authToken;
@override
Future<void> saveIsLoggedIn(bool value) =>
_sharedPrefsHelper.saveIsLoggedIn(value);
@override
Future<bool> get isLoggedIn => _sharedPrefsHelper.isLoggedIn;
}
user_api.dart
ecapsulates the logic for interacting with a remote user API, acting as a bridge between the application and the external user data source.
# data/network/apis/auth/user_api.dart
class UserApi {
// dio instance
final DioClient _dioClient;
// injecting dio instance
UserApi(this._dioClient);
Future<User> getUser() async {
try {
final res = await _dioClient.dio.get(Endpoints.getUser);
if (res.data['status'] != "success") {
throw NetworkException(message: res.data['message']);
}
return User.fromJson(res.data['data']);
} catch (e) {
print(e.toString());
rethrow;
}
}
Future<UpdateUserParams> updateUser(UpdateUserParams params) async {
try {
final res = await _dioClient.dio.put(Endpoints.updateUser, data: {
"name": params.name,
"email": params.email,
"phone": params.phone,
});
return UpdateUserParams.fromJson(res.data['data']);
} catch (e) {
print(e.toString());
rethrow;
}
}
}
Domain Layer
The domain layer is the heart of the application architecture. It encapsulates the core business logic and entities, independent of external concerns like data storage or presentation details. This isolation ensures that domain entities remain unaffected by changes in the data layer (implementing the domain’s contracts) or the presentation layer (using the implemented contracts). This approach promotes loose coupling, making the application more maintainable and adaptable to future modifications.
The User
class serves as the core representation of a user within the application. It encapsulates all the essential user-related data like name, email, profile picture URL, phone number, and a role identifier. Additionally, it can hold references to other domain entities like Farmland
and IotInstance
through lists, suggesting potential relationships between users and these objects within the domain model. This class provides constructors for creating user objects and a factory constructor specifically designed to parse user data received in JSON format. This allows the application to create user objects from various sources like API responses. Furthermore, the toJson
method assists in converting user objects back into a JSON-compatible format, useful for sending user data over networks or persisting it in data storage.
# domain/entity/user/user.dart
class User {
String? name;
String? email;
Uri? photoUrl;
String? phone;
String? role;
List<Farmland>? farmlandList;
List<IotInstance>? iotInstanceList;
User({
this.name,
this.email,
this.photoUrl,
this.phone,
this.role,
this.farmlandList = const [],
this.iotInstanceList = const [],
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'],
email: json['email'],
photoUrl: Uri.parse(json['picture']),
phone: json['phone'],
role: json['role'],
farmlandList: (json['farmland_list'] as List?)
?.map((e) => Farmland.fromJson(e))
.toList() ??
[],
iotInstanceList: (json['iot_instance_list'] as List?)
?.map((e) => IotInstance.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'email': email,
'picture': photoUrl.toString(),
'phone': phone,
'role': role,
'farmland_list': farmlandList?.map((e) => e.toJson()).toList(),
'iot_instance_list': iotInstanceList?.map((e) => e.toJson()).toList(),
};
}
}
The UserRepository
class defines an abstract contract for any class responsible for handling user data access logic. It acts as a blueprint outlining the expected functionalities for user management. This interface specifies methods for various user operations, including logging in, logging out, registering new users, and authenticating with Google. Additionally, it defines methods for retrieving existing user information, updating user data, managing authentication tokens, and handling the user's login status. By employing an abstract interface, the domain layer remains independent to the specific implementation details of user data access. Concrete implementations of this interface, likely residing in UserRepositoryImpl
in the data layer, will handle interacting with actual data sources like databases or APIs.
# domain/repository/user/user_repository.dart
abstract class UserRepository {
Future<User?> login(LoginParams params);
Future<User?> logout();
Future<User?> register(RegisterParams params);
Future<User?> googleAuth(GoogleAuthParams params);
Future<User> getUser();
Future<UpdateUserParams> updateUser(UpdateUserParams params);
Future<void> saveAuthToken(String authToken);
Future<String?> get authToken;
Future<void> saveIsLoggedIn(bool value);
Future<bool> get isLoggedIn;
}
The LoginUseCase
demonstrates how Use Cases coordinate domain logic within the application. It focuses on the specific business logic related to user login. This class doesn't directly interact with data sources; instead, it relies on an injected UserRepository
to handle the actual login operation.
To perform the login, the LoginUseCase
requires user credentials. This is where the LoginParams
class comes into play. It acts as a dedicated value object specifically designed for login functionality. It encapsulates the essential information required during a login attempt, holding only the necessary fields: email
and password
. This approach promotes data immutability and prevents accidental inclusion of any unnecessary (or irrelevant) data in login requests.
# domain/usecase/user/login_usecase.dart
@JsonSerializable()
class LoginParams {
final String email;
final String password;
LoginParams({required this.email, required this.password});
factory LoginParams.fromJson(Map<String, dynamic> json) =>
_$LoginParamsFromJson(json);
Map<String, dynamic> toJson() => _$LoginParamsToJson(this);
}
class LoginUseCase implements UseCase<User?, LoginParams> {
final UserRepository _userRepository;
LoginUseCase(this._userRepository);
@override
Future<User?> call({required LoginParams params}) async {
return _userRepository.login(params);
}
}
Presentation Layer
This class, UserStore
, manages user login, registration, and data retrieval within the Bloc pattern (GetX). It acts as a central hub for user-related functionalities, interacting with UseCases in the domain layer and keeping the presentation layer (LoginScreen) separate.
Here’s a breakdown of its functionalities:
- Use Cases: The class holds references to various UseCases like
_isLoggedInUseCase
,_loginUseCase
, and_registerUseCase
. These UseCases handle core user logic like checking login status, logging in, and registering users. - Stores: It interacts with other stores like
FormErrorStore
andErrorStore
to manage form validation errors and general error messages displayed to the user. - Firebase Authentication: It utilizes
_auth
(FirebaseAuth instance) and_googleSignIn
for interacting with Firebase Authentication and Google Sign-In functionality. - Observables and Disposers: The class utilizes Observables (
authFuture
,isLoggedIn
, etc.) to manage asynchronous data streams and update UI accordingly. Disposers (_disposers
) ensure proper cleanup of these Observables when the widget is disposed of. - Login and Registration: Methods like
login
andregister
handle user login and registration processes. They call relevant UseCases, manage success/failure states through observables, and update user data if successful. - User Data Management: It retrieves and stores user data using
getUser
and updates it withupdateUser.
- Sign Out: Methods like
signOutFromGoogle
andlogout
handle signing out the user through Firebase and updating login status.
# presentation/auth/login/store/user_store.dart
class UserStore = _UserStore with _$UserStore;
abstract class _UserStore with Store {
// constructor:---------------------------------------------------------------
_UserStore(
this._isLoggedInUseCase,
this._saveLoginStatusUseCase,
this._googleAuthUseCase,
this._loginUseCase,
this._logoutUseCase,
this._registerUseCase,
this._getUserUseCase,
this._updateUserUseCase,
this.formErrorStore,
this.errorStore,
) {
// setting up disposers
_setupDisposers();
// checking if user is logged in
_isLoggedInUseCase.call(params: null).then((value) async {
isLoggedIn = value;
if (value) {
await getUser();
}
});
}
// use cases:-----------------------------------------------------------------
final IsLoggedInUseCase _isLoggedInUseCase;
final SaveLoginStatusUseCase _saveLoginStatusUseCase;
final GoogleAuthUseCase _googleAuthUseCase;
final LoginUseCase _loginUseCase;
final LogoutUseCase _logoutUseCase;
final RegisterUseCase _registerUseCase;
final GetUserUseCase _getUserUseCase;
final UpdateUserUseCase _updateUserUseCase;
// stores:--------------------------------------------------------------------
// for handling form errors
final FormErrorStore formErrorStore;
// store for handling error messages
final ErrorStore errorStore;
// firebase auth store
final fb.FirebaseAuth _auth = fb.FirebaseAuth.instance;
final GoogleSignIn _googleSignIn = GoogleSignIn();
// disposers:-----------------------------------------------------------------
late List<ReactionDisposer> _disposers;
void _setupDisposers() {
_disposers = [
reaction((_) => success, (_) => success = false, delay: 200),
];
}
// empty responses:-----------------------------------------------------------
static ObservableFuture<User?> emptyAuthResponse =
ObservableFuture.value(null);
// store variables:-----------------------------------------------------------
bool isLoggedIn = false;
@observable
User? user = User();
@observable
bool success = false;
@observable
bool logoutSuccess = false;
@observable
ObservableFuture<User?> authFuture = emptyAuthResponse;
@observable
fb.FirebaseAuth? firebaseUser = fb.FirebaseAuth.instance;
@computed
bool get isLoading => authFuture.status == FutureStatus.pending;
// actions:-------------------------------------------------------------------
@action
Future login(String email, String password) async {
final LoginParams loginParams =
LoginParams(email: email, password: password);
final future = _loginUseCase.call(params: loginParams);
authFuture = ObservableFuture(future);
await future.then((value) async {
if (value != null) {
await getUser();
await _saveLoginStatusUseCase.call(params: true);
isLoggedIn = true;
success = true;
}
}).catchError((e) {
if (e.toString().contains("400")) {
errorStore.errorMessage = "Invalid email or password";
} else {
errorStore.errorMessage = e.toString();
}
isLoggedIn = false;
success = false;
throw e;
});
}
@action
Future register(String name, String email, String phone, String password,
String confirmPassword) async {
final RegisterParams registerParams = RegisterParams(
name: name,
email: email,
phone: phone,
password: password,
confirmPassword: confirmPassword,
);
final future = _registerUseCase.call(params: registerParams);
authFuture = ObservableFuture(future);
await future.then((value) async {
if (value != null) {
user = value;
await _saveLoginStatusUseCase.call(params: true);
isLoggedIn = true;
success = true;
}
}).catchError((e) {
isLoggedIn = false;
success = false;
throw e;
});
}
@action
Future<void> signInWithGoogle() async {
try {
final GoogleSignInAccount? googleSignInAccount =
await _googleSignIn.signIn();
if (googleSignInAccount != null) {
final GoogleSignInAuthentication googleSignInAuthentication =
await googleSignInAccount.authentication;
final fb.AuthCredential credential = fb.GoogleAuthProvider.credential(
accessToken: googleSignInAuthentication.accessToken,
idToken: googleSignInAuthentication.idToken,
);
await _auth.signInWithCredential(credential);
fb.User fbUser = fb.FirebaseAuth.instance.currentUser!;
String? idToken = await fbUser.getIdToken();
final GoogleAuthParams googleAuthParams =
GoogleAuthParams(idToken: idToken);
final future = _googleAuthUseCase.call(params: googleAuthParams);
authFuture = ObservableFuture(future);
await future.then((value) async {
if (value != null) {
await _saveLoginStatusUseCase.call(params: true);
isLoggedIn = true;
success = true;
user = User(
name: firebaseUser!.currentUser!.displayName!,
email: firebaseUser!.currentUser!.email!,
photoUrl: Uri.parse(firebaseUser!.currentUser!.photoURL!),
phone: "not set",
role: "FARMER",
);
}
}).catchError((e) {
isLoggedIn = false;
success = false;
throw e;
});
}
} catch (e) {
isLoggedIn = false;
success = false;
rethrow;
}
}
@action
Future<void> getUser() async {
final future = _getUserUseCase.call(params: null);
return await future.then((value) {
user = value;
}).catchError((e) {
throw e;
});
}
@action
Future<void> updateUser(String name, String email, String phone) async {
final UpdateUserParams updateUserParams =
UpdateUserParams(name: name, email: email, phone: phone);
final future = _updateUserUseCase.call(params: updateUserParams);
return await future.then((value) {
user?.name = value.name;
user?.email = value.email;
user?.phone = value.phone;
}).catchError((e) {
errorStore.errorMessage = e.toString();
throw e;
});
}
@action
Future<bool> signOutFromGoogle() async {
try {
await fb.FirebaseAuth.instance.signOut();
return true;
} on Exception catch (_) {
return false;
}
}
@action
Future logout() async {
final future = _logoutUseCase.call(params:null);
authFuture = ObservableFuture(future);
await future.then((value) async {
if (value != null) {
await _saveLoginStatusUseCase.call(params: false);
isLoggedIn = false;
logoutSuccess = true;
}
}).catchError((e) {
isLoggedIn = true;
logoutSuccess = false;
throw e;
});
}
// general methods:-----------------------------------------------------------
void dispose() {
for (final d in _disposers) {
d();
}
}
}
- Interaction with Login Screen: The LoginScreen class (
_LoginScreenState
) interacts withUserStore
through getters and actions. It observes changes insuccess
,isLoading
, anderrorStore
to update UI elements like progress indicators and error messages.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
//text controllers:-----------------------------------------------------------
final TextEditingController _userEmailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
//stores:---------------------------------------------------------------------
final ThemeStore _themeStore = getIt<ThemeStore>();
final FormStore _formStore = getIt<FormStore>();
final UserStore _userStore = getIt<UserStore>();
//focus node:-----------------------------------------------------------------
late FocusNode _passwordFocusNode;
bool _isObscure = true;
@override
void initState() {
super.initState();
_passwordFocusNode = FocusNode();
}
@override
Widget build(BuildContext context) {
return Scaffold(
primary: true,
appBar: AppBar(),
body: _buildBody(),
);
}
// body methods:--------------------------------------------------------------
Widget _buildBody() {
return Material(
child: Stack(
children: <Widget>[
MediaQuery.of(context).orientation == Orientation.landscape
? Row(
children: <Widget>[
Expanded(
flex: 1,
child: _buildLeftSide(),
),
Expanded(
flex: 1,
child: _buildRightSide(),
),
],
)
: Center(child: _buildRightSide()),
Observer(
builder: (context) {
return _userStore.success
? navigate(context)
: _showErrorMessage(_userStore.errorStore.errorMessage);
},
),
Observer(
builder: (context) {
return Visibility(
visible: _userStore.isLoading,
child: const CustomProgressIndicatorWidget(),
);
},
)
],
),
);
}
Widget _buildLeftSide() {
return SizedBox.expand(
child: Image.asset(
Assets.padiBackground,
fit: BoxFit.cover,
),
);
}
Widget _buildRightSide() {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Masuk',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Baloo2',
fontSize: 24.0,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24.0),
_buildSignInGoogle(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
AppLocalizations.of(context).translate('login_other_opt'),
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Poppins',
fontSize: 16.0,
fontWeight: FontWeight.normal,
color: Colors.black,
),
),
),
_buildUserIdField(),
_buildPasswordField(),
_buildForgotPasswordButton(),
SizedBox(height: DeviceUtils.getScaledHeight(context, 0.1)),
_buildSignInButton(),
_buildSignUpButton(),
],
),
),
);
}
Widget _buildSignInGoogle() {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: const Color.fromRGBO(237, 237, 237, 1),
),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: MaterialButton(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset(
Assets.icGoogle,
height: 32.0,
width: 32.0,
),
const SizedBox(width: 16.0),
Text(
AppLocalizations.of(context)
.translate('login_btn_sign_in_google'),
style: const TextStyle(
fontFamily: 'Poppins',
fontSize: 16.0,
fontWeight: FontWeight.normal,
color: Colors.black,
),
),
],
),
onPressed: () {
_userStore.signInWithGoogle();
},
),
);
}
Widget _buildUserIdField() {
return Observer(
builder: (context) {
return TextFieldWidget(
hint: AppLocalizations.of(context).translate('login_et_user_email'),
inputType: TextInputType.emailAddress,
prefixIcon: Icons.email,
iconColor: _themeStore.darkMode ? Colors.white70 : Colors.black54,
textController: _userEmailController,
inputAction: TextInputAction.next,
autoFocus: false,
onChanged: (value) {
_formStore.setUserEmail(_userEmailController.text);
},
onFieldSubmitted: (value) {
FocusScope.of(context).requestFocus(_passwordFocusNode);
},
errorText: _formStore.formErrorStore.userEmail,
);
},
);
}
Widget _buildPasswordField() {
return Observer(
builder: (context) {
return TextFieldWidget(
hint:
AppLocalizations.of(context).translate('login_et_user_password'),
isObscure: _isObscure,
padding: const EdgeInsets.only(top: 16.0),
isSuffixIcon: _userEmailController.text.isNotEmpty,
prefixIcon: Icons.lock,
suffixIcon: IconButton(
icon: Icon(
// Show password
_isObscure ? Icons.visibility : Icons.visibility_off,
color: _themeStore.darkMode ? Colors.white70 : Colors.black54,
),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
iconColor: _themeStore.darkMode ? Colors.white70 : Colors.black54,
textController: _passwordController,
focusNode: _passwordFocusNode,
errorText: _formStore.formErrorStore.password,
onChanged: (value) {
_formStore.setPassword(_passwordController.text);
},
);
},
);
}
Widget _buildForgotPasswordButton() {
return Align(
alignment: FractionalOffset.centerRight,
child: MaterialButton(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Text(
AppLocalizations.of(context).translate('login_btn_forgot_password'),
style: const TextStyle(
fontFamily: 'Poppins',
fontSize: 16.0,
fontWeight: FontWeight.normal,
color: Colors.black,
decoration: TextDecoration.underline),
),
onPressed: () {
Navigator.pushNamed(context, Routes.forgotPassword);
},
),
);
}
Widget _buildSignInButton() {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: AppColors.oliveGreen,
),
child: MaterialButton(
padding: const EdgeInsets.all(16.0),
child: Text(
AppLocalizations.of(context).translate('login_btn_sign_in'),
style: const TextStyle(
fontFamily: 'Poppins',
fontSize: 16.0,
fontWeight: FontWeight.normal,
color: Colors.black,
),
),
onPressed: () async {
if (_formStore.canLogin) {
DeviceUtils.hideKeyboard(context);
_userStore.login(
_userEmailController.text, _passwordController.text);
} else {
_showErrorMessage('Please fill in all fields');
}
},
),
);
}
Widget _buildSignUpButton() {
return Padding(
padding: const EdgeInsets.only(bottom: 32.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Belum mempunyai akun?',
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
const SizedBox(width: 8.0),
GestureDetector(
onTap: () {
Navigator.pushNamed(context, Routes.register);
},
child: const Text(
'Daftar',
style: TextStyle(
color: Color(0xff132137),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
Widget navigate(BuildContext context) {
SharedPreferences.getInstance().then((prefs) {
prefs.setBool(Preferences.isLoggedIn, true);
});
Future.delayed(const Duration(milliseconds: 0), () {
Navigator.of(context).pushNamedAndRemoveUntil(
Routes.home, (Route<dynamic> route) => false);
});
return Container();
}
// General Methods:-----------------------------------------------------------
_showErrorMessage(String message) {
if (message.isNotEmpty) {
Future.delayed(const Duration(milliseconds: 0), () {
if (message.isNotEmpty) {
FlushbarHelper.createError(
message: message,
title: AppLocalizations.of(context).translate('home_tv_error'),
duration: const Duration(seconds: 3),
).show(context);
}
});
}
return const SizedBox.shrink();
}
// dispose:-------------------------------------------------------------------
@override
void dispose() {
// Clean up the controller when the Widget is removed from the Widget tree
_userEmailController.dispose();
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
}
Benefits: Clean Architecture vs. Anti-Patterns
Having explored the core principles of clean architecture, let’s see how it directly addresses common pain points encountered in Flutter development:
- Combating God Classes: Clean architecture promotes separation of concerns by dividing the application code into distinct layers (presentation, domain, and data). This prevents the creation of massive “God classes” that try to handle everything, leading to cluttered and difficult-to-manage codebases. Each layer has its specific responsibilities, fostering a more organized and maintainable structure.
- Untangling Spaghetti Code: Spaghetti code, characterized by its tangled structure and lack of clear organization, can be difficult to debug and modify. Clean architecture enforces a well-defined layered approach. This structure brings clarity and organization to the codebase, making it much easier to understand, maintain, and navigate for developers.
- Breaking Free from Tight Coupling: Often, tightly coupled code creates a situation where different parts of the code are highly dependent on each other. This makes modifications and testing cumbersome. Clean architecture breaks this dependency by promoting loose coupling. Each layer depends on abstractions (interfaces) rather than concrete implementations. This allows for greater flexibility: you can easily swap out specific implementations without affecting other parts of the system. Additionally, it simplifies testing as you can mock dependencies within individual layers.
- Separating UI Concerns: Occasionally, developers unintentionally mix UI logic (updating widget states) with business logic (calculating data). This approach makes the code less reusable and harder to test. Clean architecture establishes a clear separation between these concerns. Business logic resides in the domain layer, promoting reusability across different UI elements or even in other applications. UI logic remains limited to the presentation layer, simplifying testing and keeping the code focused on its specific purpose.
Reference: