fix(ui): pre-load ShopInBit payment info instead of in-page spinner overlay
What changed, and why it matters
This commit is a user-interface refactor for the ShopInBit checkout flow. It moves the loading of payment information earlier in the process so the payment screen appears already filled in, rather than showing a spinner after the page opens. There is no direct security fix here, but the change touches code that handles invoice creation, polling, and route arguments for a third-party payment integration.
Treat as a routine UX refactor. If reviewing for security, verify that the pre-load helper still handles API errors and empty paymentLinks safely, that the route argument tuple is constructed consistently across all navigation paths, and that the broad catch blocks do not mask authentication or payment-state errors that should be surfaced to the user.
Security signals we found
Refactor of payment/invoice lifecycle code
Route argument type changed from ShopInBitOrderModel to (ShopInBitOrderModel, PaymentInfo?)
Network calls (getPayment/putPayment) moved from inside payment view initState to shipping view PAY NOW handler
Error handling is broad (catch (_) with fallback to polling-only)
No new input validation, authentication, or cryptography changes visible
Evidence from the diff
The patch removes in-page LoadingIndicator overlays from several ShopInBit views and introduces a new helper, fetchShopInBitPaymentInfo(), which GETs an existing invoice or PUTs to regenerate one before the payment view is pushed. The payment view now accepts an optional initialPaymentInfo argument, route argument handling is updated to pass a (ShopInBitOrderModel, PaymentInfo?) tuple, and refresh/check operations use a modal showLoading helper. The actual API calls (getPayment/putPayment) and their error handling remain largely unchanged; the main difference is timing and UX.
Changed components
lib/pages/shopinbit/shopinbit_offer_view.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_shipping_view.dartlib/route_generator.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dartInspect captured patch +143 / −162
diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart
index 544a554..64e90d1 100644
--- a/lib/pages/shopinbit/shopinbit_offer_view.dart
+++ b/lib/pages/shopinbit/shopinbit_offer_view.dart
@@ -13,7 +13,6 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/desktop/secondary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
-import '../../widgets/loading_indicator.dart';
import '../../widgets/rounded_white_container.dart';
import 'shopinbit_shipping_view.dart';
@@ -195,13 +194,7 @@ class _ShopInBitOfferViewState extends ConsumerState<ShopInBitOfferView> {
bottom: 32,
top: 16,
),
- child: Stack(
- children: [
- content,
- if (_loading)
- const LoadingIndicator(width: 24, height: 24),
- ],
- ),
+ child: content,
),
),
],
@@ -222,21 +215,16 @@ class _ShopInBitOfferViewState extends ConsumerState<ShopInBitOfferView> {
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
- return Stack(
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: SingleChildScrollView(
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minHeight: constraints.maxHeight - 32,
- ),
- child: IntrinsicHeight(child: content),
- ),
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
),
+ child: IntrinsicHeight(child: content),
),
- if (_loading) const LoadingIndicator(width: 24, height: 24),
- ],
+ ),
);
},
),
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index c70f253..bd6aade 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -5,8 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_config.dart';
import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../providers/global/shopin_bit_service_provider.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
+import '../../services/shopinbit/src/models/payment.dart';
import '../../services/wallets.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
@@ -17,7 +19,6 @@ import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
import '../../widgets/background.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
-import '../../widgets/loading_indicator.dart';
import 'shopinbit_send_from_view.dart';
final String kShopInBitUsdtContractAddress = DefaultTokens.list
@@ -223,20 +224,45 @@ Future<bool> tryNavigateToShopInBitWalletSend({
return false;
}
+// Fetches the live payment info for a ticket so the caller can pass it into
+// the payment view as an arg (rather than loading it after the view is up).
+// GET first to reuse an existing invoice per the spec's "page reload
+// recovery" guidance; PUT (which regenerates) only when GET shows none.
+// Returns null on any failure so the view can fall back to polling.
+Future<PaymentInfo?> fetchShopInBitPaymentInfo(
+ WidgetRef ref,
+ int apiTicketId,
+) async {
+ try {
+ final client = ref.read(pShopinBitService).client;
+ final getResp = await client.getPayment(apiTicketId);
+ if (!getResp.hasError &&
+ getResp.value != null &&
+ getResp.value!.paymentLinks.isNotEmpty) {
+ return getResp.value;
+ }
+ final putResp = await client.putPayment(apiTicketId);
+ if (!putResp.hasError && putResp.value != null) {
+ return putResp.value;
+ }
+ } catch (_) {
+ // Degrade to polling-only.
+ }
+ return null;
+}
+
// Shared mobile chrome for the two ShopInBit payment views: Background +
// PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic
-// height body. Set [showLoading] to overlay a spinner.
+// height body.
class ShopInBitPaymentMobileScaffold extends StatelessWidget {
const ShopInBitPaymentMobileScaffold({
super.key,
required this.onBack,
required this.child,
- this.showLoading = false,
});
final VoidCallback onBack;
final Widget child;
- final bool showLoading;
@override
Widget build(BuildContext context) {
@@ -259,22 +285,16 @@ class ShopInBitPaymentMobileScaffold extends StatelessWidget {
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
- return Stack(
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: SingleChildScrollView(
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minHeight: constraints.maxHeight - 32,
- ),
- child: IntrinsicHeight(child: child),
- ),
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
),
+ child: IntrinsicHeight(child: child),
),
- if (showLoading)
- const LoadingIndicator(width: 24, height: 24),
- ],
+ ),
);
},
),
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index e876a71..589e6f2 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -16,6 +16,7 @@ import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/assets.dart';
+import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/desktop/desktop_dialog.dart';
@@ -23,24 +24,30 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/desktop/secondary_button.dart';
import '../../widgets/icon_widgets/copy_icon.dart';
-import '../../widgets/loading_indicator.dart';
import '../../widgets/rounded_white_container.dart';
import 'shopinbit_payment_shared.dart';
class ShopInBitPaymentView extends ConsumerStatefulWidget {
- const ShopInBitPaymentView({super.key, required this.model});
+ const ShopInBitPaymentView({
+ super.key,
+ required this.model,
+ this.initialPaymentInfo,
+ });
static const String routeName = "/shopInBitPayment";
final ShopInBitOrderModel model;
+ // Pre-loaded by the caller (see fetchShopInBitPaymentInfo) so the view can
+ // render populated immediately instead of fetching after it's pushed.
+ final PaymentInfo? initialPaymentInfo;
+
@override
ConsumerState<ShopInBitPaymentView> createState() =>
_ShopInBitPaymentViewState();
}
class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
- bool _loading = false;
int _selectedMethod = 0;
Timer? _pollTimer;
@@ -72,8 +79,13 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
@override
void initState() {
super.initState();
+ if (widget.initialPaymentInfo != null) {
+ _applyPaymentInfo(widget.initialPaymentInfo!);
+ }
+ // Poll even when the pre-load returned null so the view can still recover
+ // a live invoice on its own.
if (widget.model.apiTicketId != 0) {
- _loadPayment();
+ _startPolling();
}
}
@@ -115,132 +127,80 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
} catch (_) {}
}
- // The shipping view's PAY NOW button is the only path into this view today,
- // but we still GET first per the 1.0.4 spec's "page reload recovery"
- // guidance: if a live invoice already exists for this ticket, reuse it. PUT
- // (which regenerates) only when GET shows there isn't one. An empty
- // paymentLinks map covers all "no live invoice" cases the server returns
- // (fresh ticket, expired, invalid) and a non-empty map covers everything
- // worth preserving (live, paid, paid_late, processing).
- Future<void> _loadPayment() async {
- setState(() => _loading = true);
- try {
- final client = ref.read(pShopinBitService).client;
- final getResp = await client.getPayment(widget.model.apiTicketId);
- PaymentInfo? info;
- if (!getResp.hasError &&
- getResp.value != null &&
- getResp.value!.paymentLinks.isNotEmpty) {
- info = getResp.value!;
- } else {
- final putResp = await client.putPayment(widget.model.apiTicketId);
- if (!putResp.hasError && putResp.value != null) {
- info = putResp.value!;
- }
- }
- if (info != null) {
- _applyPaymentInfo(info);
- }
- } catch (_) {
- // Fall back to local/dummy data
- } finally {
- if (mounted) {
- setState(() => _loading = false);
- _startPolling();
- }
- }
- }
-
Future<void> _refreshInvoice() async {
- setState(() => _loading = true);
- try {
- final resp = await ref
+ _pollTimer?.cancel();
+ final resp = await showLoading(
+ whileFuture: ref
.read(pShopinBitService)
.client
- .putPayment(widget.model.apiTicketId);
- if (!resp.hasError && resp.value != null) {
- _applyPaymentInfo(resp.value!);
- }
- } catch (_) {}
- if (mounted) {
- setState(() => _loading = false);
- _startPolling();
+ .putPayment(widget.model.apiTicketId),
+ context: context,
+ message: "Refreshing invoice",
+ );
+ if (!mounted) return;
+ if (resp != null && !resp.hasError && resp.value != null) {
+ setState(() => _applyPaymentInfo(resp.value!));
}
+ _startPolling();
}
Future<void> _checkForPayment() async {
_pollTimer?.cancel();
- setState(() => _loading = true);
- try {
- final resp = await ref
+ final resp = await showLoading(
+ whileFuture: ref
.read(pShopinBitService)
.client
- .getPayment(widget.model.apiTicketId);
- if (!resp.hasError && resp.value != null && mounted) {
- setState(() => _applyPaymentInfo(resp.value!));
- final status = resp.value!.status;
- if (const {
- 'paid',
- 'paid_over',
- 'paid_late',
- 'payment_processing',
- }.contains(status)) {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.success,
- message: "Payment received!",
- context: context,
- ),
- );
- }
- } else if (status == 'underpaid') {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.warning,
- message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.",
- context: context,
- ),
- );
- }
- } else {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "No payment detected yet.",
- context: context,
- ),
- );
- }
- }
- } else if (mounted) {
+ .getPayment(widget.model.apiTicketId),
+ context: context,
+ message: "Checking for payment",
+ );
+ if (!mounted) return;
+
+ if (resp != null && !resp.hasError && resp.value != null) {
+ setState(() => _applyPaymentInfo(resp.value!));
+ final status = resp.value!.status;
+ if (const {
+ 'paid',
+ 'paid_over',
+ 'paid_late',
+ 'payment_processing',
+ }.contains(status)) {
unawaited(
showFloatingFlushBar(
- type: FlushBarType.warning,
- message: resp.exception?.message ?? "Failed to check payment.",
+ type: FlushBarType.success,
+ message: "Payment received!",
context: context,
),
);
- }
- } catch (e) {
- if (mounted) {
+ } else if (status == 'underpaid') {
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
- message: e.toString(),
+ message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.",
+ context: context,
+ ),
+ );
+ } else {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "No payment detected yet.",
context: context,
),
);
}
- } finally {
- if (mounted) {
- setState(() => _loading = false);
- if (!_isTerminal) {
- _startPolling();
- }
- }
+ } else {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: resp?.exception?.message ?? "Failed to check payment.",
+ context: context,
+ ),
+ );
+ }
+
+ if (!_isTerminal) {
+ _startPolling();
}
}
@@ -634,12 +594,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
horizontal: 32,
vertical: 8,
),
- child: Stack(
- children: [
- SingleChildScrollView(child: content),
- if (_loading) const LoadingIndicator(width: 24, height: 24),
- ],
- ),
+ child: SingleChildScrollView(child: content),
),
),
],
@@ -649,7 +604,6 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
return ShopInBitPaymentMobileScaffold(
onBack: _popToTickets,
- showLoading: _loading,
child: content,
);
}
diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart
index 597656f..4dc567d 100644
--- a/lib/pages/shopinbit/shopinbit_shipping_view.dart
+++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart
@@ -8,6 +8,7 @@ import 'package:flutter_svg/svg.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
import '../../services/shopinbit/src/models/address.dart';
+import '../../services/shopinbit/src/models/payment.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
import '../../utilities/constants.dart';
@@ -19,6 +20,7 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
import '../../widgets/textfields/adaptive_text_field.dart';
+import 'shopinbit_payment_shared.dart';
import 'shopinbit_payment_view.dart';
class ShopInBitShippingView extends ConsumerStatefulWidget {
@@ -186,6 +188,10 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
country: country,
);
+ // Pre-load the payment info before pushing the payment view so it renders
+ // populated immediately. The Continue button's spinner (_submitting)
+ // already covers this wait.
+ PaymentInfo? paymentInfo;
if (widget.model.apiTicketId != 0) {
setState(() => _submitting = true);
try {
@@ -232,6 +238,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
// Sandbox may fail here; continue anyway.
debugPrint("submitAddress failed: ${resp.exception?.message}");
}
+
+ paymentInfo = await fetchShopInBitPaymentInfo(
+ ref,
+ widget.model.apiTicketId,
+ );
} catch (e) {
debugPrint("submitAddress threw: $e");
} finally {
@@ -242,9 +253,10 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
if (!mounted) return;
unawaited(
- Navigator.of(
- context,
- ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model),
+ Navigator.of(context).pushNamed(
+ ShopInBitPaymentView.routeName,
+ arguments: (widget.model, paymentInfo),
+ ),
);
}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index 2a42a8a..df03fd5 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -262,6 +262,7 @@ import 'services/cakepay/src/models/order.dart';
import 'services/event_bus/events/global/node_connection_status_changed_event.dart';
import 'services/event_bus/events/global/wallet_sync_status_changed_event.dart';
import 'services/shopinbit/src/models/car_research.dart';
+import 'services/shopinbit/src/models/payment.dart';
import 'utilities/amount/amount.dart';
import 'utilities/enums/add_wallet_type_enum.dart';
import 'wallets/crypto_currency/crypto_currency.dart';
@@ -1258,10 +1259,13 @@ class RouteGenerator {
return _routeError("${settings.name} invalid args: ${args.toString()}");
case ShopInBitPaymentView.routeName:
- if (args is ShopInBitOrderModel) {
+ if (args is (ShopInBitOrderModel, PaymentInfo?)) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
- builder: (_) => ShopInBitPaymentView(model: args),
+ builder: (_) => ShopInBitPaymentView(
+ model: args.$1,
+ initialPaymentInfo: args.$2,
+ ),
settings: RouteSettings(name: settings.name),
);
}
diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
index f625a63..e5561b4 100644
--- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
+++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
@@ -196,16 +196,19 @@ abstract final class NestedNavigatorDialogRouteGenerator {
);
case ShopInBitPaymentView.routeName:
- if (args is ShopInBitOrderModel) {
+ if (args is (ShopInBitOrderModel, PaymentInfo?)) {
return getRoute(
- builder: (_) => ShopInBitPaymentView(model: args),
+ builder: (_) => ShopInBitPaymentView(
+ model: args.$1,
+ initialPaymentInfo: args.$2,
+ ),
settings: RouteSettings(name: settings.name),
);
}
return _routeError(
"${settings.name} invalid args\n"
"Got ${args.runtimeType}\n"
- "Expected ShopInBitOrderModel",
+ "Expected (ShopInBitOrderModel, PaymentInfo?)",
);
case CakePayVendorsView.routeName:
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.