pre loading example combined with required args in widget/view
What changed, and why it matters
This commit refactors how a shopping feature in Stack Wallet loads country lists. Instead of fetching countries inside the shipping screen, it now fetches them earlier when the user accepts an offer, shows a loading spinner, validates the delivery country, and passes the list forward as a required argument. The change makes the shipping screen simpler and removes a fallback path where users could pick a different delivery country for restored orders. There is no obvious security bug in the diff, but the change removes some flexibility and error tolerance.
Treat as a routine UI/UX refactor. Review whether removing the editable country path for restored orders could break legitimate user workflows or create support issues. Verify that getCountries() failures are handled gracefully and that the new required arguments cannot be bypassed via deep links or route injection. No immediate security patch is indicated by this diff alone.
Security signals we found
Refactor of external API call timing and argument passing
Removal of user-editable country selection for restored orders
Addition of required constructor arguments and route argument validation
No new sanitization, authentication, or authorization logic visible
Evidence from the diff
The patch moves country-list fetching from ShopInBitShippingView.initState/_fetchCountries into ShopInBitOfferView’s accept-offer handler. It uses showLoading() while awaiting shopinBitApi.getCountries(), checks that the response contains exactly one entry matching model.deliveryCountry, shows an error dialog otherwise, and only then pushes ShopInBitShippingView with a record argument ({model, countries}). The shipping view now requires both model and countries as constructor arguments, removes the _countryLocked/_loadingCountries state, removes the editable country dropdown for restored orders, and always displays the delivery country as a read-only DetailItem. Route generators are updated to expect the new record-shaped argument. No input validation, cryptographic, or network-trust changes are visible.
Changed components
lib/pages/shopinbit/shopinbit_offer_view.dartlib/pages/shopinbit/shopinbit_shipping_view.dartlib/route_generator.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dartInspect captured patch +104 / −210
diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart
index 64e90d1..b159493 100644
--- a/lib/pages/shopinbit/shopinbit_offer_view.dart
+++ b/lib/pages/shopinbit/shopinbit_offer_view.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
import '../../themes/stack_colors.dart';
+import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/background.dart';
@@ -14,6 +15,7 @@ import '../../widgets/desktop/primary_button.dart';
import '../../widgets/desktop/secondary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
import '../../widgets/rounded_white_container.dart';
+import '../../widgets/stack_dialog.dart';
import 'shopinbit_shipping_view.dart';
class ShopInBitOfferView extends ConsumerStatefulWidget {
@@ -145,13 +147,59 @@ class _ShopInBitOfferViewState extends ConsumerState<ShopInBitOfferView> {
label: "Accept offer",
buttonHeight: Util.isDesktop ? ButtonHeight.l : null,
enabled: !_loading,
- onPressed: () {
+ onPressed: () async {
// TODO verify this is ok to stay set to accepted if the next route pops back and then decline is tapped
model.status = ShopInBitOrderStatus.accepted;
- Navigator.of(
- context,
- ).pushNamed(ShopInBitShippingView.routeName, arguments: model);
+ final shopinBitApi = ref.read(pShopinBitService).client;
+ final response = await showLoading(
+ context: context,
+ rootNavigator: true,
+ message: "Updating available countries",
+ whileFuture: shopinBitApi.getCountries(),
+ delay: const Duration(
+ seconds: 1,
+ ), // at least 1 sec to prevent ui flashing
+ );
+
+ if (!context.mounted) return;
+
+ String? errorMessage;
+
+ if (response?.value == null) {
+ errorMessage =
+ response?.exception?.toString() ??
+ "Failed to fetch countries data";
+ } else if (response!.value!
+ .where((c) => c['iso'] == model.deliveryCountry)
+ .length !=
+ 1) {
+ errorMessage =
+ "Delivery country code \""
+ "${model.deliveryCountry}"
+ "\" is invalid";
+ }
+
+ if (errorMessage != null) {
+ await showDialog<dynamic>(
+ context: context,
+ useRootNavigator: Util.isDesktop,
+ builder: (context) => StackOkDialog(
+ title: "ShopinBit API error",
+ maxWidth: Util.isDesktop ? 500 : null,
+ message: errorMessage,
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
+ return;
+ }
+
+ if (context.mounted) {
+ await Navigator.of(context).pushNamed(
+ ShopInBitShippingView.routeName,
+ arguments: (model: model, countries: response!.value!),
+ );
+ }
},
),
SecondaryButton(
diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart
index 88be18d..adb0892 100644
--- a/lib/pages/shopinbit/shopinbit_shipping_view.dart
+++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart
@@ -16,9 +16,9 @@ import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/background.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
-import '../../widgets/detail_item.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/detail_item.dart';
import '../../widgets/dialogs/s_dialog.dart';
import '../../widgets/stack_dialog.dart';
import '../../widgets/textfields/adaptive_text_field.dart';
@@ -26,11 +26,16 @@ import 'shopinbit_payment_shared.dart';
import 'shopinbit_payment_view.dart';
class ShopInBitShippingView extends ConsumerStatefulWidget {
- const ShopInBitShippingView({super.key, required this.model});
+ const ShopInBitShippingView({
+ super.key,
+ required this.model,
+ required this.countries,
+ });
static const String routeName = "/shopInBitShipping";
final ShopInBitOrderModel model;
+ final List<Map<String, dynamic>> countries;
@override
ConsumerState<ShopInBitShippingView> createState() =>
@@ -64,13 +69,8 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
String? _billingSelectedCountryIso;
bool _differentBilling = false;
- List<Map<String, dynamic>> _countries = [];
- String? _selectedCountryIso;
- bool _loadingCountries = false;
- // True when we arrived with a pre-set delivery country (the normal new-order
- // path). Restored-from-API orders land here with no country, so we unlock
- // the dropdown only in that case.
- late final bool _countryLocked;
+ late final String _selectedCountryIso;
+ late final String _deliveryCountryLabel;
bool _submitting = false;
@@ -80,8 +80,7 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
_nameController.text.trim().isNotEmpty &&
_streetController.text.trim().isNotEmpty &&
_cityController.text.trim().isNotEmpty &&
- _postalCodeController.text.trim().isNotEmpty &&
- _selectedCountryIso != null;
+ _postalCodeController.text.trim().isNotEmpty;
if (!shippingValid) return false;
if (_differentBilling) {
return _billingNameController.text.trim().isNotEmpty &&
@@ -114,10 +113,16 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
_billingCityFocusNode = FocusNode();
_billingPostalCodeFocusNode = FocusNode();
- _selectedCountryIso = widget.model.deliveryCountry.isNotEmpty
- ? widget.model.deliveryCountry
- : null;
- _countryLocked = _selectedCountryIso != null;
+ _selectedCountryIso = widget.model.deliveryCountry;
+
+ // firstWhere should never fail here as the caller of this widget must
+ // check that countries contains the expected value. Failure here should be
+ // considered unrecoverable/fatal as it indicates a bug elsewhere
+ _deliveryCountryLabel =
+ widget.countries.firstWhere(
+ (e) => e["iso"] == _selectedCountryIso,
+ )["label"]
+ as String;
for (final node in [
_nameFocusNode,
@@ -131,8 +136,6 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
]) {
node.addListener(() => setState(() {}));
}
-
- _fetchCountries();
}
@override
@@ -158,29 +161,12 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
super.dispose();
}
- Future<void> _fetchCountries() async {
- setState(() => _loadingCountries = true);
- try {
- final resp = await ref.read(pShopinBitService).client.getCountries();
- if (resp.hasError || resp.value == null) return;
- _countries = resp.value!;
- if (_selectedCountryIso != null &&
- !_countries.any((c) => c['iso'] == _selectedCountryIso)) {
- _selectedCountryIso = null;
- }
- } catch (_) {
- // leave list empty; user will see no items
- } finally {
- if (mounted) setState(() => _loadingCountries = false);
- }
- }
-
Future<void> _continue() async {
final name = _nameController.text.trim();
final street = _streetController.text.trim();
final city = _cityController.text.trim();
final postalCode = _postalCodeController.text.trim();
- final country = _selectedCountryIso!;
+ final country = _selectedCountryIso;
widget.model.setShippingAddress(
name: name,
@@ -189,11 +175,6 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
postalCode: postalCode,
country: country,
);
- // Keep deliveryCountry authoritative and in sync with the shipping
- // country. No-op when it was already set (the normal flow); fills the gap
- // for restored orders, where deliveryCountry came back empty from the API
- // and the user picked one here.
- widget.model.deliveryCountry = country;
// The payment view needs a live invoice, so load it here and only navigate
// once we have usable payment links.
@@ -294,139 +275,6 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
);
}
- // Read-only display of the locked delivery country: it was fixed when the
- // offer was priced and can't change here.
- Widget _buildLockedCountryField() {
- final label =
- _countries
- .where((c) => c['iso'] == _selectedCountryIso)
- .map((c) => c['label'] as String)
- .firstOrNull ??
- (_selectedCountryIso ?? "");
-
- return DetailItem(
- title: "Country",
- detail: label,
- disableSelectableText: true,
- );
- }
-
- // Editable, searchable country dropdown. Only shown when the delivery country
- // wasn't pre-set (restored-from-API orders).
- Widget _buildCountryDropdown(
- BuildContext context, {
- required bool isDesktop,
- }) {
- return ClipRRect(
- borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
- child: DropdownButtonHideUnderline(
- child: DropdownButton2<String>(
- value: _selectedCountryIso,
- items: _countries
- .map(
- (c) => DropdownMenuItem<String>(
- value: c['iso'] as String,
- child: Text(
- c['label'] as String,
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- )
- : STextStyles.w500_14(context),
- ),
- ),
- )
- .toList(),
- onMenuStateChange: (isOpen) {
- if (!isOpen) {
- _countrySearchController.clear();
- }
- },
- onChanged: _loadingCountries
- ? null
- : (value) => setState(() => _selectedCountryIso = value),
- hint: Text(
- _loadingCountries ? "Loading countries..." : "Country",
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultSearchIconLeft,
- )
- : STextStyles.fieldLabel(context),
- ),
- isExpanded: true,
- buttonStyleData: ButtonStyleData(
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- iconStyleData: IconStyleData(
- icon: Padding(
- padding: const EdgeInsets.only(right: 10),
- child: SvgPicture.asset(
- Assets.svg.chevronDown,
- width: 12,
- height: 6,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
- ),
- ),
- ),
- dropdownStyleData: DropdownStyleData(
- offset: const Offset(0, 0),
- elevation: 0,
- maxHeight: 300,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- dropdownSearchData: DropdownSearchData<String>(
- searchController: _countrySearchController,
- searchInnerWidgetHeight: 48,
- searchInnerWidget: TextFormField(
- controller: _countrySearchController,
- decoration: InputDecoration(
- isDense: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 14,
- ),
- hintText: "Search...",
- hintStyle: STextStyles.fieldLabel(context),
- border: InputBorder.none,
- ),
- ),
- searchMatchFn: (item, searchValue) {
- final label = _countries
- .where((c) => c['iso'] == item.value)
- .map((c) => c['label'] as String)
- .firstOrNull;
- return label?.toLowerCase().contains(searchValue.toLowerCase()) ??
- false;
- },
- ),
- menuItemStyleData: const MenuItemStyleData(
- padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- ),
- ),
- ),
- );
- }
-
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
@@ -494,25 +342,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
],
),
spacing,
- // The delivery country was chosen when the offer was requested and the
- // price (incl. shipping + VAT) was calculated from it, so it can't be
- // changed here. Restored-from-API orders are the exception: they come
- // back with no country, so we let the user supply one (and warn that it
- // may not match what the offer was priced for).
- if (_countryLocked)
- _buildLockedCountryField()
- else ...[
- _buildCountryDropdown(context, isDesktop: isDesktop),
- SizedBox(height: isDesktop ? 8 : 6),
- Text(
- "This order was started on another device. Choosing a country "
- "here may not match the delivery destination the offer was "
- "priced for.",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- ],
+ DetailItem(
+ title: "Country",
+ detail: _deliveryCountryLabel,
+ disableSelectableText: true,
+ ),
spacing,
// Billing address toggle.
GestureDetector(
@@ -627,7 +461,7 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
child: DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value: _billingSelectedCountryIso,
- items: _countries
+ items: widget.countries
.map(
(c) => DropdownMenuItem<String>(
value: c['iso'] as String,
@@ -651,15 +485,13 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
_billingCountrySearchController.clear();
}
},
- onChanged: _loadingCountries
- ? null
- : (value) {
- setState(() {
- _billingSelectedCountryIso = value;
- });
- },
+ onChanged: (value) {
+ setState(() {
+ _billingSelectedCountryIso = value;
+ });
+ },
hint: Text(
- _loadingCountries ? "Loading countries..." : "Country",
+ "Country",
style: isDesktop
? STextStyles.desktopTextExtraSmall(context).copyWith(
color: Theme.of(context)
@@ -722,7 +554,7 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
),
),
searchMatchFn: (item, searchValue) {
- final label = _countries
+ final label = widget.countries
.where((c) => c['iso'] == item.value)
.map((c) => c['label'] as String)
.firstOrNull;
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index 9c98d6f..42a6c0e 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -1226,10 +1226,17 @@ class RouteGenerator {
return _routeError("${settings.name} invalid args: ${args.toString()}");
case ShopInBitShippingView.routeName:
- if (args is ShopInBitOrderModel) {
+ if (args
+ is ({
+ ShopInBitOrderModel model,
+ List<Map<String, dynamic>> countries,
+ })) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
- builder: (_) => ShopInBitShippingView(model: args),
+ builder: (_) => ShopInBitShippingView(
+ model: args.model,
+ countries: args.countries,
+ ),
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 f97e6f2..045a289 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
@@ -183,9 +183,16 @@ abstract final class NestedNavigatorDialogRouteGenerator {
);
case ShopInBitShippingView.routeName:
- if (args is ShopInBitOrderModel) {
+ if (args
+ is ({
+ ShopInBitOrderModel model,
+ List<Map<String, dynamic>> countries,
+ })) {
return getRoute(
- builder: (_) => ShopInBitShippingView(model: args),
+ builder: (_) => ShopInBitShippingView(
+ model: args.model,
+ countries: args.countries,
+ ),
settings: RouteSettings(name: settings.name),
);
}
Why this scored 16/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.