Add saved payout destination UX and service contracts (#54)
This commit is contained in:
1 parent
a5689876ff
commit
24af794112
49 files changed
+4687
-62
No files matched your search
@@ -0,0 +1,276 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'key.dart';
|
||||
import 'models.dart';
|
||||
import 'service.dart';
|
||||
|
||||
class DestinationFormProvider extends ChangeNotifier {
|
||||
DestinationFormProvider(
|
||||
this.service,
|
||||
this.analytics, {
|
||||
this.context,
|
||||
this.initial,
|
||||
this.replacing,
|
||||
this.restoring,
|
||||
}) : recorder = JourneyRecorder(analytics),
|
||||
details = initial {
|
||||
if (context == null) _startJourney();
|
||||
}
|
||||
final DestinationService service;
|
||||
final AnalyticsService analytics;
|
||||
final JourneyRecorder recorder;
|
||||
final PayoutContext? context;
|
||||
final DestinationDetails? initial;
|
||||
final String? replacing;
|
||||
final SavedReference? restoring;
|
||||
DestinationDetails? details;
|
||||
String nickname = '';
|
||||
bool save = false, busy = false, _disposed = false, uncertain = false;
|
||||
DestinationIssue? error;
|
||||
String? errorField;
|
||||
CreationResult? saved;
|
||||
PayoutEligibility? eligibility;
|
||||
DestinationInput? result;
|
||||
String? _key;
|
||||
bool _creationStarted = false;
|
||||
bool _journeyStarted = false;
|
||||
void _startJourney() {
|
||||
if (_journeyStarted) return;
|
||||
_journeyStarted = true;
|
||||
final op = recorder.begin(
|
||||
JourneyKind.destination,
|
||||
JourneyStep.destinationDraft,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
recorder.finish(op, simulated: service.isSimulated);
|
||||
}
|
||||
|
||||
bool savingFailed = false;
|
||||
bool get hasDraft => details != null || nickname.isNotEmpty;
|
||||
void change(DestinationDetails? value) {
|
||||
if (busy || uncertain) return;
|
||||
details = value;
|
||||
error = null;
|
||||
errorField = null;
|
||||
eligibility = null;
|
||||
result = null;
|
||||
saved = null;
|
||||
_key = null;
|
||||
_creationStarted = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void preferences({bool? save, String? nickname}) {
|
||||
if (busy || uncertain) return;
|
||||
if (save == true) _startJourney();
|
||||
if (save == false && this.save) {
|
||||
recorder.abandon(JourneyExit.modeChange);
|
||||
_journeyStarted = false;
|
||||
}
|
||||
this.save = save ?? this.save;
|
||||
this.nickname = nickname ?? this.nickname;
|
||||
_key = null;
|
||||
saved = null;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> proceed({bool withoutSaving = false}) async {
|
||||
if (busy || details == null) return;
|
||||
if (withoutSaving && !uncertain) recorder.abandon(JourneyExit.modeChange);
|
||||
if (uncertain && !withoutSaving) {
|
||||
await reconcile();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = null;
|
||||
errorField = null;
|
||||
savingFailed = false;
|
||||
if (!uncertain) _creationStarted = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
// Validation precedes both persistence and payout assessment.
|
||||
final normalized = (await service.validate(details!)).details;
|
||||
if (_disposed) return;
|
||||
if (restoring != null) {
|
||||
_key ??= destinationKey();
|
||||
final bindingOperation = recorder.begin(
|
||||
JourneyKind.destination,
|
||||
JourneyStep.destinationBinding,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
final restored = await service.restoreCard(
|
||||
restoring!.id,
|
||||
restoring!.executionRevision,
|
||||
normalized as CardDestination,
|
||||
_key!,
|
||||
);
|
||||
recorder.finish(
|
||||
bindingOperation,
|
||||
terminal: true,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
if (!_disposed) result = restored.input;
|
||||
} else if (context != null) {
|
||||
eligibility = await service.assess(
|
||||
OneTimeDestination(normalized),
|
||||
context!,
|
||||
);
|
||||
if (_disposed) return;
|
||||
}
|
||||
if (restoring == null &&
|
||||
(context == null || save) &&
|
||||
!withoutSaving &&
|
||||
saved == null) {
|
||||
if (eligibility?.canSave == false)
|
||||
throw const DestinationException(DestinationIssue.unsupported);
|
||||
_key ??= destinationKey();
|
||||
final op = recorder.begin(
|
||||
JourneyKind.destination,
|
||||
JourneyStep.destinationSave,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
_creationStarted = true;
|
||||
try {
|
||||
saved = await service.create(
|
||||
normalized,
|
||||
nickname,
|
||||
_key!,
|
||||
replaces: replacing,
|
||||
);
|
||||
_creationStarted = false;
|
||||
if (_disposed) return;
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
terminal: true,
|
||||
resolution: DestinationResolution.values.byName(
|
||||
saved!.outcome.name,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
savingFailed = true;
|
||||
recorder.finish(
|
||||
op,
|
||||
failure: JourneyFailure.unavailable,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
if (restoring != null) {
|
||||
// Restored bindings never create or rename a saved destination.
|
||||
} else if (context == null) {
|
||||
if (saved?.outcome == SaveOutcome.created)
|
||||
result = SavedReference(
|
||||
saved!.destinationId,
|
||||
(await service.get(saved!.destinationId)).executionRevision,
|
||||
);
|
||||
} else if (eligibility!.eligible) {
|
||||
if (withoutSaving || saved == null) {
|
||||
result = OneTimeDestination(normalized);
|
||||
} else if (saved!.outcome == SaveOutcome.created) {
|
||||
final d = await service.get(saved!.destinationId);
|
||||
result = d.input;
|
||||
}
|
||||
// Duplicates need explicit Use existing method, never another create.
|
||||
}
|
||||
} on DestinationException catch (e) {
|
||||
error = e.issue;
|
||||
errorField = e.field;
|
||||
uncertain =
|
||||
_creationStarted &&
|
||||
[
|
||||
DestinationIssue.uncertain,
|
||||
DestinationIssue.unavailable,
|
||||
].contains(e.issue);
|
||||
} catch (_) {
|
||||
error = DestinationIssue.unavailable;
|
||||
uncertain = _creationStarted;
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> useExisting() async {
|
||||
if (busy || saved == null) return;
|
||||
error = null;
|
||||
busy = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final d = await service.get(saved!.destinationId);
|
||||
if (d.removed) throw const DestinationException(DestinationIssue.removed);
|
||||
if (context != null)
|
||||
eligibility = await service.assess(d.input, context!);
|
||||
if (context == null || eligibility!.eligible) result = d.input;
|
||||
} on DestinationException catch (e) {
|
||||
error = e.issue;
|
||||
} catch (_) {
|
||||
error = DestinationIssue.unavailable;
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> reconcile() async {
|
||||
if (busy || _key == null) return;
|
||||
busy = true;
|
||||
notifyListeners();
|
||||
final op = recorder.begin(
|
||||
JourneyKind.destination,
|
||||
JourneyStep.destinationReconciliation,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final outcome = await service.creation(_key!);
|
||||
if (_disposed) return;
|
||||
saved = outcome;
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
terminal: outcome != null,
|
||||
resolution: outcome == null
|
||||
? null
|
||||
: DestinationResolution.values.byName(outcome.outcome.name),
|
||||
);
|
||||
uncertain = false;
|
||||
_creationStarted = false;
|
||||
error = null;
|
||||
// Absence is an authoritative lookup result, not a timeout inference.
|
||||
if (outcome?.outcome == SaveOutcome.created && context == null) {
|
||||
final d = await service.get(outcome!.destinationId);
|
||||
if (d.removed)
|
||||
throw const DestinationException(DestinationIssue.removed);
|
||||
result = d.input;
|
||||
}
|
||||
} on DestinationException catch (e) {
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
failure: JourneyFailure.unavailable,
|
||||
);
|
||||
error = e.issue;
|
||||
} catch (_) {
|
||||
recorder.finish(
|
||||
op,
|
||||
simulated: service.isSimulated,
|
||||
failure: JourneyFailure.unavailable,
|
||||
);
|
||||
error = DestinationIssue.unavailable;
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
recorder.dispose(abandonActive: !uncertain);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user