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:
Stephan D committed 2026-09-11 15:36:58 +02:00
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);
}
}
}