What to expect from this article?
We initiated our Flutter project with the main libraries in the previous article
This article will cover the UI design and API implementation of the login screen, same steps can be followed to build some other app screens like signup screen, edit profile screen and other.
Before going on, I want to point out this amazing article series by AbdulMuaz Aqeel, his articles detail the project structure, clean architecture concepts, VS code extensions, and many other great details. feel free to use it as a more detailed reference for this article 🤓.
I'll try to cover as many details as possible without boring you, but I still expect you to be familiar with some aspects of Dart and Flutter.
the final version of the source code can be found at https://github.com/saad4software/alive-diary-app
Login API implementation
So, every feature in this app starts with the Domain
layer, then we implement the domain interfaces in the Data
layer, and finally connect the presentation layer with the domain repositories.
Domain Layer
Let's start with the models. Personally, I recommend using json_to_dart extension, it is simple and powerful
It creates the models and sub-model classes from the JSON, the only downside is in file names, they follow camel-case 🐫 schema, like GenericResponse
, while it is recommended to name files following snakelike 🐍 schema, like generic_response
.
We are getting the JSON from Swagger, starting with logging in request
class LoginRequest {
LoginRequest({
this.username,
this.password,});
LoginRequest.fromJson(dynamic json) {
username = json['username'];
password = json['password'];
}
String? username;
String? password;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['username'] = username;
map['password'] = password;
return map;
}
}
lib/domain/models/requests/login_request.dart
Great job, now for the responses, it's kinda tricky, all our app responses follow a certain schema
{
"status": "success",
"code": 200,
"data": {},
"message": None
}
Where data
can vary based on the request, so we need a generic type of response that can be used with retrofit
, our http client library. After looking around, it seems doable this way
class GenericResponse<T> {
GenericResponse({
this.status,
this.code,
this.data,
this.message,});
GenericResponse.fromJson(dynamic json, T Function(Object json) fromJsonT) {
status = json['status'];
code = json['code'];
data = fromJsonT(json['data']);
message = json['message'];
}
String? status;
num? code;
T? data;
String? message;
Map<String, dynamic> toJson(Object Function(T? value) toJsonT) {
final map = <String, dynamic>{};
map['status'] = status;
map['code'] = code;
map['data'] = toJsonT(data);
map['message'] = message;
return map;
}
}
lib/domain/models/responses/generic_response.dart
We basically provided a function to convert the generic type T
to JSON and another one to convert the JSON String to type T
.
Now let's create a model for the login response using json_to_dart
extension.
class LoginModel {
LoginModel({
this.refresh,
this.access,
this.user,
this.role,
this.notifications,});
LoginModel.fromJson(dynamic json) {
refresh = json['refresh'];
access = json['access'];
user = json['user'] != null ? UserModel.fromJson(json['user']) : null;
role = json['role'];
notifications = json['notifications'];
}
String? refresh;
String? access;
UserModel? user;
String? role;
num? notifications;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['refresh'] = refresh;
map['access'] = access;
if (user != null) {
map['user'] = user?.toJson();
}
map['role'] = role;
map['notifications'] = notifications;
return map;
}
}
lib/domain/models/entities/login_model.dart
and the user model
class UserModel {
UserModel({
this.firstName,
this.lastName,
this.username,
this.countryCode,
this.expirationDate,
this.hobbies,
this.job,
this.bio,
this.role,});
UserModel.fromJson(dynamic json) {
firstName = json['first_name'];
lastName = json['last_name'];
username = json['username'];
countryCode = json['country_code'];
expirationDate = json['expiration_date'];
hobbies = json['hobbies'];
job = json['job'];
bio = json['bio'];
role = json['role'];
}
String? firstName;
String? lastName;
String? username;
String? countryCode;
String? expirationDate;
String? hobbies;
String? job;
String? bio;
String? role;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['first_name'] = firstName;
map['last_name'] = lastName;
map['username'] = username;
map['country_code'] = countryCode;
map['expiration_date'] = expirationDate;
map['hobbies'] = hobbies;
map['job'] = job;
map['bio'] = bio;
map['role'] = role;
return map;
}
}
lib/domain/models/entities/user_model.dart
Now, after creating models, let's create the repository interface in the domain layer
import '../../utils/data_state.dart';
import '../models/entities/login_model.dart';
import '../models/entities/user_model.dart';
import '../models/responses/generic_response.dart';
abstract class RemoteRepository {
Future<DataState<GenericResponse<LoginModel>>> login({
String? username,
String? password,
});
}
lib/domain/repositories/remote_repository.dart
This is the imprint of our APIs. We have already created GenericResponse
, LoginModel
, and UserModel
.
DataState
is a helping model to pass Remote API status to the UI, it looks like
import 'package:dio/dio.dart';
abstract class DataState<T> {
final T? data;
final DioException? error;
const DataState({this.data, this.error});
}
class DataSuccess<T> extends DataState<T> {
const DataSuccess(T data) : super(data: data);
}
class DataFailed<T> extends DataState<T> {
const DataFailed(DioException error) : super(error: error);
}
class DataNotSet<T> extends DataState<T> {
const DataNotSet();
}
lib/utils/data_state.dart
It simply provide access to API exception model if exists and the data model if we get a successful response.
Data Layer
We are going to implement the repository interface in the data layer like this
import 'package:alive_diary_app/domain/models/entities/login_model.dart';
import 'package:alive_diary_app/domain/models/entities/user_model.dart';
import 'package:alive_diary_app/domain/models/responses/generic_response.dart';
import 'package:alive_diary_app/domain/repositories/remote_repository.dart';
import 'package:alive_diary_app/utils/data_state.dart';
class RemoteRepositoryImpl extends RemoteRepository {
@override
Future<DataState<GenericResponse<LoginModel>>> login({
String? username,
String? password,
}) {
// TODO: implement login
throw UnimplementedError();
}
}
lib/data/repositories/remote_repository_impl.dart
The low level requests happens in the data-sources with retrofit
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import '../../config/app_constants.dart';
import '../../domain/models/entities/login_model.dart';
import '../../domain/models/entities/user_model.dart';
import '../../domain/models/requests/login_request.dart';
import '../../domain/models/requests/register_request.dart';
import '../../domain/models/responses/generic_response.dart';
part 'remote_datasource.g.dart';
@RestApi(baseUrl: AppConstants.baseUrl, parser: Parser.JsonSerializable)
abstract class RemoteDatasource {
factory RemoteDatasource(Dio dio, {String baseUrl}) = _RemoteDatasource;
@POST('/account/login/')
Future<HttpResponse<GenericResponse<LoginModel>>> login({
@Body() LoginRequest? request,
});
}
lib/data/sources/remote_datasource.dart
now we need to run build_runner
to generate the code for remote_datasource.g.dart
dart run build_runner build - delete-conflicting-outputs
Back to the repository implementation in the data-layer, we can use the data-source to get data now
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import '../../domain/models/entities/login_model.dart';
import '../../domain/models/entities/user_model.dart';
import '../../domain/models/responses/generic_response.dart';
import '../../domain/repositories/remote_repository.dart';
import '../../utils/data_state.dart';
import '../../domain/models/requests/login_request.dart';
import '../../domain/models/requests/register_request.dart';
import '../sources/remote_datasource.dart';
class RemoteRepositoryImpl extends RemoteRepository {
RemoteRepositoryImpl(this.remoteDatasource);
final RemoteDatasource remoteDatasource;
@override
Future<DataState<GenericResponse<LoginModel>>> login({
String? username,
String? password,
}) => getStateOf<GenericResponse<LoginModel>>(
request: ()=>remoteDatasource.login(
request: LoginRequest(
username: username,
password: password,
),
),
);
Future<DataState<T>> getStateOf<T>({
required Future<HttpResponse<T>> Function() request,
}) async {
try {
final httpResponse = await request();
final okStatus = [HttpStatus.ok, HttpStatus.created, HttpStatus.accepted,];
if (okStatus.contains(httpResponse.response.statusCode) ) {
return DataSuccess(httpResponse.data);
} else {
throw DioException(
response: httpResponse.response,
requestOptions: httpResponse.response.requestOptions,
);
}
} on DioException catch (error) {
return DataFailed(error);
}
}
}
lib/data/repositories/remote_repository_impl.dart
So, now we have the data-source, and the remote repository, it is time to inject it where needed, moving to the dependency injection config file
import 'package:alive_diary_app/domain/repositories/remote_repository.dart';
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../data/repositories/remote_repository_impl.dart';
import '../data/sources/remote_datasource.dart';
final locator = GetIt.instance;
Future<void> initializeDependencies() async {
final dio = Dio();
locator.registerSingleton<Dio>(dio);
locator.registerSingleton<SharedPreferences> (
await SharedPreferences.getInstance(),
);
// new
locator.registerSingleton<RemoteDatasource>(
RemoteDatasource(locator()), // this locator returns the dio object
);
// new
locator.registerSingleton<RemoteRepository>(
RemoteRepositoryImpl(locator()), // this locator returns the RemoteDatasource
);
}
lib/config/dependencies.dart
Great job! Now, we can get the repository from the dependency injection provider wherever it is needed 🤓 basically in the presentation layer.
Presentation Layer
We are going to use Bloc state management for the presentation layer, so every screen will have its own Bloc file, and a list of events and statuses. The screen logic and repository calls usually happen in the bloc file, so we pass the remote repositories to the Bloc file.
Before creating any screen with Bloc, we need to think about the events, statuses, and required data for each of them. For instance, our login screen should have
- Events: login pressed event, data: username and password
- Status: login success state, data: token probably, but it will not be shown so it is not important for the state. login fails, data: a message of the error
- bloc: uses the repository to try to log in. On success, it should emit a login success state, if not, it should show an error message.
Events
a login pressed event, with a username and password data should look somewhat like
part of 'login_bloc.dart';
@immutable
sealed class LoginEvent {
final String? username;
final String? password;
const LoginEvent({
this.username,
this.password,
});
}
class LoginPressedEvent extends LoginEvent {
const LoginPressedEvent({
super.username,
super.password,
});
}
lib/presentation/screens/login/login_event.dart
I'm using Bloc extension to generate the boilerplate code. now for the status, it should look somewhat like
part of 'login_bloc.dart';
@immutable
sealed class LoginState {
final String? message;
const LoginState({this.message});
}
final class LoginInitial extends LoginState {}
final class LoginLoadingState extends LoginState {}
final class LoginSuccessState extends LoginState {}
final class LoginErrorState extends LoginState {
const LoginErrorState({super.message});
}
lib/presentation/screens/login/login_state.dart
We basically have success and error status, as discussed above; a loading state was added to inform the UI of the loading status too. With our events and statuses ready, it is time to connect them to our bloc logic file
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import '../../../domain/repositories/remote_repository.dart';
import '../../../utils/data_state.dart';
import '../../../utils/dio_exception_extension.dart';
part 'login_event.dart';
part 'login_state.dart';
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final RemoteRepository repository;
LoginBloc(this.repository) : super(LoginInitial()) {
on<LoginPressedEvent>(handleLoginEvent);
}
FutureOr<void> handleLoginEvent(
LoginPressedEvent event,
Emitter<LoginState> emit,
) async {
emit(LoginLoadingState());
final response = await repository.login(
username: event.username,
password: event.password,
);
if (response is DataSuccess) {
final tokenModel = response.data?.data;
emit(LoginSuccessState());
} else if (response is DataFailed) {
emit(LoginErrorState(message: response.error?.getErrorMessage()));
}
}
}
lib/presentation/screens/login/login_bloc.dart
on submitting a LoginPressedEvent
, we start by emitting a LoginLoadingState
to inform the UI of the loading status, then we make the login request using the event username and password. We emit a LoginSuccessState
on a successful login or LoginErrorState
on a failed login attempt.
In order to get the server error message, we created an extension to dio exception; it tries to get the server error message if possible and passes dio error message if it is not available.
import 'package:alive_diary_app/domain/models/responses/generic_response.dart';
import 'package:dio/dio.dart';
extension DioExceptionExtension on DioException {
String getErrorMessage() {
final msg = response?.data == null ?
message :
GenericResponse
.fromErrorJson(response?.data)
.message?.trim();
return msg ?? "Unexpected error";
}
}
lib/utils/dio_exception_extension.dart
it checks for the response data in the dio exception instant, if it exists, the extension tries to parse the response to a generic response and extract the message field value, if not, it passes the dio exception message directly. We created a simple fromErrorJson
to simplify parsing the error response from JSON.
GenericResponse.fromErrorJson(dynamic json) {
status = json['status'];
code = json['code'];
message = json['message'];
}
lib/domain/models/responses/generic_response.dart
Nice, we can finally start with the UI design 🤓.
A login screen should have two text fields and a login button 🤔
In flutter, it is always recommended to isolate common components as much as possible. so, let's start by creating a generic text field
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class AppFieldWidget extends StatelessWidget {
const AppFieldWidget({
super.key,
required this.controller,
required this.label,
this.isEnabled = true,
this.isRequired = false,
this.isMultiline = false,
this.isPassword = false,
this.suffixIcon,
this.inputType = TextInputType.text,
this.onTab,
});
final TextEditingController controller;
final String label;
final bool isEnabled;
final bool isRequired;
final bool isMultiline;
final bool isPassword;
final Widget? suffixIcon;
final TextInputType inputType;
final Function()? onTab;
@override
Widget build(BuildContext context) {
return Container(
child:TextFormField(
enabled: isEnabled,
keyboardType: inputType,
controller: controller,
validator: isRequired ? (val) => val!.isEmpty ? "required".tr() : null : null,
textInputAction: TextInputAction.next,
maxLines: isMultiline ? 3 : 1,
onTap: onTab,
obscureText: isPassword,
decoration: InputDecoration(
suffixIcon: suffixIcon,
labelText: label,
alignLabelWithHint: true,
labelStyle: const TextStyle(color: Colors.black38),
floatingLabelStyle:
const TextStyle(height: 4, color: Colors.black),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15.0),
borderSide: const BorderSide(width: 2),
),
filled: true,
),
),
);
}
}
lib/presentation/widgets/app_field_widget.dart
We are basically styling the text field and providing a basic API for it. we can use this widget anywhere in the app, which keeps the widget format stable on all screens.
let's also create a loading widget to indicate the loading state
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class LoadingWidget extends HookWidget {
final Color color;
const LoadingWidget({
super.key,
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return Container(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(color: color, backgroundColor: Colors.transparent,),
),
);
}
}
lib/presentation/widgets/loading_widget.dart
it is a simple circular progress indicator. Building the login screen with those widgets
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:oktoast/oktoast.dart';
import 'package:lottie/lottie.dart';
import '../../../config/router/app_router.dart';
import '../../widgets/app_field_widget.dart';
import '../../widgets/loading_widget.dart';
import 'login_bloc.dart';
@RoutePage()
class LoginScreen extends HookWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
final bloc = BlocProvider.of<LoginBloc>(context);
final usernameField = useTextEditingController(text: "");
final passwordField = useTextEditingController(text: "");
final formKey = useMemoized(GlobalKey<FormState>.new);
return Scaffold(
body: BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state is LoginErrorState) {
showToast(state.message ?? "Something went wrong");
} else if (state is LoginSuccessState) {
showToast("welcome".tr());
appRouter.replaceAll([HomeRoute()]);
}
},
child: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(20),
child: Form(
key: formKey,
child: Column(
children: [
const SizedBox(height: 20,),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(onPressed: () {
// SystemNavigator.pop(animated: true);
exit(0);
}, icon: const Icon(Icons.close)),
],
),
Lottie.asset('assets/lottie/profile.json'),
AppFieldWidget(
controller: usernameField,
label: "email".tr(),
isRequired: true,
inputType: TextInputType.emailAddress,
),
const SizedBox(height: 20,),
AppFieldWidget(
controller: passwordField,
label: "password".tr(),
isRequired: true,
isPassword: true,
),
const SizedBox(height: 20,),
BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
return state is LoginLoadingState ?
const LoadingWidget() :
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors
.green, // This is what you need!
),
onPressed: () async {
if (formKey.currentState!.validate()) {
bloc.add(
LoginPressedEvent(
username: usernameField.text,
password: passwordField.text
)
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text("login".tr(),
style: const TextStyle(color: Colors.white),),
),
);
},
),
TextButton(
child: Text("noAccount".tr()),
onPressed: () {
// navigate to register screen
},
),
TextButton(
child: Text("haveCode".tr()),
onPressed: () {
// navigate to verification code screen
},
),
],
),
),
),
),
),
);
}
}
lib/presentation/screens/login_screen.dart
We are using flutter hooks to simplify the UI design. @RoutePage()
annotation indicates this to be a router screen (auto-router).
We are using Block listener to show error messages in case of an error state and navigate to the home screen in case of a success state. a block builder wraps the login button to show the loading widget if the status is loading, and the login button if not. we have also added two buttons for the verification code screen and register screen. A Lottie animation is added at the top of the fields which can be replaced with the app logo if needed.
We are using easy_localization
to translate interfaces, so make sure to add the keys (username, email, password ...) to the language JSON.
We still need to add the LoginBloc
to the app bloc provider in the main app file
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: MultiBlocProvider(
providers: [
BlocProvider(create: (context)=>HomeBloc(locator())),
BlocProvider(create: (context)=>LoginBloc(locator())),
],
child: OKToast(
...
lib/main.dart
With the login widget ready, we can generate the auto-router code with
dart run build_runner build - delete-conflicting-outputs
and registering the screen in the app router config file
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../presentation/screens/home/home_screen.dart';
import '../../presentation/screens/login/login_screen.dart';
part 'app_router.gr.dart';
@AutoRouterConfig()
class AppRouter extends _$AppRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: LoginRoute.page, initial: true),
AutoRoute(page: HomeRoute.page),
];
}
final appRouter = AppRouter();
lib/config/router/app_router.dart
great, It should work now! let's try it
I forgot to run
flutter pub run easy_localization:generate -S ./assets/locales -O ./lib/config/locale
for easy_localization
😅
That is it! we can log in now 🤓, creating registration screen, account verification screen and profile edit screen is similar to creating this screen. we still need to store the token in the app before moving to the Text-to-Speech and Speech-to-Text parts of the app, so
Stay tuned 😎
Top comments (0)