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.)
  • Increased cognitive load - context switching between layers

What is cognitive load?

Cognitive load is how much a developer needs to think in order to complete a task.

Intrinsic

The inherent level of difficulty associated with a specific problem we are solving.

Extraneous

The level of difficulty imposed by factors not directly relevant to the problem we are trying to solve.

Types of Cognitive Load

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?

Speed dating code intro

import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

Why Flutter?

Full-stack Dart in 2024

  • 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 Globe 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: backend

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

Building login: frontend

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

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

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

The future is bright

  • Celest on the horizon
  • Serverpod adding Terraform support
  • Various IfC providers interested in supporting Dart.

Would you like to learn more?

Full-stack Dart 2024 (60min)

By Ryan Edge

Full-stack Dart 2024 (60min)

  • 54