master #81
@ -1,5 +1,5 @@
|
||||
<!--
|
||||
* Copyright (C) 2022 WYATT GROUP
|
||||
* Copyright (C) 2023 WYATT GROUP
|
||||
* Please see the AUTHORS file for details.
|
||||
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
@ -19,9 +19,7 @@
|
||||
# Flutter - Authentication BLoC
|
||||
|
||||
<p align="left">
|
||||
<a href="https://git.wyatt-studio.fr/Wyatt-FOSS/wyatt-packages/src/branch/master/packages/wyatt_analysis">
|
||||
<img src="https://img.shields.io/badge/Style-Wyatt%20Analysis-blue.svg?style=flat-square" alt="Style: Wyatt Analysis" />
|
||||
</a>
|
||||
<a href="https://git.wyatt-studio.fr/Wyatt-FOSS/wyatt-packages/src/branch/master/packages/wyatt_analysis"><img src="https://img.shields.io/badge/Style-Wyatt%20Analysis-blue.svg?style=flat-square" alt="Style: Wyatt Analysis" /></a>
|
||||
<img src="https://img.shields.io/badge/SDK-Flutter-blue?style=flat-square" alt="SDK: Flutter" />
|
||||
</p>
|
||||
|
||||
@ -29,37 +27,195 @@ Authentication Bloc for Flutter.
|
||||
|
||||
## Features
|
||||
|
||||
- Wyatt Architecture
|
||||
- Entities:
|
||||
- Account : AccountModel -> Contains account information from provider
|
||||
- AccountWrapper : AccountWrapperModel -> Contains account and extra data.
|
||||
- Data Sources:
|
||||
- Local:
|
||||
- Cached Authentication Data : AuthenticationCacheDataSourceImpl -> Provides a cache implementation
|
||||
- Remote:
|
||||
- Remote Authentication Data : AuthenticationFirebaseDataSourceImpl -> Provides a proxy to FirebaseAuth
|
||||
- Repositories:
|
||||
- AuthenticationRepository : AuthenticationRepositoryImpl -> Provides all authentication methods
|
||||
- Features:
|
||||
- Authentication:
|
||||
- AuthenticationBuilder : widget to build reactive view from authentication state
|
||||
- AuthenticationCubit : tracks every auth changes, have sign out capability.
|
||||
- SignUp:
|
||||
- SignUpCubit: implementation of a FormDataCubit from `wyatt_form_bloc` for the sign up
|
||||
- SignIn:
|
||||
- SignUpCubit: implementation of a FormDataCubit from `wyatt_form_bloc` for the sign in
|
||||
- Consistent
|
||||
* Every class have same naming convention
|
||||
- Tested
|
||||
* 🧐 Wyatt Architecture
|
||||
* 🧱 Entities
|
||||
- Account -> Contains account information from provider.
|
||||
- Session -> Contains account and associated data retrieved from an external source.
|
||||
- AuthenticationChangeEvent -> Describes an event in authentication change (sign in, sign up, sign out, etc...)
|
||||
- SessionWrapper -> Contains latest authentication change event and session.
|
||||
* 🔑 Powerful and secured authentication repository
|
||||
* 🔥 Multiple data sources
|
||||
- Mock
|
||||
- Firebase
|
||||
* 🧊 Cubits, why make it complicated when you can make it simple?
|
||||
- Goes to the essential.
|
||||
* 📐 Consistent
|
||||
- Every class have same naming convention
|
||||
* 🧪 Tested
|
||||
* 📚 Documented: [available here](./doc/api/index.md)
|
||||
|
||||
## Getting started
|
||||
|
||||
Simply add `wyatt_authentication_bloc` in `pubspec.yaml`, then
|
||||
Simply add `wyatt_authentication_bloc` in `pubspec.yaml` , then
|
||||
|
||||
```dart
|
||||
import 'package:wyatt_authentication_bloc/wyatt_authentication_bloc.dart';
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Data source
|
||||
|
||||
// TODO
|
||||
The first step is to provide a data source.
|
||||
|
||||
```dart
|
||||
getIt.registerLazySingleton<AuthenticationRemoteDataSource<int>>(
|
||||
() => AuthenticationFirebaseDataSourceImpl<int>(
|
||||
firebaseAuth: FirebaseAuth.instance,
|
||||
googleSignIn:
|
||||
GoogleSignIn(clientId: DefaultFirebaseOptions.ios.iosClientId)),
|
||||
);
|
||||
```
|
||||
|
||||
> Here we use GetIt (see example project)
|
||||
|
||||
### Repository
|
||||
|
||||
Then you can configure your repository.
|
||||
|
||||
```dart
|
||||
final AuthenticationRepository<int> authenticationRepository = AuthenticationRepositoryImpl(
|
||||
authenticationRemoteDataSource:
|
||||
getIt<AuthenticationRemoteDataSource<int>>(),
|
||||
customPasswordValidator: const CustomPassword.pure(),
|
||||
extraSignUpInputs: [
|
||||
FormInput(
|
||||
AuthFormField.confirmPassword,
|
||||
const ConfirmedPassword.pure(),
|
||||
metadata: const FormInputMetadata<void>(export: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
> Here we pass some extra inputs for the sign up, and a custom password validator.
|
||||
|
||||
### Cubits
|
||||
|
||||
It is necessary to implement each cubit. Don't panic, most of the work is already done 😊 you just have to customize the logic of these.
|
||||
|
||||
In each of these cubits it is necessary to overload the various callbacks.
|
||||
|
||||
> Here the associated data `Data` is a `int`
|
||||
|
||||
#### Authentication
|
||||
|
||||
In the authentication are managed, the refresh, the deletion of account or the disconnection.
|
||||
|
||||
```dart
|
||||
class ExampleAuthenticationCubit extends AuthenticationCubit<int> {
|
||||
ExampleAuthenticationCubit({required super.authenticationRepository});
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onReauthenticate(Result<Account, AppException> result) async {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onRefresh(Result<Account, AppException> result) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignInFromCache(SessionWrapper<int> wrapper) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<void> onSignOut() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<void> onDelete() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sign Up
|
||||
|
||||
```dart
|
||||
class ExampleSignUpCubit extends SignUpCubit<int> {
|
||||
ExampleSignUpCubit({
|
||||
required super.authenticationRepository,
|
||||
});
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignUpWithEmailAndPassword(Result<Account, AppException> result, WyattForm form) async {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### Sign In
|
||||
|
||||
```dart
|
||||
class ExampleSignInCubit extends SignInCubit<int> {
|
||||
ExampleSignInCubit({
|
||||
required super.authenticationRepository,
|
||||
});
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignInWithEmailAndPassword(Result<Account, AppException> result, WyattForm form) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignInAnonymously(Result<Account, AppException> result, WyattForm form) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignInWithGoogle(Result<Account, AppException> result, WyattForm form) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After setting up all these cubits you can provide them in the application. And that's it!
|
||||
|
||||
```dart
|
||||
BlocProvider<SignUpCubit<int>>(
|
||||
create: (_) => ExampleSignUpCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
),
|
||||
BlocProvider<SignInCubit<int>>(
|
||||
create: (_) => ExampleSignInCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Widgets are provided to make your life easier. Starting with the `AuthenticationBuilder` which allows you to build according to the authentication state.
|
||||
|
||||
```dart
|
||||
AuthenticationBuilder<int>(
|
||||
authenticated: (context, sessionWrapper) => Text(
|
||||
'Logged as ${sessionWrapper.session?.account.email} | GeneratedId is ${sessionWrapper.session?.data}'),
|
||||
unauthenticated: (context) =>
|
||||
const Text('Not logged (unauthenticated)'),
|
||||
unknown: (context) => const Text('Not logged (unknown)'),
|
||||
),
|
||||
```
|
||||
|
||||
A `BuildContext` extension is also available to access certain attributes more quickly.
|
||||
|
||||
```dart
|
||||
Text('Home | ${context.account<AuthenticationCubit<int>, int>()?.email}'),
|
||||
```
|
||||
|
||||
Listeners are used to listen to the status of the sign in and sign up forms.
|
||||
|
||||
```dart
|
||||
return SignInListener<int>(
|
||||
onError: (context, status, errorMessage) => ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(errorMessage ?? 'Sign In Failure')),
|
||||
),
|
||||
child: ...
|
||||
);
|
||||
```
|
@ -10,7 +10,6 @@ dart pub global run dartdoc --format md \
|
||||
--no-auto-include-dependencies \
|
||||
--no-validate-links \
|
||||
--show-progress \
|
||||
--output "/Users/hpcl/Work/Wyatt/wyatt-packages/wiki/wyatt_authentication_bloc/"
|
||||
|
||||
sed -i -e "s/\/\/export 'package:firebase_auth\/firebase_auth.dart';/export 'package:firebase_auth\/firebase_auth.dart';/g" lib/wyatt_authentication_bloc.dart
|
||||
sed -i -e "s/\/\/export 'package:google_sign_in\/google_sign_in.dart';/export 'package:google_sign_in\/google_sign_in.dart';/g" lib/wyatt_authentication_bloc.dart
|
||||
|
@ -26,25 +26,49 @@ class MockAuthenticationRepository extends Mock
|
||||
|
||||
class MockAccount extends Mock implements Account {}
|
||||
|
||||
class TestAuthenticationCubit extends AuthenticationCubit<int> {
|
||||
TestAuthenticationCubit({required super.authenticationRepository});
|
||||
|
||||
@override
|
||||
FutureOrResult<void> onDelete() async => const Ok(null);
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onReauthenticate(
|
||||
Result<Account, AppException> result,
|
||||
) async =>
|
||||
const Ok(null);
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onRefresh(Result<Account, AppException> result) async =>
|
||||
const Ok(null);
|
||||
|
||||
@override
|
||||
FutureOrResult<int?> onSignInFromCache(SessionWrapper<int> wrapper) async =>
|
||||
const Ok(null);
|
||||
|
||||
@override
|
||||
FutureOrResult<void> onSignOut() async => const Ok(null);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AuthenticationCubit<T>', () {
|
||||
final MockAccount account = MockAccount();
|
||||
final AccountWrapper<int> wrapper = AccountWrapperModel(account, 10);
|
||||
final SessionWrapper<int> wrapper = SessionWrapper(
|
||||
event: const UnknownAuthenticationEvent(),
|
||||
session: Session(account: account, data: 10),
|
||||
);
|
||||
late AuthenticationRepository<int> authenticationRepository;
|
||||
|
||||
setUp(() {
|
||||
authenticationRepository = MockAuthenticationRepository();
|
||||
when(() => authenticationRepository.streamAccount()).thenAnswer(
|
||||
when(() => authenticationRepository.sessionStream()).thenAnswer(
|
||||
(_) => const Stream.empty(),
|
||||
);
|
||||
when(
|
||||
() => authenticationRepository.getAccount(),
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
});
|
||||
|
||||
test('initial auth state is `unknown`', () {
|
||||
expect(
|
||||
AuthenticationCubit<int>(
|
||||
TestAuthenticationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
).state,
|
||||
const AuthenticationState<Never>.unknown(),
|
||||
@ -53,17 +77,13 @@ void main() {
|
||||
|
||||
group('ListenForAuthenticationChanges', () {
|
||||
blocTest<AuthenticationCubit<int>, AuthenticationState<int>>(
|
||||
'emits authenticated when stream contains account',
|
||||
'emits authenticated when stream contains session',
|
||||
setUp: () {
|
||||
when(() => authenticationRepository.streamAccount()).thenAnswer(
|
||||
(_) => Stream.fromIterable([
|
||||
Future.value(
|
||||
Ok(wrapper),
|
||||
)
|
||||
]),
|
||||
when(() => authenticationRepository.sessionStream()).thenAnswer(
|
||||
(_) => Stream.fromIterable([wrapper]),
|
||||
);
|
||||
},
|
||||
build: () => AuthenticationCubit(
|
||||
build: () => TestAuthenticationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
seed: () => const AuthenticationState.unknown(),
|
||||
@ -73,39 +93,12 @@ void main() {
|
||||
blocTest<AuthenticationCubit<int>, AuthenticationState<int>>(
|
||||
'emits unauthenticated when account stream is empty',
|
||||
setUp: () {
|
||||
when(
|
||||
() => authenticationRepository.destroyCache(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
when(() => authenticationRepository.streamAccount()).thenAnswer(
|
||||
(_) => Stream.fromIterable([
|
||||
Future.value(
|
||||
Ok(AccountWrapperModel(null, 1)),
|
||||
)
|
||||
]),
|
||||
when(() => authenticationRepository.sessionStream()).thenAnswer(
|
||||
(_) => Stream.fromIterable(
|
||||
[const SessionWrapper(event: SignedOutEvent())],),
|
||||
);
|
||||
},
|
||||
build: () => AuthenticationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
seed: () => const AuthenticationState.unknown(),
|
||||
expect: () => [const AuthenticationState<int>.unauthenticated()],
|
||||
);
|
||||
|
||||
blocTest<AuthenticationCubit<int>, AuthenticationState<int>>(
|
||||
'emits unauthenticated when there is an error in stream',
|
||||
setUp: () {
|
||||
when(
|
||||
() => authenticationRepository.destroyCache(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
when(() => authenticationRepository.streamAccount()).thenAnswer(
|
||||
(_) => Stream.fromIterable([
|
||||
Future.value(
|
||||
Err(ServerException()),
|
||||
)
|
||||
]),
|
||||
);
|
||||
},
|
||||
build: () => AuthenticationCubit(
|
||||
build: () => TestAuthenticationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
seed: () => const AuthenticationState.unknown(),
|
||||
@ -121,7 +114,7 @@ void main() {
|
||||
() => authenticationRepository.signOut(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
},
|
||||
build: () => AuthenticationCubit(
|
||||
build: () => TestAuthenticationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
act: (cubit) => cubit.signOut(),
|
||||
|
@ -27,7 +27,7 @@ void main() {
|
||||
const AuthenticationState<void> state =
|
||||
AuthenticationState.unauthenticated();
|
||||
expect(state.status, AuthenticationStatus.unauthenticated);
|
||||
expect(state.accountWrapper, null);
|
||||
expect(state.wrapper, null);
|
||||
});
|
||||
});
|
||||
|
||||
@ -36,10 +36,13 @@ void main() {
|
||||
final MockAccount account = MockAccount();
|
||||
final AuthenticationState<void> state =
|
||||
AuthenticationState.authenticated(
|
||||
AccountWrapperModel<void>(account, null),
|
||||
SessionWrapper<void>(
|
||||
event: SignedInEvent(account: account),
|
||||
session: Session(account: account),
|
||||
),
|
||||
);
|
||||
expect(state.status, AuthenticationStatus.authenticated);
|
||||
expect(state.accountWrapper?.account, account);
|
||||
expect(state.wrapper?.session?.account, account);
|
||||
});
|
||||
});
|
||||
|
||||
@ -49,11 +52,14 @@ void main() {
|
||||
const String extra = 'AwesomeExtraData';
|
||||
final AuthenticationState<String> state =
|
||||
AuthenticationState.authenticated(
|
||||
AccountWrapperModel(account, extra),
|
||||
SessionWrapper<String>(
|
||||
event: SignedInEvent(account: account),
|
||||
session: Session(account: account, data: extra),
|
||||
),
|
||||
);
|
||||
expect(state.status, AuthenticationStatus.authenticated);
|
||||
expect(state.accountWrapper?.account, account);
|
||||
expect(state.accountWrapper?.data, extra);
|
||||
expect(state.wrapper?.session?.account, account);
|
||||
expect(state.wrapper?.session?.data, extra);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -39,15 +39,21 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
authenticationRepository = MockAuthenticationRepository();
|
||||
when(
|
||||
() => authenticationRepository.getAccount(),
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
account = MockAccount();
|
||||
|
||||
when(() => authenticationRepository.sessionStream()).thenAnswer(
|
||||
(_) => Stream.fromIterable([
|
||||
SessionWrapper<int>(
|
||||
event: SignedInFromCacheEvent(account: account),
|
||||
session: Session<int>(account: account, data: 10),
|
||||
)
|
||||
]),
|
||||
);
|
||||
|
||||
when(
|
||||
() => authenticationRepository.refresh(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
|
||||
account = MockAccount();
|
||||
when(
|
||||
() => account.emailVerified,
|
||||
).thenAnswer((_) => true);
|
||||
@ -129,7 +135,7 @@ void main() {
|
||||
setUp: () {
|
||||
when(
|
||||
() => authenticationRepository.refresh(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
},
|
||||
build: () => EmailVerificationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
@ -145,7 +151,7 @@ void main() {
|
||||
setUp: () {
|
||||
when(
|
||||
() => authenticationRepository.refresh(),
|
||||
).thenAnswer((_) async => const Ok(null));
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
when(() => account.emailVerified).thenAnswer((_) => false);
|
||||
},
|
||||
build: () => EmailVerificationCubit(
|
||||
@ -161,7 +167,7 @@ void main() {
|
||||
'emits success with true if verified',
|
||||
setUp: () {
|
||||
when(() => authenticationRepository.refresh())
|
||||
.thenAnswer((_) async => const Ok(null));
|
||||
.thenAnswer((_) async => Ok(account));
|
||||
},
|
||||
build: () => EmailVerificationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
@ -183,7 +189,7 @@ void main() {
|
||||
'emits success with false if not verified',
|
||||
setUp: () {
|
||||
when(() => authenticationRepository.refresh())
|
||||
.thenAnswer((_) async => const Ok(null));
|
||||
.thenAnswer((_) async => Ok(account));
|
||||
when(() => account.emailVerified).thenAnswer((_) => false);
|
||||
},
|
||||
build: () => EmailVerificationCubit(
|
||||
@ -222,28 +228,6 @@ void main() {
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<EmailVerificationCubit<int>, EmailVerificationState>(
|
||||
'emits failure on get account error',
|
||||
setUp: () {
|
||||
when(() => authenticationRepository.getAccount())
|
||||
.thenAnswer((_) async => Err(ServerException('erreur')));
|
||||
},
|
||||
build: () => EmailVerificationCubit(
|
||||
authenticationRepository: authenticationRepository,
|
||||
),
|
||||
seed: () => const EmailVerificationState(),
|
||||
act: (cubit) => cubit.checkEmailVerification(),
|
||||
expect: () => [
|
||||
const EmailVerificationState(
|
||||
status: FormStatus.submissionInProgress,
|
||||
),
|
||||
const EmailVerificationState(
|
||||
errorMessage: 'erreur',
|
||||
status: FormStatus.submissionFailure,
|
||||
)
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -59,14 +59,7 @@ void main() {
|
||||
formRepository = MockFormRepository();
|
||||
|
||||
when(
|
||||
() => authenticationRepository.signUp(
|
||||
email: any(named: 'email'),
|
||||
password: any(named: 'password'),
|
||||
),
|
||||
).thenAnswer((_) async => Ok(account));
|
||||
|
||||
when(
|
||||
() => authenticationRepository.signInWithEmailAndPassword(
|
||||
() => authenticationRepository.signUpWithEmailAndPassword(
|
||||
email: any(named: 'email'),
|
||||
password: any(named: 'password'),
|
||||
),
|
||||
@ -318,7 +311,7 @@ void main() {
|
||||
act: (cubit) => cubit.signUpWithEmailPassword(),
|
||||
verify: (_) {
|
||||
verify(
|
||||
() => authenticationRepository.signUp(
|
||||
() => authenticationRepository.signUpWithEmailAndPassword(
|
||||
email: validEmailString,
|
||||
password: validPasswordString,
|
||||
),
|
||||
@ -409,7 +402,7 @@ void main() {
|
||||
'when signUp fails',
|
||||
setUp: () {
|
||||
when(
|
||||
() => authenticationRepository.signUp(
|
||||
() => authenticationRepository.signUpWithEmailAndPassword(
|
||||
email: any(named: 'email'),
|
||||
password: any(named: 'password'),
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user