@@ -187,6 +187,11 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
187187 postalCode: postalCode,
188188 country: country,
189189 );
190+ // Keep deliveryCountry authoritative and in sync with the shipping
191+ // country. No-op when it was already set (the normal flow); fills the gap
192+ // for restored orders, where deliveryCountry came back empty from the API
193+ // and the user picked one here.
194+ widget.model.deliveryCountry = country;
190195
191196 // Pre-load the payment info before pushing the payment view so it renders
192197 // populated immediately. The Continue button's spinner (_submitting)
@@ -260,6 +265,171 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
260265 );
261266 }
262267
268+ // Read-only display of the locked delivery country. Looks like the other
269+ // fields but isn't editable; the country was fixed when the offer was priced.
270+ Widget _buildLockedCountryField (
271+ BuildContext context, {
272+ required bool isDesktop,
273+ }) {
274+ final label =
275+ _countries
276+ .where ((c) => c['iso' ] == _selectedCountryIso)
277+ .map ((c) => c['label' ] as String )
278+ .firstOrNull ??
279+ (_selectedCountryIso ?? "" );
280+
281+ return Container (
282+ decoration: BoxDecoration (
283+ color: Theme .of (context).extension < StackColors > ()! .textFieldDefaultBG,
284+ borderRadius: BorderRadius .circular (
285+ Constants .size.circularBorderRadius,
286+ ),
287+ ),
288+ padding: const EdgeInsets .symmetric (horizontal: 16 , vertical: 16 ),
289+ child: Row (
290+ mainAxisAlignment: MainAxisAlignment .spaceBetween,
291+ children: [
292+ Text (
293+ "Country" ,
294+ style: isDesktop
295+ ? STextStyles .desktopTextExtraSmall (context).copyWith (
296+ color: Theme .of (
297+ context,
298+ ).extension < StackColors > ()! .textFieldDefaultSearchIconLeft,
299+ )
300+ : STextStyles .fieldLabel (context),
301+ ),
302+ Text (
303+ label,
304+ style: isDesktop
305+ ? STextStyles .desktopTextExtraSmall (context).copyWith (
306+ color: Theme .of (
307+ context,
308+ ).extension < StackColors > ()! .textFieldActiveText,
309+ )
310+ : STextStyles .w500_14 (context),
311+ ),
312+ ],
313+ ),
314+ );
315+ }
316+
317+ // Editable, searchable country dropdown. Only shown when the delivery country
318+ // wasn't pre-set (restored-from-API orders).
319+ Widget _buildCountryDropdown (
320+ BuildContext context, {
321+ required bool isDesktop,
322+ }) {
323+ return ClipRRect (
324+ borderRadius: BorderRadius .circular (Constants .size.circularBorderRadius),
325+ child: DropdownButtonHideUnderline (
326+ child: DropdownButton2 <String >(
327+ value: _selectedCountryIso,
328+ items: _countries
329+ .map (
330+ (c) => DropdownMenuItem <String >(
331+ value: c['iso' ] as String ,
332+ child: Text (
333+ c['label' ] as String ,
334+ style: isDesktop
335+ ? STextStyles .desktopTextExtraSmall (context).copyWith (
336+ color: Theme .of (
337+ context,
338+ ).extension < StackColors > ()! .textFieldActiveText,
339+ )
340+ : STextStyles .w500_14 (context),
341+ ),
342+ ),
343+ )
344+ .toList (),
345+ onMenuStateChange: (isOpen) {
346+ if (! isOpen) {
347+ _countrySearchController.clear ();
348+ }
349+ },
350+ onChanged: _loadingCountries
351+ ? null
352+ : (value) => setState (() => _selectedCountryIso = value),
353+ hint: Text (
354+ _loadingCountries ? "Loading countries..." : "Country" ,
355+ style: isDesktop
356+ ? STextStyles .desktopTextExtraSmall (context).copyWith (
357+ color: Theme .of (
358+ context,
359+ ).extension < StackColors > ()! .textFieldDefaultSearchIconLeft,
360+ )
361+ : STextStyles .fieldLabel (context),
362+ ),
363+ isExpanded: true ,
364+ buttonStyleData: ButtonStyleData (
365+ decoration: BoxDecoration (
366+ color: Theme .of (
367+ context,
368+ ).extension < StackColors > ()! .textFieldDefaultBG,
369+ borderRadius: BorderRadius .circular (
370+ Constants .size.circularBorderRadius,
371+ ),
372+ ),
373+ ),
374+ iconStyleData: IconStyleData (
375+ icon: Padding (
376+ padding: const EdgeInsets .only (right: 10 ),
377+ child: SvgPicture .asset (
378+ Assets .svg.chevronDown,
379+ width: 12 ,
380+ height: 6 ,
381+ color: Theme .of (
382+ context,
383+ ).extension < StackColors > ()! .textFieldActiveSearchIconRight,
384+ ),
385+ ),
386+ ),
387+ dropdownStyleData: DropdownStyleData (
388+ offset: const Offset (0 , 0 ),
389+ elevation: 0 ,
390+ maxHeight: 300 ,
391+ decoration: BoxDecoration (
392+ color: Theme .of (
393+ context,
394+ ).extension < StackColors > ()! .textFieldDefaultBG,
395+ borderRadius: BorderRadius .circular (
396+ Constants .size.circularBorderRadius,
397+ ),
398+ ),
399+ ),
400+ dropdownSearchData: DropdownSearchData <String >(
401+ searchController: _countrySearchController,
402+ searchInnerWidgetHeight: 48 ,
403+ searchInnerWidget: TextFormField (
404+ controller: _countrySearchController,
405+ decoration: InputDecoration (
406+ isDense: true ,
407+ contentPadding: const EdgeInsets .symmetric (
408+ horizontal: 16 ,
409+ vertical: 14 ,
410+ ),
411+ hintText: "Search..." ,
412+ hintStyle: STextStyles .fieldLabel (context),
413+ border: InputBorder .none,
414+ ),
415+ ),
416+ searchMatchFn: (item, searchValue) {
417+ final label = _countries
418+ .where ((c) => c['iso' ] == item.value)
419+ .map ((c) => c['label' ] as String )
420+ .firstOrNull;
421+ return label? .toLowerCase ().contains (searchValue.toLowerCase ()) ??
422+ false ;
423+ },
424+ ),
425+ menuItemStyleData: const MenuItemStyleData (
426+ padding: EdgeInsets .symmetric (horizontal: 16 , vertical: 8 ),
427+ ),
428+ ),
429+ ),
430+ );
431+ }
432+
263433 @override
264434 Widget build (BuildContext context) {
265435 final isDesktop = Util .isDesktop;
@@ -327,120 +497,25 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
327497 ],
328498 ),
329499 spacing,
330- ClipRRect (
331- borderRadius: BorderRadius .circular (
332- Constants .size.circularBorderRadius,
333- ),
334- child: DropdownButtonHideUnderline (
335- child: DropdownButton2 <String >(
336- value: _selectedCountryIso,
337- items: _countries
338- .map (
339- (c) => DropdownMenuItem <String >(
340- value: c['iso' ] as String ,
341- child: Text (
342- c['label' ] as String ,
343- style: isDesktop
344- ? STextStyles .desktopTextExtraSmall (
345- context,
346- ).copyWith (
347- color: Theme .of (
348- context,
349- ).extension < StackColors > ()! .textFieldActiveText,
350- )
351- : STextStyles .w500_14 (context),
352- ),
353- ),
354- )
355- .toList (),
356- onMenuStateChange: (isOpen) {
357- if (! isOpen) {
358- _countrySearchController.clear ();
359- }
360- },
361- onChanged: (_countryLocked || _loadingCountries)
362- ? null
363- : (value) => setState (() => _selectedCountryIso = value),
364- hint: Text (
365- _loadingCountries ? "Loading countries..." : "Country" ,
366- style: isDesktop
367- ? STextStyles .desktopTextExtraSmall (context).copyWith (
368- color: Theme .of (context)
369- .extension < StackColors > ()!
370- .textFieldDefaultSearchIconLeft,
371- )
372- : STextStyles .fieldLabel (context),
373- ),
374- isExpanded: true ,
375- buttonStyleData: ButtonStyleData (
376- decoration: BoxDecoration (
377- color: Theme .of (
378- context,
379- ).extension < StackColors > ()! .textFieldDefaultBG,
380- borderRadius: BorderRadius .circular (
381- Constants .size.circularBorderRadius,
382- ),
383- ),
384- ),
385- iconStyleData: IconStyleData (
386- icon: Padding (
387- padding: const EdgeInsets .only (right: 10 ),
388- child: SvgPicture .asset (
389- Assets .svg.chevronDown,
390- width: 12 ,
391- height: 6 ,
392- color: Theme .of (
393- context,
394- ).extension < StackColors > ()! .textFieldActiveSearchIconRight,
395- ),
396- ),
397- ),
398- dropdownStyleData: DropdownStyleData (
399- offset: const Offset (0 , 0 ),
400- elevation: 0 ,
401- maxHeight: 300 ,
402- decoration: BoxDecoration (
403- color: Theme .of (
404- context,
405- ).extension < StackColors > ()! .textFieldDefaultBG,
406- borderRadius: BorderRadius .circular (
407- Constants .size.circularBorderRadius,
408- ),
409- ),
410- ),
411- dropdownSearchData: DropdownSearchData <String >(
412- searchController: _countrySearchController,
413- searchInnerWidgetHeight: 48 ,
414- searchInnerWidget: TextFormField (
415- controller: _countrySearchController,
416- decoration: InputDecoration (
417- isDense: true ,
418- contentPadding: const EdgeInsets .symmetric (
419- horizontal: 16 ,
420- vertical: 14 ,
421- ),
422- hintText: "Search..." ,
423- hintStyle: STextStyles .fieldLabel (context),
424- border: InputBorder .none,
425- ),
426- ),
427- searchMatchFn: (item, searchValue) {
428- final label = _countries
429- .where ((c) => c['iso' ] == item.value)
430- .map ((c) => c['label' ] as String )
431- .firstOrNull;
432- return label? .toLowerCase ().contains (
433- searchValue.toLowerCase (),
434- ) ??
435- false ;
436- },
437- ),
438- menuItemStyleData: const MenuItemStyleData (
439- padding: EdgeInsets .symmetric (horizontal: 16 , vertical: 8 ),
440- ),
441- ),
500+ // The delivery country was chosen when the offer was requested and the
501+ // price (incl. shipping + VAT) was calculated from it, so it can't be
502+ // changed here. Restored-from-API orders are the exception: they come
503+ // back with no country, so we let the user supply one (and warn that it
504+ // may not match what the offer was priced for).
505+ if (_countryLocked)
506+ _buildLockedCountryField (context, isDesktop: isDesktop)
507+ else ...[
508+ _buildCountryDropdown (context, isDesktop: isDesktop),
509+ SizedBox (height: isDesktop ? 8 : 6 ),
510+ Text (
511+ "This order was started on another device. Choosing a country "
512+ "here may not match the delivery destination the offer was "
513+ "priced for." ,
514+ style: isDesktop
515+ ? STextStyles .desktopTextSmall (context)
516+ : STextStyles .itemSubtitle (context),
442517 ),
443- ) ,
518+ ] ,
444519 spacing,
445520 // Billing address toggle.
446521 GestureDetector (
0 commit comments