Building a Full-Stack Application with Flutter and Dart
by Ryan Edge
https://edge-library.fly.dev
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?
Observable Flutter: Code sharing episodes 1 and 2
How to organize software development teams?
What Do I Mean By “Horizontal” Versus “Vertical” Engineering Teams?
Full-stack Dart (60min)
By Ryan Edge
Full-stack Dart (60min)
- 96