Building a Full-Stack Application with Flutter and Dart

by Ryan Edge

https://edge-library.fly.dev

Ryan Edge

Lead, Uptech Studio

http://ryanedge.page

I build multiplatform applications for Web, Desktop, and Mobile

I ❤️ Flutter and React

I wrote a book

Today's agenda

  • Developing horizontally vs. vertically
  • Developing using a single-language stack
  • Options for the backend when building Flutter apps
  • Building a Library Application, almost
  • Wrap up

Developing horizontally

Tradeoffs

  • 🚀 Tasks not features
  • ⬆️ Horizontal knowledge
  • ⬇️ Vertical knowledge

Developing vertically

Tradeoffs

  • 🚀 Features not tasks
  • ⬆️ Vertical knowledge
  • ⬇️ Horizontal knowledge

Tradeoffs of vertical slice development cont'd

Benefits

  • Great for product teams
  • Vertical knowledge sharing
  • No horizontal feature juggling
  • Should translate to faster development cycles

Drawbacks

  • Not great for functional teams (ie. backend, frontend, etc.)
  • Context switching between layers

Monoglot vertical stack development

Monoglot vertical stack development

Benefits

  • Learn language once, write everywhere
  • Less context switching
  • Better developer efficiency
  • Quicker onboarding
  • Code reuse

Some examples of monoglot?

 

  • Kotlin Multiplatform + Kotlin server-side
  • Next.js, Remix, Sveltkit, etc. (Node.js)
  • Swift + Vapor
  • What else?

Full-stack Dart

    Wait, what is Dart again? And what is Flutter?

Why Flutter?

Full-stack Dart in 2023

  • Implement from scratch
  • Implement using a minimalist backend framework
  • Implement using full-fledged backend framework

From Scratch

Shelf

  • Open-source (maintained by Google)
  • Minimal web server middleware
  • Inspired by Express.js & Rack for Ruby

The minimalist

Dart Frog

  • Open-source (maintained by VGV)
  • Simple API
  • Inspired by remix.run, next.js, etc.

Features of Dart Frog

  • Hot reload
  • Dart DevTools
  • File System Routing
  • Middleware
  • 🚧 Full-stack type safety (client codegen)
  • 🚧 API Documentation generation

Kitchen-sink framework

Serverpod

  • Open-source (own company)
  • Very opinionated
  • Full-stack framework inspired by .NET, Nest, etc.

Features of Serverpod

  • RPC-like communication & serialization (codegen)
  • DevOps included (Terraform & Docker)
  • Built-in Logging, ORM & Caching
  • Authentication
  • File upload
  • Task scheduling

What we built: Edge's Library

https://edge-library.fly.dev

  • Passwordless user authentication
  • Patrons can see available books
  • Patrons can borrow/return books

 

Patrons: authenticated users

What we used

  • Dart Frog for the backend
  • Flutter for the frontend
  • Passage.id for authentication
  • Supabase for persistance
  • Fly.io for deployment

Starting out: create the monorepo

mkdir edge_library && cd edge_library
dart_frog create edge_library_api
flutter create edge_library_app -e

Starting out cont'd: backend packages

cd dart_frog_api
mkdir packages && cd packages
dart create edge_library_common
dart create edge_library_data
dependencies:
  dart_frog: ^1.0.0
  dart_frog_auth: ^1.1.0
  dart_jsonwebtoken: ^2.11.0
  dotenv: ^4.1.0
  edge_library_common:
    path: ./packages/edge_library_common
  edge_library_data:
    path: ./packages/edge_library_data
  option_result: ^3.1.3
  shelf_cors_headers: ^0.1.5
  supabase: ^1.11.1

Starting out cont'd: frontend packages

dependencies:
  collection: ^1.17.2
  edge_library_common:
    path: ../edge_library_api/packages/edge_library_common
  envied: ^0.3.0+3
  equatable: ^2.0.5
  flex_color_scheme: ^7.3.1
  flextras: ^1.0.0
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.0
  gap: ^3.0.1
  go_router: ^10.1.2
  intl: ^0.18.1
  logging: ^1.2.0
  loggy: ^2.0.3
  option_result: ^3.1.2
  passage_flutter: ^0.3.0
  riverpod: ^2.4.0
  uno: ^1.1.9
  wolt_modal_sheet: ^0.1.2

Building the API

Getting to know the backend

The anatomy of a route

import 'package:dart_frog/dart_frog.dart';

Response onRequest(RequestContext context) {
  return Response(body: 'Welcome to Dart Frog!');
}

Middleware

import 'package:dart_frog/dart_frog.dart';
import 'package:dart_frog_auth/dart_frog_auth.dart';

Handler middleware(Handler handler) {
  return handler.use(
    bearerAuthentication<User>(
      authenticator: (context, token) async {
        return null;
      },
    ),
  );
}

Building login

Data: Authenticator Facade

import 'dart:convert';

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:option_result/option_result.dart';

class AuthenticatorFacade {
  const AuthenticatorFacade(this.publicAuthKey);

  final String publicAuthKey;

  Future<Option<User>> verifyToken(String token) async {
    try {
      final payload = JWT.verify(
        token,
        RSAPublicKey(String.fromCharCodes(base64Decode(publicAuthKey))),
      );

      return Some(User(
        id: payload.subject!,
      ));
    } catch (error, stackTrace) {
      print(stackTrace);
      print(error);

      return None();
    }
  }
}

class User {
  const User({
    required this.id,
  });

  final String id;
}

API: Middleware for authentication

import 'package:dart_frog/dart_frog.dart';
import 'package:dart_frog_auth/dart_frog_auth.dart';
import 'package:edge_library_data/edge_library_data.dart';
import 'package:option_result/option_result.dart';

Handler middleware(Handler handler) {
  return handler.use(
    bearerAuthentication<User>(
      authenticator: (context, token) async {
        final userRepository = context.read<AuthenticatorFacade>();
        final tokenResult = await userRepository.verifyToken(token);

        return switch (tokenResult) {
          Some(:final value) => value,
          _ => null,
        };
      },
    ),
  );
}

Frontend: Identity Facade

import 'package:edge_library_app/shared/env/env.dart';
import 'package:edge_library_app/shared/logger/logger.dart';
import 'package:equatable/equatable.dart';

import 'package:flutter/foundation.dart';
import 'package:option_result/option_result.dart';
import 'package:passage_flutter/passage_flutter.dart';
import 'package:passage_flutter/passage_flutter_models/passage_app_info.dart';
import 'package:passage_flutter/passage_flutter_models/passage_error.dart';
import 'package:passage_flutter/passage_flutter_models/passage_error_code.dart';
import 'package:passage_flutter/passage_flutter_models/passage_user.dart';

import 'package:riverpod/riverpod.dart';

final identityFacadeProvider = Provider<IdentityFacade>((ref) {
  return IdentityFacade(PassageFlutter(Env.passageAppId));
});

class IdentityFacade with LoggerMixin {
  IdentityFacade(this.passage);

  PassageFlutter passage;

  Future<Result<PassageUser?, Exception>> checkForAuthenticatedUser() async {
    try {
      final user = await passage.getCurrentUser();

      if (user == null) {
        passage.signOut();
      }

      return Ok(user);
    } catch (e, stackTrace) {
      logger.info('issue checking for authenticated user', e, stackTrace);

      return Err(Exception());
    }
  }

  Future<Result<String?, AuthException>> getAuthToken() async {
    try {
      return Ok(await passage.getAuthToken());
    } catch (e) {
      return Err(_mapError(e));
    }
  }

  Future<Result<PassageUser, AuthException>> register(String identifier) async {
    try {
      await passage.register(identifier);
      final user = await passage.getCurrentUser();

      if (user == null) throw Exception();

      return Ok(user);
    } on PassageError catch (error) {
      logger.warning('issue logging in', error);

      return switch (error.code) {
        'identifier_not_verified' => const Err(PassageOtpRequiredException()),
        PassageErrorCode.passkeyError when identifier.isNotEmpty =>
          const Err(PassageOtpRequiredException()),
        _ => Err(_mapError(error)),
      };
    } catch (error) {
      logger.warning('issue logging in', error);

      return Err(_mapError(error));
    }
  }

  Future<Result<PassageUser, AuthException>> login(String identifier) async {
    try {
      if (kIsWeb) {
        await passage.loginWithIdentifier(identifier);
      } else {
        await passage.login();
      }
      final user = await passage.getCurrentUser();

      if (user == null) throw Exception();

      return Ok(user);
    } on PassageError catch (error) {
      logger.warning('issue logging in', error);

      return switch (error.code) {
        'identifier_not_verified' => const Err(PassageOtpRequiredException()),
        PassageErrorCode.passkeyError when identifier.isNotEmpty =>
          const Err(PassageOtpRequiredException()),
        _ => Err(_mapError(error)),
      };
    } catch (error) {
      logger.warning('issue logging in', error);

      return Err(_mapError(error));
    }
  }

  Future<Result<PassageUser, AuthException>> activateOTP(
    String otp,
    authFallbackId,
  ) async {
    try {
      await passage.oneTimePasscodeActivate(otp, authFallbackId!);
      final user = await passage.getCurrentUser();

      if (user == null) throw Exception();

      return addPasskey();
    } catch (error) {
      logger.severe('issue activating OTP', error);
      return Err(_mapError(error));
    }
  }

  Future<Result<String, AuthException>> resendOTP({
    required String userIdentifer,
    required bool isRegistering,
  }) async {
    try {
      final newOtpId = isRegistering
          ? await passage.newRegisterOneTimePasscode(userIdentifer)
          : await passage.newLoginOneTimePasscode(userIdentifer);

      return Ok(newOtpId);
    } catch (error) {
      logger.severe('issue resending OTP', error);
      return Err(_mapError(error));
    }
  }

  Future<Result<PassageUser, AuthException>> addPasskey() async {
    try {
      await passage.addPasskey();

      final user = await passage.getCurrentUser();

      if (user == null) throw Exception();

      return Ok(user);
    } catch (error) {
      logger.severe('issue adding passkey', error);
      return Err(_mapError(error));
    }
  }

  Future<Result<None, AuthException>> signOut() async {
    try {
      await passage.signOut();
      return const Ok(None());
    } catch (e) {
      return Err(_mapError(e));
    }
  }

  Future<Result<String, dynamic>> fallbackRegister(String identifier) async {
    try {
      final appInfo = await passage.getAppInfo();

      if (appInfo?.authFallbackMethod == PassageAuthFallbackMethod.otp) {
        final otpId = await passage.newRegisterOneTimePasscode(identifier);
        return Ok(otpId);
      }

      return const Ok('');
    } catch (error) {
      logger.severe('issue signing out', error);
      return Err(_mapError(error));
    }
  }

  Future<Result<String, dynamic>> fallbackLogin(String identifier) async {
    try {
      final appInfo = await passage.getAppInfo();
      if (appInfo?.authFallbackMethod == PassageAuthFallbackMethod.otp) {
        final otpId = await passage.newLoginOneTimePasscode(identifier);
        return Ok(otpId);
      }

      return const Ok('');
    } catch (error) {
      logger.severe('issue with fallback login', error);
      return Err(_mapError(error));
    }
  }

  AuthException _mapError(Object error) => switch (error) {
        PassageOtpRequiredException() => error,
        PassageAuthException() => error,
        PassageError() => PassageAuthException(error),
        _ => UnknownException(error),
      };
}

sealed class AuthException extends Equatable implements Exception {
  const AuthException();

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

class PassageAuthException implements AuthException {
  PassageAuthException(this.original);

  final PassageError original;

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

  @override
  bool? get stringify => true;
}

class PassageOtpRequiredException implements AuthException {
  const PassageOtpRequiredException();
  @override
  List<Object?> get props => [];
  @override
  bool? get stringify => true;
}

class UnknownException implements AuthException {
  const UnknownException(this.original);

  final Object original;

  @override
  List<Object?> get props => [original];
  @override
  bool? get stringify => true;
}

Frontend: Login Notifier

import 'package:edge_library_app/shared/logger/logger.dart';
import 'package:equatable/equatable.dart';
import 'package:option_result/option_result.dart';
import 'package:passage_flutter/passage_flutter_models/passage_user.dart';

import 'package:riverpod/riverpod.dart';

import 'package:edge_library_app/shared/api/identity/identity_facade.dart';

final loginNotifierProvider =
    NotifierProvider.autoDispose<LoginNotifier, LoginState>(LoginNotifier.new);

class LoginNotifier extends AutoDisposeNotifier<LoginState> with LoggerMixin {
  @override
  build() {
    return const LoginState();
  }

  toggleIsNewUser() {
    state = state.copyWith(isNewUser: !state.isNewUser);
  }

  resetCode() {
    state = state.resetCode();
  }

  Future<Result<PassageUser, Exception>> login(String identifier) async {
    final result = await ref.read(identityFacadeProvider).login(identifier);

    final isOtpRequired = result.isErrAnd(
      (failure) => switch (failure) {
        PassageOtpRequiredException() => true,
        _ => false
      },
    );

    if (isOtpRequired) {
      final otpResult =
          await ref.read(identityFacadeProvider).fallbackLogin(identifier);

      state = state.copyWith(
        result: result
            .mapErr((p0) => otpResult.isErr() ? otpResult.unwrapErr() : p0),
        isOtpRequired: otpResult.isErr() ? false : isOtpRequired,
        fallbackIdentifier: switch (otpResult) {
          Ok(:final value) => value,
          _ => null,
        },
      );

      return result
          .mapErr((p0) => otpResult.isErr() ? otpResult.unwrapErr() : p0);
    }

    state = state.copyWith(
      result: result,
      isOtpRequired: isOtpRequired,
    );

    return result;
  }

  Future<Result<PassageUser, AuthException>> register(String identifier) async {
    final result = await ref.read(identityFacadeProvider).register(identifier);

    final isOtpRequired = result.isErrAnd(
      (failure) => switch (failure) {
        PassageOtpRequiredException() => true,
        _ => false
      },
    );

    if (isOtpRequired) {
      final otpResult =
          await ref.read(identityFacadeProvider).fallbackRegister(identifier);

      state = state.copyWith(
        result: result
            .mapErr((p0) => otpResult.isErr() ? otpResult.unwrapErr() : p0),
        isOtpRequired: otpResult.isErr() ? false : isOtpRequired,
        fallbackIdentifier: switch (otpResult) {
          Ok(:final value) => value,
          _ => null,
        },
      );

      return result
          .mapErr((p0) => otpResult.isErr() ? otpResult.unwrapErr() : p0);
    }

    return result;
  }

  Future<Result<PassageUser, Exception>> activateOtp(
      String otp, String identifier) async {
    final result = await ref
        .read(identityFacadeProvider)
        .activateOTP(otp, state.fallbackIdentifier);

    state = state.copyWith(result: result);

    return result;
  }
}

class LoginState extends Equatable {
  const LoginState({
    this.isNewUser = true,
    this.result,
    this.isOtpRequired = false,
    this.fallbackIdentifier,
  });

  final bool isNewUser;
  final bool isOtpRequired;
  final String? fallbackIdentifier;
  final Result<PassageUser, AuthException>? result;

  @override
  List<Object?> get props => [
        isNewUser,
        result,
        isOtpRequired,
        fallbackIdentifier,
      ];

  LoginState resetCode() {
    return LoginState(
      isNewUser: isNewUser,
      isOtpRequired: false,
    );
  }

  LoginState copyWith({
    bool? isNewUser,
    bool? isOtpRequired,
    String? fallbackIdentifier,
    Result<PassageUser, AuthException>? result,
  }) {
    return LoginState(
      isNewUser: isNewUser ?? this.isNewUser,
      isOtpRequired: isOtpRequired ?? this.isOtpRequired,
      result: result ?? this.result,
      fallbackIdentifier: fallbackIdentifier ?? this.fallbackIdentifier,
    );
  }
}

Building the Patron feature

Common: Patron model

import 'package:dart_mappable/dart_mappable.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_common/src/models/base_model.dart';

part 'patron.mapper.dart';

@MappableClass()
class Patron extends BaseModel with PatronMappable {
  const Patron({
    required this.id,
    required this.name,
    required this.email,
    required this.address,
    required this.phoneNumber,
    this.borrowed = const [],
  });

  final String id;
  final String? email;
  final String? address;
  
  @MappableField(key: 'name')
  final String? name;
  
  @MappableField(key: 'phone_number')
  final String? phoneNumber;

  @MappableField(key: 'books')
  final List<Book> borrowed;
}

Common: Create Patron

import 'package:dart_mappable/dart_mappable.dart';

part 'create_patron_request.mapper.dart';

@MappableClass()
class CreatePatronRequest with CreatePatronRequestMappable {
  const CreatePatronRequest(
    this.name,
    this.email,
  );

  final String name;
  final String email;
}

Data: Patron Repository

import 'package:edge_library_common/edge_library_common.dart';
import 'package:supabase/supabase.dart';

class PatronRepository {
  const PatronRepository(this.client);

  final SupabaseClient client;

  Future<Patron?> getPatronById(String id) async {
    try {
      final patron = await client
          .from('patrons')
          .select<PostgrestMap?>()
          .eq('external_id', id)
          .maybeSingle()
          .withConverter((data) {
        return data == null ? null : PatronMapper.fromMap(data);
      });
      
      final borrowedBooks = await client
          .from('books')
          .select<PostgrestList>('*, borrowed_books!inner(*)')
          .eq('borrowed_books.patron_id', patron?.id)
          .filter('borrowed_books.is_active', 'eq', true)
          .withConverter((data) => data.map(BookMapper.fromMap));
      
      return patron?.copyWith(borrowed: borrowedBooks.toList());
    } catch (e) {
      print(e);
      rethrow;
    }
  }

  Future<Patron?> createPatron({
    required String id,
    required String name,
    required String email,
  }) async {
    try {
      return await client
          .from('patrons')
          .insert({
            'name': name,
            'external_id': id,
            'email': email,
          })
          .select<PostgrestMap?>()
          .maybeSingle()
          .withConverter(
              (data) => data == null ? null : PatronMapper.fromMap(data));
    } catch (e) {
      print(e);
      rethrow;
    }
  }
}

Middleware for the glue

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:dotenv/dotenv.dart';
import 'package:edge_library_data/edge_library_data.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
import 'package:supabase/supabase.dart';

Handler middleware(Handler handler) {
  final env = DotEnv(includePlatformEnvironment: true)
    ..load(File('.env').existsSync() ? const ['.env'] : const []);

  final client = SupabaseClient(
    env['SUPABASE_URL']!,
    env['SUPABASE_KEY']!,
  );

  return handler
      .use(fromShelfMiddleware(corsHeaders()))
      .use(requestLogger())
      .use(provider<BookRepository>((context) => BookRepository(client)))
      .use(provider<PatronRepository>((context) => PatronRepository(client)))
      .use(
        provider<AuthenticatorFacade>(
          (context) => AuthenticatorFacade(env['PUBLIC_AUTH_KEY']!),
        ),
      );
}

API: Creating our patron routes

/patrons/index.dart

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_data/edge_library_data.dart';

Future<Response> onRequest(RequestContext context) async {
  return switch (context.request.method) {
    HttpMethod.post => _handlePost(context),
    HttpMethod.get => _handleGet(context),
    _ => Future.value(Response.json(statusCode: HttpStatus.notAcceptable)),
  };
}

Future<Response> _handleGet(RequestContext context) async {
  final user = context.read<User>();
  final repository = context.read<PatronRepository>();

  final result = await repository.getPatronById(user.id);

  if (result == null) return Response.json(statusCode: HttpStatus.notFound);

  return Response.json(
    body: GetPatronResponse(result).toJson(),
  );
}

Future<Response> _handlePost(RequestContext context) async {
  final user = context.read<User>();
  final repository = context.read<PatronRepository>();

  final input =
      CreatePatronRequestMapper.fromJson(await context.request.body());

  final result = await repository.createPatron(
    id: user.id,
    name: input.name,
    email: input.email,
  );

  if (result == null) return Response.json(statusCode: HttpStatus.badRequest);

  return Response.json(
    body: GetPatronResponse(result).toJson(),
  );
}

Frontend: Patron Facade

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:edge_library_app/shared/api/identity/identity_facade.dart';
import 'package:edge_library_app/shared/env/env.dart';
import 'package:edge_library_app/shared/logger/logger.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:option_result/option_result.dart';

import 'package:riverpod/riverpod.dart';
import 'package:uno/uno.dart';

final patronFacadeProvider = Provider<PatronFacade>((ref) {
  final uno = Uno(baseURL: Env.baseUrl);

  uno.interceptors.request.use((request) async {
    final token = await ref.read(identityFacadeProvider).getAuthToken();

    request.headers['Authorization'] = 'Bearer $token';
    return request;
  });

  return PatronFacade(uno);
});

class PatronFacade with LoggerMixin {
  const PatronFacade(this.uno);

  final Uno uno;

  Future<Result<GetPatronResponse, Exception>> getPatron() async {
    try {
      final result = await uno.get('/patron', responseType: ResponseType.plain);

      if (result.status == HttpStatus.notFound) return Err(Exception());

      return switch (result) {
        Response(:final status) when status != HttpStatus.ok =>
          Err(Exception()),
        _ => Ok(GetPatronResponseMapper.fromJson(json.decode(result.data))),
      };
    } on UnoError catch (e, stackTrace) {
      logger.warning('error getting patron', e, stackTrace);
      return Err(e);
    }
  }

  Future<Result<GetPatronResponse, Exception>> createPatron(
      String name, String email) async {
    try {
      final result = await uno.post(
        '/patron',
        data: CreatePatronRequest(name, email).toJson(),
      );

      if (result.status == HttpStatus.notFound) return Err(Exception());

      return switch (result) {
        Response(:final status) when status != HttpStatus.ok =>
          Err(Exception()),
        _ => Ok(GetPatronResponseMapper.fromJson(result.data)),
      };
    } on UnoError catch (e, stackTrace) {
      logger.warning('error getting patron', e, stackTrace);
      return Err(e);
    }
  }
}

Frontend: Patron Entity

import 'package:edge_library_app/shared/api/patron/patron_facade.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:option_result/option_result.dart';

import 'package:riverpod/riverpod.dart';

final patronProvider = FutureProvider<Patron?>((ref) async {
  return ref.watch(patronFacadeProvider).getPatron().then(
        (value) => switch (value) {
          Ok(:final value) => value.data,
          _ => null,
        },
      );
});

Building the Book/Borrow features

Common: Book model

import 'package:dart_mappable/dart_mappable.dart';
import 'package:edge_library_common/src/models/base_model.dart';

part 'book.mapper.dart';

@MappableClass()
class Book extends BaseModel with BookMappable {
  const Book({
    required this.id,
    required this.title,
    required this.description,
    required this.author,
    required this.publicationDate,
    required this.genre,
    required this.copies,
  });

  final int id;
  final String title;
  final String description;
  final String author;
  @MappableField(key: 'publication_date')
  final DateTime publicationDate;
  final String genre;
  final int copies;
}

Common: JSON Result

import 'package:dart_mappable/dart_mappable.dart';
import 'package:option_result/option_result.dart';

part 'json_result.mapper.dart';

@MappableClass(discriminatorKey: 'type')
sealed class JsonResult<T, E> with JsonResultMappable<T, E> {
  const JsonResult();

  const factory JsonResult.ok(T data) = JsonOk;
  const factory JsonResult.err(E error) = JsonErr;
}

@MappableClass(discriminatorValue: 'ok')
class JsonOk<T, E> extends JsonResult<T, E> with JsonOkMappable<T, E> {
  const JsonOk(this.data);

  final T data;
}

@MappableClass(discriminatorValue: 'err')
class JsonErr<T, E> extends JsonResult<T, E> with JsonErrMappable<T, E> {
  const JsonErr(this.error);

  final E error;
}

extension EnhancedJsonResult<T, E> on JsonResult<T, E> {
  Result<T, E> toResult() {
    return switch (this) {
      JsonOk(:final data) => Ok(data),
      JsonErr(:final error) => Err(error),
    };
  }
}

Common: Get Books Response

import 'package:dart_mappable/dart_mappable.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_common/src/models/internal_server_error.dart';

part 'get_books_response.mapper.dart';

typedef GetBooksResponse = JsonResult<List<Book>, GetBooksResponseError>;

@MappableClass()
class GetBooksResponseError extends InternalServerError
    with GetBooksResponseErrorMappable {}

Common: Get Book Response

import 'package:dart_mappable/dart_mappable.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_common/src/models/internal_server_error.dart';

part 'get_book_response.mapper.dart';

typedef GetBookResponse = JsonResult<Book, GetBookResponseError>;

@MappableClass()
class GetBookResponseError extends InternalServerError
    with GetBookResponseErrorMappable {
  const GetBookResponseError({super.code});
}

Data: Book Repository

import 'package:edge_library_common/edge_library_common.dart';
import 'package:option_result/option_result.dart';
import 'package:supabase/supabase.dart';

class BookRepository {
  const BookRepository(this.client);

  final SupabaseClient client;

  Future<Result<None, Exception>> borrowBook(
    final int bookId,
    final String patronId,
  ) async {
    try {
      print('borrowing book');
      final bookResult = await getBookById(bookId);

      final book = bookResult.unwrap();

      if (book == null) throw Exception();

      final borrowedCopiesResponse = await client
          .from('borrowed_books')
          .select<PostgrestResponse>(
              '*', FetchOptions(count: CountOption.exact))
          .eq('book_id', bookId)
          .filter('is_active', 'eq', true);

      final borrowedCopiesCount = borrowedCopiesResponse.count ?? 0;

      if (book.copies <= borrowedCopiesCount) throw Exception();

      await client.from('borrowed_books').upsert({
        'book_id': bookId,
        'patron_id': patronId,
        'borrow_date': DateTime.now().toIso8601String(),
        'return_date': null,
      });

      return Ok(None());
    } catch (e) {
      return Err(Exception());
    }
  }

  Future<Result<None, Exception>> returnBook(
    final int bookId,
    final String patronId,
  ) async {
    try {
      print('returning book');

      await client
          .from('borrowed_books')
          .update({'return_date': DateTime.now().toIso8601String()})
          .eq('book_id', bookId)
          .eq('patron_id', patronId);

      return Ok(None());
    } catch (e) {
      return Err(Exception());
    }
  }

  Future<Result<List<Book>, Exception>> getBooks(final String? search) async {
    try {
      final baseQuery = client.from('books').select<PostgrestList>();
      final books = search == null
          ? await baseQuery
              .order('title', ascending: true)
              .withConverter((data) => data.map(BookMapper.fromMap))
          : await baseQuery
              .textSearch('title', search)
              .withConverter((data) => data.map(BookMapper.fromMap));

      return Ok(books.toList());
    } catch (e) {
      return Err(Exception());
    }
  }

  Future<Result<Book?, Exception>> getBookById(int id) async {
    try {
      final book = await client
          .from('books')
          .select<PostgrestMap?>()
          .eq('id', id)
          .maybeSingle()
          .withConverter(
              (data) => data == null ? null : BookMapper.fromMap(data));

      return Ok(book);
    } catch (e) {
      print(e);
      return Err(Exception());
    }
  }
}

API: Creating our books route

/books/index.dart

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_data/edge_library_data.dart';
import 'package:option_result/option_result.dart';

Future<Response> onRequest(RequestContext context) async {
  return switch (context.request.method) {
    HttpMethod.get => _handleGet(context),
    _ => Future.value(Response.json(statusCode: HttpStatus.notAcceptable)),
  };
}

Future<Response> _handleGet(RequestContext context) async {
  final repository = context.read<BookRepository>();
  final search = context.request.uri.queryParameters['search'];

  final books = await repository.getBooks(search);

  return Response.json(
    body: switch (books) {
      Ok(:final value) => GetBooksResponse.ok(value).toMap(),
      Err() => GetBooksResponse.err(GetBooksResponseError()),
    },
  );
}

API: Creating our book details route

/books/[id]/index.dart

import 'dart:async';
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:edge_library_data/edge_library_data.dart';
import 'package:option_result/option_result.dart';

FutureOr<Response> onRequest(
  RequestContext context,
  String idArg,
) async {
  return switch (context.request.method) {
    HttpMethod.get => _handleGet(context, idArg),
    _ => Future.value(Response.json(statusCode: HttpStatus.notAcceptable)),
  };
}

Future<Response> _handleGet(
  RequestContext context,
  String idArg,
) async {
  final id = int.tryParse(idArg);

  if (id == null) return Response.json(statusCode: HttpStatus.badRequest);

  final repository = context.read<BookRepository>();
  final book = await repository.getBookById(id);

  return switch (book) {
    Ok(:final value) when value == null => Response.json(
        body: const GetBookResponse.err(
          GetBookResponseError(code: 'NOT_FOUND'),
        ).toMap(),
      ),
    Ok(:final value) when value != null => Response.json(
        body: GetBookResponse.ok(value).toMap(),
      ),
    _ => Response.json(
        body: const GetBookResponse.err(
          GetBookResponseError(),
        ).toMap(),
      ),
  };
}

API: Creating our borrow routes

/books/[id]/borrow/index.dart

import 'dart:async';
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:edge_library_data/edge_library_data.dart';
import 'package:option_result/option_result.dart';

FutureOr<Response> onRequest(
  RequestContext context,
  String idArg,
) async {
  return switch (context.request.method) {
    HttpMethod.put => _handlePut(context, idArg),
    HttpMethod.post => _handlePost(context, idArg),
    _ => Future.value(Response.json(statusCode: HttpStatus.notAcceptable)),
  };
}

Future<Response> _handlePut(
  RequestContext context,
  String idArg,
) async {
  final id = int.tryParse(idArg);

  if (id == null) return Response.json(statusCode: HttpStatus.badRequest);

  final user = context.read<User>();
  final bookRepository = context.read<BookRepository>();
  final patron = await context.read<PatronRepository>().getPatronById(user.id);

  if (patron == null) return Response.json(statusCode: HttpStatus.badRequest);

  await bookRepository.returnBook(id, patron.id);

  return Response.json();
}

Future<Response> _handlePost(
  RequestContext context,
  String idArg,
) async {
  final id = int.tryParse(idArg);

  if (id == null) return Response.json(statusCode: HttpStatus.badRequest);

  final user = context.read<User>();
  final bookRepository = context.read<BookRepository>();
  final patron = await context.read<PatronRepository>().getPatronById(user.id);

  if (patron == null) return Response.json(statusCode: HttpStatus.badRequest);

  final result = await bookRepository.borrowBook(id, patron.id);

  return switch (result) {
    Ok() => Response.json(),
    _ => Response.json(statusCode: HttpStatus.internalServerError),
  };
}

Frontend: Book Facade

import 'dart:io';

import 'package:edge_library_app/shared/api/identity/identity_facade.dart';
import 'package:edge_library_app/shared/env/env.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:option_result/option_result.dart';
import 'package:riverpod/riverpod.dart';
import 'package:uno/uno.dart';

final bookFacadeProvider = Provider<BookFacade>((ref) {
  final uno = Uno(baseURL: Env.baseUrl);

  uno.interceptors.request.use((request) async {
    final token = await ref.read(identityFacadeProvider).getAuthToken();

    request.headers['Authorization'] = 'Bearer ${token.unwrapOr(null)}';
    return request;
  });

  return BookFacade(uno);
});

class BookFacade {
  const BookFacade(this.uno);

  final Uno uno;

  Future<Result<List<Book>, GetBooksResponseError>> getBooks() async {
    try {
      final result = await uno.get('/books');

      final jsonResult =
          JsonResultMapper.fromMap<List<Book>, GetBooksResponseError>(
              result.data);

      return jsonResult.toResult();
    } catch (e, stackTrace) {
      print(e);
      print(stackTrace);
      return Err(GetBooksResponseError());
    }
  }

  Future<Result<Book, GetBookResponseError>> getBookById(String id) async {
    try {
      final result = await uno.get('/books/$id');

      if (result.status != HttpStatus.ok) {
        return const Err(GetBookResponseError());
      }

      final jsonResult =
          JsonResultMapper.fromMap<Book, GetBookResponseError>(result.data);
      return jsonResult.toResult();
    } catch (e, stackTrace) {
      print(e);
      print(stackTrace);

      return const Err(GetBookResponseError());
    }
  }

  Future<Result<bool, Exception>> borrowBook(int id) async {
    try {
      final response = await uno.post('/books/$id/borrow');

      return Ok(response.status == HttpStatus.accepted);
    } catch (e) {
      return Err(Exception());
    }
  }

  Future<Result<bool, Exception>> returnBook(int id) async {
    try {
      final response = await uno.put('/books/$id/borrow');

      return Ok(response.status == HttpStatus.accepted);
    } catch (e) {
      return Err(Exception());
    }
  }
}

Frontend: Book Providers

import 'package:edge_library_app/shared/api/book/book_facade.dart';
import 'package:edge_library_app/shared/extensions/either_option.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:riverpod/riverpod.dart';

final booksListProvider = FutureProvider<List<Book>>((ref) async {
  return ref
      .watch(bookFacadeProvider)
      .getBooks()
      .then((value) => value.getOrThrow());
});

final bookByIdProvider = FutureProvider.family<Book, String>((ref, id) async {
  final result = await ref.watch(bookFacadeProvider).getBookById(id);

  return result.getOrThrow();
});

Frontend: Book List Screen

import 'package:edge_library_app/app/app_bar.dart';
import 'package:edge_library_app/entities/book/model/book.dart';
import 'package:edge_library_app/entities/book/widgets/book_list_view.dart';
import 'package:edge_library_app/entities/patron/widgets/patron_floating_action_button.dart';
import 'package:edge_library_common/edge_library_common.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class BooksListScreen extends ConsumerWidget {
  const BooksListScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final booksListAsync = ref.watch(booksListProvider);

    return Scaffold(
      appBar: const LibraryAppBar(
        title: Text("Edge's Library"),
      ),
      floatingActionButton: const PatronFloatingActionButton(),
      body: booksListAsync.when(
        data: BooksListDataView.new,
        error: BooksListErrorView.new,
        loading: BooksListLoadingView.new,
      ),
    );
  }
}

class BooksListLoadingView extends StatelessWidget {
  @visibleForTesting
  const BooksListLoadingView({super.key});

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

class BooksListErrorView extends StatelessWidget {
  @visibleForTesting
  const BooksListErrorView(this.error, this.stackTrace, {super.key});

  final Object error;
  final StackTrace stackTrace;

  @override
  Widget build(BuildContext context) {
    return const CircularProgressIndicator();
  }
}

class BooksListDataView extends StatelessWidget {
  @visibleForTesting
  const BooksListDataView(this.data, {super.key});

  final List<Book> data;

  @override
  Widget build(BuildContext context) {
    return BookListView(books: data);
  }
}

Frontend: Book Detail Notifier

import 'dart:async';

import 'package:equatable/equatable.dart';
import 'package:riverpod/riverpod.dart';

import 'package:edge_library_app/entities/book/model/book.dart';
import 'package:edge_library_app/entities/patron/model/patron.dart';
import 'package:edge_library_app/shared/api/book/book_facade.dart';
import 'package:edge_library_common/edge_library_common.dart';

final bookDetailNotifierProvider =
    AsyncNotifierProviderFamily<BookDetailNotifier, BookDetailState, String>(
        BookDetailNotifier.new);

class BookDetailNotifier extends FamilyAsyncNotifier<BookDetailState, String> {
  @override
  FutureOr<BookDetailState> build(arg) async {
    final book = await ref.watch(bookByIdProvider(arg).future);
    final patron = await ref.watch(patronProvider.future);
    final isBorrowed =
        patron?.borrowed.any((borrowedBook) => borrowedBook.id == book.id);

    return BookDetailState(
      book: book,
      isBorrowed: isBorrowed ?? false,
    );
  }

  Future<void> borrowBook() async {
    final state = await future;
    await ref.read(bookFacadeProvider).borrowBook(state.book.id);

    ref.invalidate(patronProvider);
  }

  Future<void> returnBook() async {
    final state = await future;
    await ref.read(bookFacadeProvider).returnBook(state.book.id);
    ref.invalidate(patronProvider);
  }
}

class BookDetailState extends Equatable {
  const BookDetailState({
    required this.book,
    this.isBorrowed = false,
  });

  final Book book;
  final bool isBorrowed;

  @override
  List<Object?> get props => [book, isBorrowed];

  BookDetailState copyWith({
    Book? book,
    bool? isBorrowed,
  }) {
    return BookDetailState(
      book: book ?? this.book,
      isBorrowed: isBorrowed ?? this.isBorrowed,
    );
  }
}

What did we learn?

  • Developing vertically helps us ship features
  • Monoglot development reduces context switching and increases productivity
  • Server-side Dart still requires some assembly

Would you like to learn more?

Made with Slides.com