Fix onboarding UX and add replaceable journey analytics

Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
This commit is contained in:
Stephan D committed 2026-09-12 08:36:45 +02:00
1 parent bf7778f312
commit f1cbf64282
28 files changed
+1158 -64

No files matched your search

+5
View File
@@ -61,3 +61,8 @@ When a supported sxchnge BFF contract exists, implement transport DTOs and mappe
`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state.
`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary.
`InstrumentedAuthServices` decorates the three auth service interfaces with a
shared `JourneyRecorder` from `sxchnge_analytics`. It preserves results/exceptions
for providers while emitting only typed semantic outcomes. `SimulationInfo`
allows decorators and UI to identify preview services before the first result.
@@ -17,9 +17,7 @@ class VerificationProvider extends ChangeNotifier {
VerificationStatus.sent,
);
Future<void> confirm(String token) => _run(
() => token.trim().isEmpty
? Future.error(const VerificationException(VerificationError.invalid))
: service.confirm(token: token),
() => service.confirm(token: token),
VerificationStatus.checking,
VerificationStatus.verified,
);
@@ -1,9 +1,12 @@
import '../models/auth.dart';
import 'contracts.dart';
import 'verification.dart';
/// Deterministic local simulation. No network, storage, credentials or sessions
/// are retained. Configure failures explicitly rather than using magic emails.
class DummyLoginService implements LoginService {
class DummyLoginService implements LoginService, SimulationInfo {
@override
bool get isSimulated => true;
const DummyLoginService({
this.delay = const Duration(milliseconds: 600),
this.failure,
@@ -19,7 +22,9 @@ class DummyLoginService implements LoginService {
}
}
class DummySignupService implements SignupService {
class DummySignupService implements SignupService, SimulationInfo {
@override
bool get isSimulated => true;
const DummySignupService({
this.delay = const Duration(milliseconds: 600),
this.failure,
@@ -0,0 +1,117 @@
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/auth.dart';
import '../models/verification.dart';
import 'contracts.dart';
import 'verification.dart';
/// Decorates domain services, so widget rebuilds never produce analytics events.
class InstrumentedAuthServices
implements
LoginService,
SignupService,
VerificationService,
SimulationInfo {
InstrumentedAuthServices({
required this.recorder,
required this.loginService,
required this.signupService,
required this.verificationService,
});
final JourneyRecorder recorder;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@override
bool get isSimulated =>
verificationService is SimulationInfo &&
(verificationService as SimulationInfo).isSimulated;
Future<T> _run<T>(
JourneyKind kind,
JourneyStep step,
Future<T> Function() action,
bool Function(T) simulated, {
bool terminal = false,
bool onboarding = false,
bool simulation = false,
}) async {
final operation = recorder.begin(
kind,
step,
onboarding: onboarding,
simulated: simulation,
);
try {
final result = await action();
recorder.finish(
operation,
simulated: simulated(result),
terminal: terminal,
);
return result;
} catch (error) {
final failure = switch (error) {
AuthException(code: final code) => switch (code) {
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected,
},
VerificationException(code: final code) => switch (code) {
VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal,
},
_ => JourneyFailure.unexpected,
};
recorder.finish(operation, failure: failure);
rethrow;
}
}
bool _simulation(Object service) =>
service is SimulationInfo && service.isSimulated;
@override
Future<AuthResult> login(LoginRequest request) => _run(
JourneyKind.login,
JourneyStep.login,
() => loginService.login(request),
(r) => r.isSimulated,
terminal: true,
simulation: _simulation(loginService),
);
@override
Future<AuthResult> signup(SignupRequest request) => _run(
JourneyKind.signup,
JourneyStep.signup,
() => signupService.signup(request),
(r) => r.isSimulated,
simulation: _simulation(signupService),
);
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) => _run(
JourneyKind.verification,
JourneyStep.emailDelivery,
() => verificationService.send(email: email, locale: locale),
(r) => r.isSimulated,
onboarding: true,
simulation: isSimulated,
);
@override
Future<VerificationResult> confirm({required String token}) => _run(
JourneyKind.verification,
JourneyStep.emailConfirmation,
() => token.trim().isEmpty
? Future.error(const VerificationException(VerificationError.invalid))
: verificationService.confirm(token: token),
(r) => r.isSimulated,
terminal: true,
onboarding: true,
simulation: isSimulated,
);
}
@@ -1,5 +1,9 @@
import '../models/verification.dart';
abstract interface class SimulationInfo {
bool get isSimulated;
}
abstract interface class VerificationService {
Future<VerificationResult> send({
required String email,
@@ -9,11 +13,13 @@ abstract interface class VerificationService {
}
/// Explicit preview tokens only. Never use this implementation in production.
class DummyVerificationService implements VerificationService {
class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({
this.delay = const Duration(milliseconds: 600),
this.sendFailure,
});
@override
bool get isSimulated => true;
final Duration delay;
final VerificationError? sendFailure;
@@ -10,3 +10,5 @@ export 'src/services/dummy.dart';
export 'src/models/verification.dart';
export 'src/providers/verification.dart';
export 'src/services/verification.dart';
export 'src/services/instrumented.dart';
@@ -6,6 +6,8 @@ environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
sxchnge_analytics:
path: ../sxchnge_analytics
flutter:
sdk: flutter
dev_dependencies: