DEV Community

Cover image for Flutter App, Login screen and API
Saad Alkentar
Saad Alkentar

Posted on

Flutter App, Login screen and API

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

json to dart extension

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;
  }

}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }

}
Enter fullscreen mode Exit fullscreen mode

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;
  }

}
Enter fullscreen mode Exit fullscreen mode

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,
  });

}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }

}
Enter fullscreen mode Exit fullscreen mode

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,
  });

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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()));
    }

  }
}

Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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'];
  }
Enter fullscreen mode Exit fullscreen mode

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,
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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,),
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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
                    },
                  ),

                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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(
...
Enter fullscreen mode Exit fullscreen mode

lib/main.dart

With the login widget ready, we can generate the auto-router code with

dart run build_runner build - delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

lib/config/router/app_router.dart

great, It should work now! let's try it

Login screen

I forgot to run

flutter pub run easy_localization:generate -S ./assets/locales -O ./lib/config/locale
Enter fullscreen mode Exit fullscreen mode

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)