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
Tradeoffs
Tradeoffs
Benefits
Drawbacks
Cognitive load is how much a developer needs to think in order to complete a task.
The inherent level of difficulty associated with a specific problem we are solving.
The level of difficulty imposed by factors not directly relevant to the problem we are trying to solve.
Benefits
Wait, what is Dart again? And what is Flutter?
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!'),
),
),
);
}
}
https://edge-library.fly.dev
Patrons: authenticated users
mkdir edge_library && cd edge_library
dart_frog create edge_library_api
flutter create edge_library_app -e
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
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
import 'package:dart_frog/dart_frog.dart';
Response onRequest(RequestContext context) {
return Response(body: 'Welcome to Dart Frog!');
}
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;
},
),
);
}
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;
}
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,
};
},
),
);
}
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;
}
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,
);
}
}
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;
}
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;
}
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;
}
}
}
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']!),
),
);
}
/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(),
);
}
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);
}
}
}
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,
},
);
});
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;
}
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),
};
}
}
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 {}
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});
}
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());
}
}
}
/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()),
},
);
}
/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(),
),
};
}
/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),
};
}
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());
}
}
}
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();
});
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);
}
}
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,
);
}
}
Observable Flutter: Code sharing episodes 1 and 2
How to organize software development teams?
What Do I Mean By “Horizontal” Versus “Vertical” Engineering Teams?