Add shared authentication and email verification services
Provide reusable models, injectable service contracts, lifecycle-safe providers and explicit dummy implementations for login, signup and verification. Add focused error and async lifecycle tests. Related to #4 and #5.
This commit is contained in:
1 parent
d9318053e8
commit
27424ed886
15 files changed
+708
No files matched your search
@@ -0,0 +1,56 @@
|
||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||
class LoginRequest {
|
||||
const LoginRequest({
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.locale,
|
||||
});
|
||||
final String email;
|
||||
final String password;
|
||||
final String locale;
|
||||
}
|
||||
|
||||
class SignupRequest {
|
||||
const SignupRequest({
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.password,
|
||||
required this.locale,
|
||||
});
|
||||
final String name;
|
||||
final String email;
|
||||
final String password;
|
||||
final String locale;
|
||||
}
|
||||
|
||||
/// An operation result, not proof of a session or an authorization decision.
|
||||
class AuthResult {
|
||||
const AuthResult({required this.userId, required this.isSimulated});
|
||||
final String userId;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected }
|
||||
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.code);
|
||||
final AuthError code;
|
||||
@override
|
||||
String toString() => 'AuthException(${code.name})';
|
||||
}
|
||||
|
||||
enum AuthStatus { idle, loading, success, error }
|
||||
|
||||
class AuthState {
|
||||
const AuthState._(this.status, {this.result, this.error});
|
||||
const AuthState.idle() : this._(AuthStatus.idle);
|
||||
const AuthState.loading() : this._(AuthStatus.loading);
|
||||
const AuthState.success(AuthResult result)
|
||||
: this._(AuthStatus.success, result: result);
|
||||
const AuthState.failure(AuthError error)
|
||||
: this._(AuthStatus.error, error: error);
|
||||
final AuthStatus status;
|
||||
final AuthResult? result;
|
||||
final AuthError? error;
|
||||
bool get isLoading => status == AuthStatus.loading;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
enum VerificationStatus {
|
||||
idle,
|
||||
sending,
|
||||
sent,
|
||||
checking,
|
||||
verified,
|
||||
expired,
|
||||
invalid,
|
||||
error,
|
||||
}
|
||||
|
||||
enum VerificationError { expired, invalid, internal }
|
||||
|
||||
class VerificationException implements Exception {
|
||||
const VerificationException(this.code);
|
||||
final VerificationError code;
|
||||
}
|
||||
|
||||
class VerificationResult {
|
||||
const VerificationResult({required this.isSimulated});
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
class VerificationState {
|
||||
const VerificationState(this.status, {this.isSimulated = false});
|
||||
final VerificationStatus status;
|
||||
final bool isSimulated;
|
||||
bool get isLoading =>
|
||||
status == VerificationStatus.sending ||
|
||||
status == VerificationStatus.checking;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/auth.dart';
|
||||
|
||||
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
|
||||
/// Reset invalidates pending results; it does not cancel service-side work.
|
||||
abstract class AuthProvider extends ChangeNotifier {
|
||||
AuthState _state = const AuthState.idle();
|
||||
int _generation = 0;
|
||||
bool _disposed = false;
|
||||
AuthState get state => _state;
|
||||
|
||||
@protected
|
||||
Future<void> run(Future<AuthResult> Function() operation) async {
|
||||
if (_disposed || _state.isLoading) return;
|
||||
final generation = ++_generation;
|
||||
_state = const AuthState.loading();
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await operation();
|
||||
if (_disposed || generation != _generation) return;
|
||||
_state = AuthState.success(result);
|
||||
} catch (error) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
// Never retain or expose arbitrary exceptions that may contain credentials.
|
||||
_state = AuthState.failure(
|
||||
error is AuthException ? error.code : AuthError.unexpected,
|
||||
);
|
||||
}
|
||||
if (!_disposed && generation == _generation) notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
if (_disposed) return;
|
||||
++_generation;
|
||||
_state = const AuthState.idle();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
++_generation;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../models/auth.dart';
|
||||
import '../services/contracts.dart';
|
||||
import 'auth.dart';
|
||||
|
||||
class LoginProvider extends AuthProvider {
|
||||
LoginProvider(this.service);
|
||||
final LoginService service;
|
||||
|
||||
Future<void> login({
|
||||
required String email,
|
||||
required String password,
|
||||
required String locale,
|
||||
}) => run(
|
||||
() => service.login(
|
||||
LoginRequest(email: email.trim(), password: password, locale: locale),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../models/auth.dart';
|
||||
import '../services/contracts.dart';
|
||||
import 'auth.dart';
|
||||
|
||||
class SignupProvider extends AuthProvider {
|
||||
SignupProvider(this.service);
|
||||
final SignupService service;
|
||||
|
||||
Future<void> signup({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
required String locale,
|
||||
}) => run(
|
||||
() => service.signup(
|
||||
SignupRequest(
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
locale: locale,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/verification.dart';
|
||||
import '../services/verification.dart';
|
||||
|
||||
class VerificationProvider extends ChangeNotifier {
|
||||
VerificationProvider(this.service);
|
||||
final VerificationService service;
|
||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
||||
VerificationState get state => _state;
|
||||
bool _disposed = false;
|
||||
int _generation = 0;
|
||||
|
||||
Future<void> send({required String email, required String locale}) => _run(
|
||||
() => service.send(email: email.trim(), locale: locale),
|
||||
VerificationStatus.sending,
|
||||
VerificationStatus.sent,
|
||||
);
|
||||
Future<void> confirm(String token) => _run(
|
||||
() => token.trim().isEmpty
|
||||
? Future.error(const VerificationException(VerificationError.invalid))
|
||||
: service.confirm(token: token),
|
||||
VerificationStatus.checking,
|
||||
VerificationStatus.verified,
|
||||
);
|
||||
|
||||
Future<void> _run(
|
||||
Future<VerificationResult> Function() operation,
|
||||
VerificationStatus loading,
|
||||
VerificationStatus success,
|
||||
) async {
|
||||
if (_disposed || _state.isLoading) return;
|
||||
final generation = ++_generation;
|
||||
_state = VerificationState(loading);
|
||||
notifyListeners();
|
||||
if (_disposed || generation != _generation) return;
|
||||
try {
|
||||
final result = await operation();
|
||||
if (_disposed || generation != _generation) return;
|
||||
_state = VerificationState(success, isSimulated: result.isSimulated);
|
||||
} catch (error) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
final code = error is VerificationException
|
||||
? error.code
|
||||
: VerificationError.internal;
|
||||
// Send failures must not be presented as confirmation-token failures.
|
||||
_state = VerificationState(
|
||||
loading == VerificationStatus.sending
|
||||
? VerificationStatus.error
|
||||
: switch (code) {
|
||||
VerificationError.expired => VerificationStatus.expired,
|
||||
VerificationError.invalid => VerificationStatus.invalid,
|
||||
VerificationError.internal => VerificationStatus.error,
|
||||
},
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
if (_disposed) return;
|
||||
++_generation;
|
||||
_state = const VerificationState(VerificationStatus.idle);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
++_generation;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../models/auth.dart';
|
||||
|
||||
/// Production adapters own transport, DTO mapping, and authentication effects.
|
||||
/// Providers and widgets only consume domain results and typed failures.
|
||||
abstract interface class LoginService {
|
||||
Future<AuthResult> login(LoginRequest request);
|
||||
}
|
||||
|
||||
abstract interface class SignupService {
|
||||
Future<AuthResult> signup(SignupRequest request);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../models/auth.dart';
|
||||
import 'contracts.dart';
|
||||
|
||||
/// Deterministic local simulation. No network, storage, credentials or sessions
|
||||
/// are retained. Configure failures explicitly rather than using magic emails.
|
||||
class DummyLoginService implements LoginService {
|
||||
const DummyLoginService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.failure,
|
||||
});
|
||||
final Duration delay;
|
||||
final AuthError? failure;
|
||||
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return const AuthResult(userId: 'demo-user', isSimulated: true);
|
||||
}
|
||||
}
|
||||
|
||||
class DummySignupService implements SignupService {
|
||||
const DummySignupService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.failure,
|
||||
});
|
||||
final Duration delay;
|
||||
final AuthError? failure;
|
||||
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return const AuthResult(userId: 'demo-user', isSimulated: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import '../models/verification.dart';
|
||||
|
||||
abstract interface class VerificationService {
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
});
|
||||
Future<VerificationResult> confirm({required String token});
|
||||
}
|
||||
|
||||
/// Explicit preview tokens only. Never use this implementation in production.
|
||||
class DummyVerificationService implements VerificationService {
|
||||
const DummyVerificationService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.sendFailure,
|
||||
});
|
||||
final Duration delay;
|
||||
final VerificationError? sendFailure;
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (sendFailure case final error?) throw VerificationException(error);
|
||||
return const VerificationResult(isSimulated: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
switch (token) {
|
||||
case 'demo-valid':
|
||||
return const VerificationResult(isSimulated: true);
|
||||
case 'demo-expired':
|
||||
throw const VerificationException(VerificationError.expired);
|
||||
case 'demo-internal':
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
default:
|
||||
throw const VerificationException(VerificationError.invalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// Platform-independent authentication state and injectable services.
|
||||
library;
|
||||
|
||||
export 'src/models/auth.dart';
|
||||
export 'src/providers/auth.dart';
|
||||
export 'src/providers/login.dart';
|
||||
export 'src/providers/signup.dart';
|
||||
export 'src/services/contracts.dart';
|
||||
export 'src/services/dummy.dart';
|
||||
export 'src/models/verification.dart';
|
||||
export 'src/providers/verification.dart';
|
||||
export 'src/services/verification.dart';
|
||||
Reference in new issue
Block a user