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,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();
}
}