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:
1 parent
bf7778f312
commit
f1cbf64282
28 files changed
+1158
-64
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,21 @@
|
||||
# SXCHNGE analytics
|
||||
|
||||
Shared pure-Dart analytics contract, semantic journey recorder and bounded
|
||||
in-memory dummy service. No PostHog SDK, network, persistence or logging.
|
||||
|
||||
```dart
|
||||
final analytics = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(analytics);
|
||||
final operation = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(operation, simulated: true, terminal: true);
|
||||
final captured = analytics.events; // immutable snapshot
|
||||
recorder.dispose();
|
||||
```
|
||||
|
||||
Inject an `AnalyticsService` implementation to replace the sink. Consumers do not
|
||||
await analytics. See [the auth journey contract](../../../docs/ux/auth-journeys.md)
|
||||
for schema, metric definitions, simulation rules, privacy, retry/abandonment
|
||||
semantics, PostHog adapter guidance and measurement limitations.
|
||||
|
||||
Run `flutter test` and `flutter analyze` here. The production library imports
|
||||
only Dart SDK libraries and can be reused by web and mobile.
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
enum JourneyKind { login, signup, verification }
|
||||
|
||||
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
|
||||
|
||||
enum JourneySignal {
|
||||
started,
|
||||
requested,
|
||||
succeeded,
|
||||
simulated,
|
||||
failed,
|
||||
abandoned,
|
||||
}
|
||||
|
||||
enum JourneyFailure {
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
expired,
|
||||
invalid,
|
||||
internal,
|
||||
}
|
||||
|
||||
enum JourneyExit { navigation, modeChange, inactivity, disposed }
|
||||
|
||||
String _snake(String value) =>
|
||||
value.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}');
|
||||
String _id() {
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
16,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
}
|
||||
|
||||
/// Only typed, allowlisted fields; never accepts form data, URLs or exceptions.
|
||||
class AnalyticsEvent {
|
||||
AnalyticsEvent({
|
||||
required this.journeyId,
|
||||
required this.kind,
|
||||
required this.signal,
|
||||
required this.sequence,
|
||||
required this.elapsedMs,
|
||||
this.step,
|
||||
this.operation,
|
||||
this.failure,
|
||||
this.exit,
|
||||
this.terminal = false,
|
||||
this.simulated = false,
|
||||
}) : eventId = _id(),
|
||||
occurredAt = DateTime.now().toUtc();
|
||||
final String eventId;
|
||||
final String journeyId;
|
||||
final JourneyKind kind;
|
||||
final JourneySignal signal;
|
||||
final JourneyStep? step;
|
||||
final JourneyFailure? failure;
|
||||
final JourneyExit? exit;
|
||||
final int sequence;
|
||||
final int? operation;
|
||||
final int elapsedMs;
|
||||
final bool terminal;
|
||||
final bool simulated;
|
||||
final DateTime occurredAt;
|
||||
String get name =>
|
||||
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
|
||||
Map<String, Object> get properties => Map.unmodifiable({
|
||||
'schema_version': 1,
|
||||
'event_id': eventId,
|
||||
'journey_id': journeyId,
|
||||
'journey_kind': kind.name,
|
||||
'sequence': sequence,
|
||||
'elapsed_ms': elapsedMs,
|
||||
'occurred_at': occurredAt.toIso8601String(),
|
||||
'terminal': terminal,
|
||||
'simulated': simulated,
|
||||
if (step != null) 'step': _snake(step!.name),
|
||||
if (operation != null) 'operation': operation!,
|
||||
if (failure != null) 'failure': _snake(failure!.name),
|
||||
if (exit != null) 'exit': _snake(exit!.name),
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class AnalyticsService {
|
||||
Future<void> capture(AnalyticsEvent event);
|
||||
}
|
||||
|
||||
/// Bounded, process-local inspection only. No disk, logs, network or SDK.
|
||||
class DummyAnalyticsService implements AnalyticsService {
|
||||
DummyAnalyticsService({this.capacity = 500}) : assert(capacity > 0);
|
||||
final int capacity;
|
||||
final Queue<AnalyticsEvent> _events = Queue();
|
||||
List<AnalyticsEvent> get events => List.unmodifiable(_events);
|
||||
void clear() => _events.clear();
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) async {
|
||||
if (_events.any((item) => item.eventId == event.eventId)) return;
|
||||
if (_events.length == capacity) _events.removeFirst();
|
||||
_events.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
class _Journey {
|
||||
_Journey(this.kind);
|
||||
final JourneyKind kind;
|
||||
final String id = _id();
|
||||
final Stopwatch time = Stopwatch()..start();
|
||||
int sequence = 0;
|
||||
int operations = 0;
|
||||
int pending = 0;
|
||||
bool closed = false;
|
||||
bool simulated = false;
|
||||
}
|
||||
|
||||
class JourneyOperation {
|
||||
JourneyOperation._(this._journey, this.step, this.number);
|
||||
final _Journey _journey;
|
||||
final JourneyStep step;
|
||||
final int number;
|
||||
bool _finished = false;
|
||||
}
|
||||
|
||||
/// One foreground auth journey. Retried operations share a journey, not outcomes.
|
||||
class JourneyRecorder {
|
||||
JourneyRecorder(
|
||||
this.service, {
|
||||
this.idleWindow = const Duration(minutes: 30),
|
||||
});
|
||||
final AnalyticsService service;
|
||||
final Duration idleWindow;
|
||||
_Journey? _active;
|
||||
Timer? _idle;
|
||||
bool _disposed = false;
|
||||
|
||||
JourneyOperation begin(
|
||||
JourneyKind kind,
|
||||
JourneyStep step, {
|
||||
bool onboarding = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
var journey = _active;
|
||||
if (journey == null ||
|
||||
journey.closed ||
|
||||
(journey.kind != kind &&
|
||||
!(onboarding && journey.kind == JourneyKind.signup))) {
|
||||
abandon(JourneyExit.navigation);
|
||||
journey = _Journey(kind)..simulated = simulated;
|
||||
_active = journey;
|
||||
_emit(journey, JourneySignal.started);
|
||||
}
|
||||
journey.simulated |= simulated;
|
||||
_idle?.cancel();
|
||||
journey.pending++;
|
||||
final operation = JourneyOperation._(journey, step, ++journey.operations);
|
||||
_emit(journey, JourneySignal.requested, operation: operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
void finish(
|
||||
JourneyOperation operation, {
|
||||
bool simulated = false,
|
||||
JourneyFailure? failure,
|
||||
bool terminal = false,
|
||||
}) {
|
||||
final journey = operation._journey;
|
||||
if (_disposed || operation._finished || journey.closed) return;
|
||||
operation._finished = true;
|
||||
journey.pending--;
|
||||
journey.simulated |= simulated;
|
||||
final complete = failure == null && terminal;
|
||||
_emit(
|
||||
journey,
|
||||
failure != null
|
||||
? JourneySignal.failed
|
||||
: (simulated || (complete && journey.simulated))
|
||||
? JourneySignal.simulated
|
||||
: JourneySignal.succeeded,
|
||||
operation: operation,
|
||||
failure: failure,
|
||||
terminal: complete,
|
||||
simulated: simulated || (complete && journey.simulated),
|
||||
);
|
||||
if (complete) {
|
||||
journey.closed = true;
|
||||
_idle?.cancel();
|
||||
} else if (journey.pending == 0) {
|
||||
_idle?.cancel();
|
||||
_idle = Timer(idleWindow, () => abandon(JourneyExit.inactivity));
|
||||
}
|
||||
}
|
||||
|
||||
void abandon(JourneyExit reason) {
|
||||
_idle?.cancel();
|
||||
final journey = _active;
|
||||
if (_disposed || journey == null || journey.closed) return;
|
||||
_emit(
|
||||
journey,
|
||||
JourneySignal.abandoned,
|
||||
terminal: true,
|
||||
exit: reason,
|
||||
simulated: journey.simulated,
|
||||
);
|
||||
journey.closed = true;
|
||||
}
|
||||
|
||||
void _emit(
|
||||
_Journey journey,
|
||||
JourneySignal signal, {
|
||||
JourneyOperation? operation,
|
||||
JourneyFailure? failure,
|
||||
JourneyExit? exit,
|
||||
bool terminal = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
final event = AnalyticsEvent(
|
||||
journeyId: journey.id,
|
||||
kind: journey.kind,
|
||||
signal: signal,
|
||||
sequence: ++journey.sequence,
|
||||
elapsedMs: journey.time.elapsedMilliseconds,
|
||||
step: operation?.step,
|
||||
operation: operation?.number,
|
||||
failure: failure,
|
||||
exit: exit,
|
||||
terminal: terminal,
|
||||
simulated: simulated || journey.simulated,
|
||||
);
|
||||
// Adapters may throw synchronously, fail asynchronously or never finish.
|
||||
// None may delay or change the authentication operation.
|
||||
unawaited(
|
||||
Future<void>.sync(() => service.capture(event)).catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
abandon(JourneyExit.disposed);
|
||||
_disposed = true;
|
||||
_idle?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name: sxchnge_analytics
|
||||
description: Privacy-minimal journey analytics contracts and in-memory dummy service.
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
class BrokenService implements AnalyticsService {
|
||||
BrokenService(this.mode);
|
||||
final int mode;
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) {
|
||||
if (mode == 0) throw StateError('offline');
|
||||
if (mode == 1) return Future.error(StateError('offline'));
|
||||
return Completer<void>().future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup);
|
||||
recorder.finish(signup, simulated: true);
|
||||
final send = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(send, simulated: true);
|
||||
final failed = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(failed, failure: JourneyFailure.expired);
|
||||
final retry = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(sink.events.last.signal, JourneySignal.simulated);
|
||||
expect(sink.events.last.operation, 4);
|
||||
expect(
|
||||
sink.events.map((e) => e.sequence),
|
||||
orderedEquals(List.generate(9, (i) => i + 1)),
|
||||
);
|
||||
expect(
|
||||
sink.events.any((e) => e.properties.toString().contains('email_address')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
test(
|
||||
'explicit exit records once and suppresses late operation completion',
|
||||
() {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.abandon(JourneyExit.modeChange);
|
||||
recorder.abandon(JourneyExit.navigation);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
final newOp = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(newOp, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'inactivity waits for pending operations and starts after settled failure',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(
|
||||
sink,
|
||||
idleWindow: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.any((e) => e.terminal), isFalse);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.last.exit, JourneyExit.inactivity);
|
||||
},
|
||||
);
|
||||
for (final mode in [0, 1, 2]) {
|
||||
test('analytics failure mode $mode never blocks outcomes', () async {
|
||||
final recorder = JourneyRecorder(BrokenService(mode));
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(op, terminal: true);
|
||||
recorder.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
}
|
||||
test(
|
||||
'dummy is bounded, immutable and deduplicates repeated event IDs',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService(capacity: 2);
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
final event = sink.events.last;
|
||||
await sink.capture(event);
|
||||
expect(sink.events.length, 2);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.length, 2);
|
||||
expect(() => sink.events.clear(), throwsUnsupportedError);
|
||||
expect(() => event.properties['password'] = 'no', throwsUnsupportedError);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user