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.)
- 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
-
FlyGlobe 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?
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 2024 (60min)
By Ryan Edge
Full-stack Dart 2024 (60min)
- 54