What changed, and why it matters
This commit is a large code cleanup that splits one big ShopinBit order form file into smaller, easier-to-maintain pieces. It does not appear to change what the app does or how user data is handled; it mostly moves existing UI code around. There is no clear security problem visible in the diff, but because the change is so large and was labeled as 'testing AI refactoring,' it could accidentally introduce subtle bugs that would need normal testing to catch.
Treat this as a routine refactor. Run the existing UI/widget tests for the ShopinBit Step 4 flow, verify form validation and submission still work for concierge, car, travel, and generic categories, and confirm the privacy-policy link and external-browser warning still behave correctly. No security-specific response is indicated by the diff alone.
Security signals we found
Large refactor with no explicit security claim in commit message
No new imports of sensitive platform APIs or network endpoints
No changes to encryption, key storage, or wallet handling
Privacy policy URL remains hard-coded and unchanged
Validation and submit logic appears functionally equivalent to original
Evidence from the diff
The commit refactors lib/pages/shopinbit/shopinbit_step_4.dart from a single ~2,400-line StatefulWidget into a StatelessWidget shell plus 13 new component files (concierge, car, travel, generic forms, country picker, dropdown, text field, checkboxes, submit button, header, etc.). The visible logic—validation rules, budget minimums, country fetching, privacy URL, submit flow, and car-research pending-ticket guard—is preserved from the original file. No new network calls, crypto operations, permissions, or data-exfiltration paths are introduced in the diff. The privacy-policy link remains the same hard-coded HTTPS URL and still opens in an external browser after a confirmation dialog.
Changed components
lib/pages/shopinbit/shopinbit_step_4.dartlib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dartlib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dartlib/pages/shopinbit/step_4_components/shopinbit_country_picker.dartlib/pages/shopinbit/step_4_components/shopinbit_generic_form.dartlib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dartlib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dartlib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dartlib/pages/shopinbit/step_4_components/shopinbit_step4_header.dartlib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dartlib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dartlib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dartlib/pages/shopinbit/step_4_components/shopinbit_travel_form.dartlib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dartInspect captured patch +2062 / −2369
diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart
index ee5c411..c5cfd4f 100644
--- a/lib/pages/shopinbit/shopinbit_step_4.dart
+++ b/lib/pages/shopinbit/shopinbit_step_4.dart
@@ -1,36 +1,20 @@
-import 'dart:async';
-
-import 'package:dropdown_button2/dropdown_button2.dart';
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_svg/svg.dart';
-import 'package:url_launcher/url_launcher.dart';
-
-import '../../db/isar/main_db.dart';
-import '../../models/shopinbit/shopinbit_order_model.dart';
-import '../../notifications/show_flush_bar.dart';
-import '../../services/shopinbit/shopinbit_service.dart';
-import '../../themes/stack_colors.dart';
-import '../../utilities/assets.dart';
-import '../../utilities/constants.dart';
-import '../../utilities/text_styles.dart';
-import '../../utilities/util.dart';
-import '../../widgets/background.dart';
-import '../../widgets/custom_buttons/app_bar_icon_button.dart';
-import '../../widgets/desktop/desktop_dialog.dart';
-import '../../widgets/desktop/desktop_dialog_close_button.dart';
-import '../../widgets/desktop/primary_button.dart';
-import '../../widgets/desktop/secondary_button.dart';
-import '../../widgets/rounded_white_container.dart';
-import '../../widgets/stack_dialog.dart';
-import '../../widgets/stack_text_field.dart';
-import '../exchange_view/sub_widgets/step_row.dart';
-import 'shopinbit_car_fee_view.dart';
-import 'shopinbit_order_created.dart';
-import 'shopinbit_tickets_view.dart';
-
-class ShopInBitStep4 extends StatefulWidget {
+import "package:flutter/material.dart";
+
+import "../../models/shopinbit/shopinbit_order_model.dart";
+import "../../themes/stack_colors.dart";
+import "../../utilities/text_styles.dart";
+import "../../utilities/util.dart";
+import "../../widgets/background.dart";
+import "../../widgets/conditional_parent.dart";
+import "../../widgets/custom_buttons/app_bar_icon_button.dart";
+import "../../widgets/desktop/desktop_dialog.dart";
+import "../../widgets/desktop/desktop_dialog_close_button.dart";
+import "step_4_components/shopinbit_car_research_form.dart";
+import "step_4_components/shopinbit_concierge_form.dart";
+import "step_4_components/shopinbit_generic_form.dart";
+import "step_4_components/shopinbit_travel_form.dart";
+
+class ShopInBitStep4 extends StatelessWidget {
const ShopInBitStep4({super.key, required this.model});
static const String routeName = "/shopInBitStep4";
@@ -38,2362 +22,67 @@ class ShopInBitStep4 extends StatefulWidget {
final ShopInBitOrderModel model;
@override
- State<ShopInBitStep4> createState() => _ShopInBitStep4State();
-}
-
-class _ShopInBitStep4State extends State<ShopInBitStep4> {
- // Generic form controllers.
- late final TextEditingController _descriptionController;
- late final FocusNode _descriptionFocusNode;
- final TextEditingController _countrySearchController =
- TextEditingController();
-
- // Concierge-specific controllers
- late final TextEditingController _whatToPurchaseController;
- late final FocusNode _whatToPurchaseFocusNode;
- late final TextEditingController _budgetController;
- late final FocusNode _budgetFocusNode;
- String? _selectedCondition;
- bool _noLimit = false;
- bool _whatToPurchaseTouched = false;
- bool _budgetTouched = false;
-
- // Car Research-specific controllers
- late final TextEditingController _brandController;
- late final FocusNode _brandFocusNode;
- late final TextEditingController _modelController;
- late final FocusNode _modelFocusNode;
- late final TextEditingController _carDescriptionController;
- late final FocusNode _carDescriptionFocusNode;
- late final TextEditingController _carBudgetController;
- late final FocusNode _carBudgetFocusNode;
- String? _selectedCarCondition;
- bool _feeAcknowledged = false;
- bool _brandTouched = false;
- bool _modelTouched = false;
- bool _carDescriptionTouched = false;
- bool _carBudgetTouched = false;
-
- // Travel-specific controllers
- late final TextEditingController _departureCountryController;
- late final FocusNode _departureCountryFocusNode;
- String? _selectedDepartureCountryIso;
- final TextEditingController _departureCountrySearchController =
- TextEditingController();
- late final TextEditingController _arrangementDetailsController;
- late final FocusNode _arrangementDetailsFocusNode;
- bool _arrangementDetailsTouched = false;
- late final TextEditingController _departureCityController;
- late final FocusNode _departureCityFocusNode;
- late final TextEditingController _destinationsController;
- late final FocusNode _destinationsFocusNode;
- late final TextEditingController _departureDateController;
- late final FocusNode _departureDateFocusNode;
- late final TextEditingController _returnDateController;
- late final FocusNode _returnDateFocusNode;
- late final TextEditingController _tripLengthController;
- late final FocusNode _tripLengthFocusNode;
- late final TextEditingController _travelBudgetController;
- late final FocusNode _travelBudgetFocusNode;
-
- // Travel dropdown state
- String? _selectedArrangement;
- String? _selectedDateMode;
- String? _selectedFlexibility;
- String? _selectedYear;
- String? _selectedMonthSeason;
- bool _needsRecommendations = false;
- int _adults = 1;
- int _children = 0;
- int _infants = 0;
- int _pets = 0;
-
- // Travel touched booleans
- bool _departureCountryTouched = false;
- bool _departureCityTouched = false;
- bool _destinationsTouched = false;
- bool _departureDateTouched = false;
- bool _returnDateTouched = false;
- bool _tripLengthTouched = false;
- bool _travelBudgetTouched = false;
-
- List<Map<String, dynamic>> _countries = [];
- String? _selectedCountryIso;
- bool _loadingCountries = false;
-
- bool _submitting = false;
- bool _privacyAccepted = false;
-
- Future<bool> _showOpenBrowserWarning(BuildContext context, String url) async {
- final uri = Uri.parse(url);
- final shouldContinue = await showDialog<bool>(
- context: context,
- barrierDismissible: false,
- builder: (context) => Util.isDesktop
- ? DesktopDialog(
- maxWidth: 550,
- maxHeight: 250,
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 32,
- vertical: 20,
- ),
- child: Column(
- children: [
- Text("Attention", style: STextStyles.desktopH2(context)),
- const SizedBox(height: 16),
- Text(
- "You are about to open "
- "${uri.scheme}://${uri.host} "
- "in your browser.",
- style: STextStyles.desktopTextSmall(context),
- ),
- const SizedBox(height: 35),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- SecondaryButton(
- width: 200,
- buttonHeight: ButtonHeight.l,
- label: "Cancel",
- onPressed: () => Navigator.of(context).pop(false),
- ),
- const SizedBox(width: 20),
- PrimaryButton(
- width: 200,
- buttonHeight: ButtonHeight.l,
- label: "Continue",
- onPressed: () => Navigator.of(context).pop(true),
- ),
- ],
- ),
- ],
- ),
- ),
- )
- : StackDialog(
- title: "Attention",
- message:
- "You are about to open "
- "${uri.scheme}://${uri.host} "
- "in your browser.",
- leftButton: SecondaryButton(
- label: "Cancel",
- onPressed: () => Navigator.of(context).pop(false),
- ),
- rightButton: PrimaryButton(
- label: "Continue",
- onPressed: () => Navigator.of(context).pop(true),
- ),
- ),
- );
- return shouldContinue ?? false;
- }
-
- bool get _budgetIsValid {
- final text = _budgetController.text.trim();
- if (text.isEmpty) return false;
- final value = int.tryParse(text);
- return value != null && value >= 1000 && value <= 100000;
- }
-
- bool get _canContinue {
- final cat = widget.model.category;
- if (cat == ShopInBitCategory.concierge) {
- return !_submitting &&
- _privacyAccepted &&
- _whatToPurchaseController.text.trim().length >= 10 &&
- _selectedCondition != null &&
- (_noLimit || _budgetIsValid) &&
- _selectedCountryIso != null;
- }
- if (cat == ShopInBitCategory.car) {
- final carBudgetVal = int.tryParse(_carBudgetController.text.trim());
- return !_submitting &&
- _privacyAccepted &&
- _feeAcknowledged &&
- _brandController.text.trim().length >= 3 &&
- _modelController.text.trim().length >= 3 &&
- _carDescriptionController.text.trim().length >= 3 &&
- _selectedCarCondition != null &&
- carBudgetVal != null &&
- carBudgetVal >= 20000 &&
- _selectedCountryIso != null;
- }
- if (cat == ShopInBitCategory.travel) {
- final travelBudgetVal = int.tryParse(_travelBudgetController.text.trim());
- final hasValidDates = _selectedDateMode == "Flexible dates"
- ? (_selectedYear != null &&
- _selectedMonthSeason != null &&
- _tripLengthController.text.trim().isNotEmpty)
- : (_selectedDateMode == "Exact dates" &&
- _departureDateController.text.trim().isNotEmpty &&
- _returnDateController.text.trim().isNotEmpty);
- return !_submitting &&
- _privacyAccepted &&
- _selectedArrangement != null &&
- _arrangementDetailsController.text.trim().length >= 10 &&
- _selectedDepartureCountryIso != null &&
- _departureCityController.text.trim().isNotEmpty &&
- (_needsRecommendations ||
- _destinationsController.text.trim().isNotEmpty) &&
- _selectedDateMode != null &&
- hasValidDates &&
- _adults >= 1 &&
- travelBudgetVal != null &&
- travelBudgetVal >= 1000;
- }
- // generic fallback
- return !_submitting &&
- _privacyAccepted &&
- _descriptionController.text.trim().isNotEmpty &&
- _selectedCountryIso != null;
- }
-
- @override
- void initState() {
- super.initState();
- _descriptionController = TextEditingController(
- text: widget.model.requestDescription,
- );
- _descriptionFocusNode = FocusNode();
- _descriptionFocusNode.addListener(() => setState(() {}));
-
- // Concierge-specific init
- _whatToPurchaseController = TextEditingController();
- _whatToPurchaseFocusNode = FocusNode();
- _whatToPurchaseFocusNode.addListener(() {
- if (!_whatToPurchaseFocusNode.hasFocus) {
- _whatToPurchaseTouched = true;
- }
- setState(() {});
- });
- _budgetController = TextEditingController(text: "1000");
- _budgetFocusNode = FocusNode();
- _budgetFocusNode.addListener(() {
- if (!_budgetFocusNode.hasFocus) {
- _budgetTouched = true;
- }
- setState(() {});
- });
-
- // Car Research-specific init
- _brandController = TextEditingController();
- _brandFocusNode = FocusNode();
- _brandFocusNode.addListener(() {
- if (!_brandFocusNode.hasFocus) {
- _brandTouched = true;
- }
- setState(() {});
- });
- _modelController = TextEditingController();
- _modelFocusNode = FocusNode();
- _modelFocusNode.addListener(() {
- if (!_modelFocusNode.hasFocus) {
- _modelTouched = true;
- }
- setState(() {});
- });
- _carDescriptionController = TextEditingController();
- _carDescriptionFocusNode = FocusNode();
- _carDescriptionFocusNode.addListener(() {
- if (!_carDescriptionFocusNode.hasFocus) {
- _carDescriptionTouched = true;
- }
- setState(() {});
- });
- _carBudgetController = TextEditingController();
- _carBudgetFocusNode = FocusNode();
- _carBudgetFocusNode.addListener(() {
- if (!_carBudgetFocusNode.hasFocus) {
- _carBudgetTouched = true;
- }
- setState(() {});
- });
-
- // Travel-specific init
- _departureCountryController = TextEditingController();
- _departureCountryFocusNode = FocusNode();
- _departureCountryFocusNode.addListener(() {
- if (!_departureCountryFocusNode.hasFocus) {
- _departureCountryTouched = true;
- }
- setState(() {});
- });
- _arrangementDetailsController = TextEditingController();
- _arrangementDetailsFocusNode = FocusNode();
- _arrangementDetailsFocusNode.addListener(() {
- if (!_arrangementDetailsFocusNode.hasFocus) {
- _arrangementDetailsTouched = true;
- }
- setState(() {});
- });
- _departureCityController = TextEditingController();
- _departureCityFocusNode = FocusNode();
- _departureCityFocusNode.addListener(() {
- if (!_departureCityFocusNode.hasFocus) {
- _departureCityTouched = true;
- }
- setState(() {});
- });
- _destinationsController = TextEditingController();
- _destinationsFocusNode = FocusNode();
- _destinationsFocusNode.addListener(() {
- if (!_destinationsFocusNode.hasFocus) {
- _destinationsTouched = true;
- }
- setState(() {});
- });
- _departureDateController = TextEditingController();
- _departureDateFocusNode = FocusNode();
- _departureDateFocusNode.addListener(() {
- if (!_departureDateFocusNode.hasFocus) {
- _departureDateTouched = true;
- }
- setState(() {});
- });
- _returnDateController = TextEditingController();
- _returnDateFocusNode = FocusNode();
- _returnDateFocusNode.addListener(() {
- if (!_returnDateFocusNode.hasFocus) {
- _returnDateTouched = true;
- }
- setState(() {});
- });
- _tripLengthController = TextEditingController();
- _tripLengthFocusNode = FocusNode();
- _tripLengthFocusNode.addListener(() {
- if (!_tripLengthFocusNode.hasFocus) {
- _tripLengthTouched = true;
- }
- setState(() {});
- });
- _travelBudgetController = TextEditingController(text: "5000");
- _travelBudgetFocusNode = FocusNode();
- _travelBudgetFocusNode.addListener(() {
- if (!_travelBudgetFocusNode.hasFocus) {
- _travelBudgetTouched = true;
- }
- setState(() {});
- });
-
- if (widget.model.deliveryCountry.isNotEmpty) {
- _selectedCountryIso = widget.model.deliveryCountry;
- }
- _fetchCountries();
- }
-
- @override
- void dispose() {
- _descriptionController.dispose();
- _descriptionFocusNode.dispose();
- _countrySearchController.dispose();
- _whatToPurchaseController.dispose();
- _whatToPurchaseFocusNode.dispose();
- _budgetController.dispose();
- _budgetFocusNode.dispose();
- _brandController.dispose();
- _brandFocusNode.dispose();
- _modelController.dispose();
- _modelFocusNode.dispose();
- _carDescriptionController.dispose();
- _carDescriptionFocusNode.dispose();
- _carBudgetController.dispose();
- _carBudgetFocusNode.dispose();
- _departureCountryController.dispose();
- _departureCountryFocusNode.dispose();
- _departureCountrySearchController.dispose();
- _arrangementDetailsController.dispose();
- _arrangementDetailsFocusNode.dispose();
- _departureCityController.dispose();
- _departureCityFocusNode.dispose();
- _destinationsController.dispose();
- _destinationsFocusNode.dispose();
- _departureDateController.dispose();
- _departureDateFocusNode.dispose();
- _returnDateController.dispose();
- _returnDateFocusNode.dispose();
- _tripLengthController.dispose();
- _tripLengthFocusNode.dispose();
- _travelBudgetController.dispose();
- _travelBudgetFocusNode.dispose();
- super.dispose();
- }
-
- Future<void> _fetchCountries() async {
- setState(() => _loadingCountries = true);
- try {
- final resp = await ShopInBitService.instance.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> _submit() async {
- // Format structured comment per category.
- // Use ISO code for delivery country in comment: country labels can
- // contain non-ASCII (e.g. "Åland Islands") which HttpClientRequest.write()
- // encodes as Latin-1, corrupting the JSON body on mobile.
- final countryIso = _selectedCountryIso!;
- if (widget.model.category == ShopInBitCategory.concierge) {
- final budgetText = _noLimit
- ? "No limit"
- : "${_budgetController.text.trim()} EUR";
- widget.model.requestDescription =
- "What to purchase: ${_whatToPurchaseController.text.trim()}\n"
- "Condition: $_selectedCondition\n"
- "Budget: $budgetText\n"
- "Delivery country: $countryIso";
- } else if (widget.model.category == ShopInBitCategory.car) {
- widget.model.requestDescription =
- "Brand: ${_brandController.text.trim()}\n"
- "Model: ${_modelController.text.trim()}\n"
- "Condition: $_selectedCarCondition\n"
- "Description: ${_carDescriptionController.text.trim()}\n"
- "Budget: ${_carBudgetController.text.trim()} EUR\n"
- "Delivery country: $countryIso";
- } else if (widget.model.category == ShopInBitCategory.travel) {
- final parts = <String>[
- "Arrangement: $_selectedArrangement",
- "Details: ${_arrangementDetailsController.text.trim()}",
- "Departure: ${_departureCityController.text.trim()}, "
- "${_selectedDepartureCountryIso ?? ''}",
- ];
-
- if (_needsRecommendations) {
- parts.add("Destinations: Recommendations requested");
- } else {
- parts.add("Destinations: ${_destinationsController.text.trim()}");
- }
-
- if (_selectedDateMode == "Exact dates") {
- final flex =
- _selectedFlexibility != null && _selectedFlexibility != "Exact"
- ? " ($_selectedFlexibility)"
- : "";
- parts.add(
- "Dates: ${_departureDateController.text.trim()} - "
- "${_returnDateController.text.trim()}$flex",
- );
- } else if (_selectedDateMode == "Flexible dates") {
- parts.add(
- "Dates: $_selectedMonthSeason $_selectedYear, "
- "${_tripLengthController.text.trim()} nights",
- );
- }
-
- final travelers = <String>[];
- travelers.add("$_adults adult${_adults > 1 ? 's' : ''}");
- if (_children > 0) {
- travelers.add("$_children child${_children > 1 ? 'ren' : ''}");
- }
- if (_infants > 0) {
- travelers.add("$_infants infant${_infants > 1 ? 's' : ''}");
- }
- if (_pets > 0) {
- travelers.add("$_pets pet${_pets > 1 ? 's' : ''}");
- }
- parts.add("Travelers: ${travelers.join(', ')}");
-
- parts.add("Budget: ${_travelBudgetController.text.trim()} EUR");
-
- widget.model.requestDescription = parts.join("\n");
- } else {
- widget.model.requestDescription = _descriptionController.text.trim();
- }
- // Travel doesn't collect delivery country: use departure country or "DE"
- // as a default since the API requires the field.
- if (widget.model.category == ShopInBitCategory.travel) {
- widget.model.deliveryCountry = "DE";
- } else {
- widget.model.deliveryCountry = _selectedCountryIso!;
- }
-
- if (widget.model.category == ShopInBitCategory.car) {
- // Block if another car research flow is already in progress.
- final existingPending = MainDB.instance
- .getShopInBitTickets()
- .where((t) => t.isPendingPayment)
- .toList();
-
- if (existingPending.isNotEmpty && mounted) {
- final resumePrevious = await showDialog<bool>(
- context: context,
- barrierDismissible: false,
- builder: (ctx) => AlertDialog(
- title: const Text("In-Progress Car Research"),
- content: const Text(
- "You have an unfinished car research payment. "
- "Would you like to resume it or start a new search?",
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(true),
- child: const Text("Resume Previous"),
- ),
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- child: const Text("Start New"),
- ),
- ],
- ),
- );
-
- if (resumePrevious == true && mounted) {
- setState(() => _submitting = false);
- unawaited(
- Navigator.of(context).pushNamedAndRemoveUntil(
- ShopInBitTicketsView.routeName,
- (route) => route.isFirst,
- ),
- );
- return;
- }
- }
-
- if (!mounted) return;
-
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- unawaited(
- showDialog<void>(
- context: context,
- builder: (_) => ShopInBitCarFeeView(model: widget.model),
- ),
- );
- } else {
- unawaited(
- Navigator.of(
- context,
- ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model),
- );
- }
- return;
- }
-
- setState(() => _submitting = true);
- try {
- final service = ShopInBitService.instance;
- final customerKey = await service.ensureCustomerKey();
-
- assert(
- widget.model.category != null,
- 'Step 4 reached with null category: Step 2 must set category before'
- ' reaching Step 4',
- );
-
- // API service_type: travel requests use "concierge" because the
- // ShopinBit API routes both through the same concierge pipeline.
- // Travel-specific details are captured in the structured comment field.
- final categoryStr = switch (widget.model.category) {
- ShopInBitCategory.concierge => "concierge",
- ShopInBitCategory.travel => "concierge",
- ShopInBitCategory.car => "car",
- null => throw StateError('category must be non-null at Step 4 submit'),
- };
-
- final resp = await service.client.createRequest(
- customerPseudonym: widget.model.displayName,
- externalCustomerKey: customerKey,
- serviceType: categoryStr,
- comment: widget.model.requestDescription,
- deliveryCountry: widget.model.deliveryCountry,
- );
-
- if (resp.hasError) {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.warning,
- message: resp.exception?.message ?? "Failed to create request",
- context: context,
- ),
- );
- }
- return;
- }
-
- final ref = resp.value!;
- widget.model.apiTicketId = ref.id;
- widget.model.ticketId = ref.number;
- widget.model.status = ShopInBitOrderStatus.pending;
- await MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket());
-
- if (!mounted) return;
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- unawaited(
- showDialog<void>(
- context: context,
- builder: (_) => ShopInBitOrderCreated(model: widget.model),
- ),
- );
- } else {
- unawaited(
- Navigator.of(
- context,
- ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model),
- );
- }
- } catch (e) {
- if (mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.warning,
- message: "Failed to create request: $e",
- context: context,
- ),
- );
- }
- } finally {
- if (mounted) setState(() => _submitting = false);
- }
- }
-
- // Shared widgets.
- Widget _buildCountryPicker(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..." : "Delivery 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),
- ),
- ),
- ),
- );
- }
-
- Widget _buildDepartureCountryPicker(bool isDesktop) {
- return ClipRRect(
- borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
- child: DropdownButtonHideUnderline(
- child: DropdownButton2<String>(
- value: _selectedDepartureCountryIso,
- 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) {
- _departureCountrySearchController.clear();
- }
- },
- onChanged: _loadingCountries
- ? null
- : (value) {
- setState(() {
- _selectedDepartureCountryIso = value;
- _departureCountryTouched = true;
- });
- },
- hint: Text(
- _loadingCountries ? "Loading countries..." : "Departure 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: _departureCountrySearchController,
- searchInnerWidgetHeight: 48,
- searchInnerWidget: TextFormField(
- controller: _departureCountrySearchController,
- 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),
- ),
- ),
- ),
- );
- }
-
- Widget _buildPrivacyCheckbox(bool isDesktop) {
- return GestureDetector(
- onTap: () {
- setState(() {
- _privacyAccepted = !_privacyAccepted;
- });
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: isDesktop
- ? CrossAxisAlignment.center
- : CrossAxisAlignment.start,
- children: [
- Padding(
- padding: EdgeInsets.only(top: isDesktop ? 3 : 0),
- child: SizedBox(
- width: 20,
- height: 20,
- child: IgnorePointer(
- child: Checkbox(
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- value: _privacyAccepted,
- onChanged: (_) {},
- ),
- ),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: RichText(
- text: TextSpan(
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- children: [
- const TextSpan(
- text: "I have read and agree to the ShopinBit ",
- ),
- TextSpan(
- text: "Privacy Policy",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: isDesktop ? 18 : 14),
- recognizer: TapGestureRecognizer()
- ..onTap = () async {
- const url =
- "https://api.shopinbit.com/static/policy/privacy.html";
- final shouldOpen = await _showOpenBrowserWarning(
- context,
- url,
- );
- if (shouldOpen) {
- await launchUrl(
- Uri.parse(url),
- mode: LaunchMode.externalApplication,
- );
- }
- },
- ),
- const TextSpan(text: "."),
- ],
- ),
- ),
- ),
- ],
- ),
+ Widget build(BuildContext context) {
+ return ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => _ShopInBitStep4DesktopShell(content: child),
+ child: ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => _ShopInBitStep4MobileShell(content: child),
+ child: switch (model.category) {
+ ShopInBitCategory.concierge => ShopInBitConciergeForm(model: model),
+ ShopInBitCategory.car => ShopInBitCarResearchForm(model: model),
+ ShopInBitCategory.travel => ShopInBitTravelForm(model: model),
+ null => ShopInBitGenericForm(model: model),
+ },
),
);
}
+}
- Widget _buildSubmitButton() {
- return PrimaryButton(
- label: _submitting ? "Submitting..." : "Submit request",
- enabled: _canContinue,
- onPressed: _canContinue ? _submit : null,
- );
- }
-
- // Per-category form builders.
-
- Widget _buildConciergeContent(bool isDesktop) {
- final whatToPurchaseError =
- _whatToPurchaseTouched &&
- _whatToPurchaseController.text.trim().length < 10
- ? "Minimum 10 characters"
- : null;
-
- final budgetError = _budgetTouched && !_noLimit && !_budgetIsValid
- ? "Enter a value between 1,000 and 100,000"
- : null;
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (!isDesktop)
- StepRow(
- count: 4,
- current: 3,
- width: MediaQuery.of(context).size.width - 32,
- ),
- if (!isDesktop) const SizedBox(height: 14),
- Text(
- "What would you like to purchase?",
- style: isDesktop
- ? STextStyles.desktopH2(context)
- : STextStyles.pageTitleH1(context),
- ),
- SizedBox(height: isDesktop ? 16 : 8),
- Text(
- "Tell us what you're looking for and we'll find it for you.",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // What to purchase free-text field
- TextField(
- controller: _whatToPurchaseController,
- focusNode: _whatToPurchaseFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- minLines: 3,
- maxLines: 6,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Describe what you'd like to purchase "
- "(e.g., electronics, luxury goods, services...)",
- _whatToPurchaseFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: whatToPurchaseError,
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Condition picker
- ClipRRect(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- child: DropdownButtonHideUnderline(
- child: DropdownButton2<String>(
- value: _selectedCondition,
- items: ["NEW", "USED"]
- .map(
- (c) => DropdownMenuItem<String>(
- value: c,
- child: Text(
- c,
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- )
- : STextStyles.w500_14(context),
- ),
- ),
- )
- .toList(),
- onChanged: (value) {
- setState(() {
- _selectedCondition = value;
- });
- },
- hint: Text(
- "Condition",
- 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,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- menuItemStyleData: const MenuItemStyleData(
- padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- ),
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Budget field
- TextField(
- controller: _budgetController,
- focusNode: _budgetFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- enabled: !_noLimit,
- keyboardType: TextInputType.number,
- inputFormatters: [FilteringTextInputFormatter.digitsOnly],
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Budget (\u20AC)",
- _budgetFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- suffixText: "\u20AC",
- errorText: budgetError,
- ),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
-
- // No budget limit checkbox
- GestureDetector(
- onTap: () {
- setState(() {
- _noLimit = !_noLimit;
- });
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: IgnorePointer(
- child: Checkbox(
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- value: _noLimit,
- onChanged: (_) {},
- ),
- ),
- ),
- const SizedBox(width: 12),
- Text(
- "No budget limit",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ],
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 12 : 12),
-
- // Country picker (shared)
- _buildCountryPicker(isDesktop),
- SizedBox(height: isDesktop ? 12 : 12),
-
- // Privacy checkbox (shared)
- _buildPrivacyCheckbox(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Submit button (shared)
- _buildSubmitButton(),
- ],
- );
- }
-
- Widget _buildCarContent(bool isDesktop) {
- final brandError = _brandTouched && _brandController.text.trim().length < 3
- ? "Minimum 3 characters"
- : null;
-
- final modelError = _modelTouched && _modelController.text.trim().length < 3
- ? "Minimum 3 characters"
- : null;
-
- final carDescriptionError =
- _carDescriptionTouched &&
- _carDescriptionController.text.trim().length < 3
- ? "Minimum 3 characters"
- : null;
-
- final carBudgetText = _carBudgetController.text.trim();
- final carBudgetVal = int.tryParse(carBudgetText);
- final carBudgetError =
- _carBudgetTouched &&
- (carBudgetText.isEmpty ||
- carBudgetVal == null ||
- carBudgetVal < 20000)
- ? "Minimum budget is 20,000\u20AC"
- : null;
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (!isDesktop)
- StepRow(
- count: 4,
- current: 3,
- width: MediaQuery.of(context).size.width - 32,
- ),
- if (!isDesktop) const SizedBox(height: 14),
- Text(
- "Car Research request",
- style: isDesktop
- ? STextStyles.desktopH2(context)
- : STextStyles.pageTitleH1(context),
- ),
- SizedBox(height: isDesktop ? 16 : 8),
- Text(
- "Tell us about the car you're looking for.",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- SizedBox(height: isDesktop ? 32 : 24),
-
- // Country picker (shared)
- _buildCountryPicker(isDesktop),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Brand field
- TextField(
- controller: _brandController,
- focusNode: _brandFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Car brand (e.g., BMW, Mercedes, Toyota...)",
- _brandFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: brandError,
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Model field
- TextField(
- controller: _modelController,
- focusNode: _modelFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Car model (e.g., 3 Series, E-Class, Camry...)",
- _modelFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: modelError,
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Condition picker
- ClipRRect(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- child: DropdownButtonHideUnderline(
- child: DropdownButton2<String>(
- value: _selectedCarCondition,
- items: ["NEW", "PREOWNED"]
- .map(
- (c) => DropdownMenuItem<String>(
- value: c,
- child: Text(
- c,
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- )
- : STextStyles.w500_14(context),
- ),
- ),
- )
- .toList(),
- onChanged: (value) {
- setState(() {
- _selectedCarCondition = value;
- });
- },
- hint: Text(
- "Condition",
- 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,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- menuItemStyleData: const MenuItemStyleData(
- padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- ),
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Description field (multiline)
- TextField(
- controller: _carDescriptionController,
- focusNode: _carDescriptionFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- minLines: 3,
- maxLines: 6,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Describe your requirements (year, mileage, features...)",
- _carDescriptionFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: carDescriptionError,
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
+class _ShopInBitStep4DesktopShell extends StatelessWidget {
+ const _ShopInBitStep4DesktopShell({required this.content});
- // Budget field
- TextField(
- controller: _carBudgetController,
- focusNode: _carBudgetFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- keyboardType: TextInputType.number,
- inputFormatters: [FilteringTextInputFormatter.digitsOnly],
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Budget (\u20AC, minimum 20,000)",
- _carBudgetFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- suffixText: "\u20AC",
- errorText: carBudgetError,
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
+ final Widget content;
- // Research fee info box
- RoundedWhiteContainer(
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
+ @override
+ Widget build(BuildContext context) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 750,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Icon(
- Icons.info_outline,
- size: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconLeft,
- ),
- const SizedBox(width: 12),
- Expanded(
- child: RichText(
- text: TextSpan(
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- children: [
- TextSpan(
- text: "Research fee: ",
- style: isDesktop
- ? STextStyles.desktopTextSmall(
- context,
- ).copyWith(fontWeight: FontWeight.bold)
- : STextStyles.w500_14(
- context,
- ).copyWith(fontWeight: FontWeight.bold),
- ),
- const TextSpan(
- text:
- "\u20AC223 (incl. VAT): one-time payment, "
- "credited toward your purchase.",
- ),
- ],
- ),
- ),
+ Row(
+ children: [
+ const AppBarBackButton(isCompact: true, iconSize: 23),
+ Text("ShopinBit", style: STextStyles.desktopH3(context)),
+ ],
),
+ const DesktopDialogCloseButton(),
],
),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Fee acknowledgement checkbox
- GestureDetector(
- onTap: () {
- setState(() {
- _feeAcknowledged = !_feeAcknowledged;
- });
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: IgnorePointer(
- child: Checkbox(
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- value: _feeAcknowledged,
- onChanged: (_) {},
- ),
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- "I acknowledge the \u20AC223 research fee",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ),
- ],
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
+ child: SingleChildScrollView(child: content),
),
),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Privacy checkbox (shared)
- _buildPrivacyCheckbox(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Submit button (shared)
- _buildSubmitButton(),
- ],
- );
- }
-
- Widget _buildGenericContent(bool isDesktop) {
- const descriptionTitle = "Describe your travel request";
- const descriptionSubtitle = "Provide details about your trip.";
- const descriptionPlaceholder =
- "Describe your travel request (destinations, dates, passengers)";
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (!isDesktop)
- StepRow(
- count: 4,
- current: 3,
- width: MediaQuery.of(context).size.width - 32,
- ),
- if (!isDesktop) const SizedBox(height: 14),
- Text(
- descriptionTitle,
- style: isDesktop
- ? STextStyles.desktopH2(context)
- : STextStyles.pageTitleH1(context),
- ),
- SizedBox(height: isDesktop ? 16 : 8),
- Text(
- descriptionSubtitle,
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- SizedBox(height: isDesktop ? 32 : 24),
- ClipRRect(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- child: TextField(
- controller: _descriptionController,
- focusNode: _descriptionFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- minLines: 3,
- maxLines: 6,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- descriptionPlaceholder,
- _descriptionFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 24 : 16),
-
- // Country picker (shared)
- _buildCountryPicker(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Privacy checkbox (shared)
- _buildPrivacyCheckbox(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
-
- // Submit button (shared)
- _buildSubmitButton(),
- ],
- );
- }
-
- // Travel form helpers.
- Widget _buildTravelDropdown({
- required String? value,
- required List<String> items,
- required String hint,
- required ValueChanged<String?> onChanged,
- required bool isDesktop,
- }) {
- return ClipRRect(
- borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
- child: DropdownButtonHideUnderline(
- child: DropdownButton2<String>(
- value: value,
- items: items
- .map(
- (c) => DropdownMenuItem<String>(
- value: c,
- child: Text(
- c,
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- )
- : STextStyles.w500_14(context),
- ),
- ),
- )
- .toList(),
- onChanged: onChanged,
- hint: Text(
- hint,
- 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,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- menuItemStyleData: const MenuItemStyleData(
- padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- ),
- ),
+ ],
),
);
}
+}
- Widget _buildTravelerCounter({
- required String label,
- required int value,
- required int min,
- required int max,
- required ValueChanged<int> onChanged,
- required bool isDesktop,
- }) {
- return Row(
- children: [
- Text(
- label,
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- const Spacer(),
- InkWell(
- onTap: value > min ? () => onChanged(value - 1) : null,
- child: Container(
- width: 32,
- height: 32,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- child: Center(
- child: Text(
- "-",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ),
- ),
- ),
- const SizedBox(width: 16),
- SizedBox(
- width: 24,
- child: Center(
- child: Text(
- "$value",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ),
- ),
- const SizedBox(width: 16),
- InkWell(
- onTap: value < max ? () => onChanged(value + 1) : null,
- child: Container(
- width: 32,
- height: 32,
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- child: Center(
- child: Text(
- "+",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ),
- ),
- ),
- ],
- );
- }
-
- Widget _buildTravelContent(bool isDesktop) {
- final departureCountryError =
- _departureCountryTouched &&
- _departureCountryController.text.trim().isEmpty
- ? "Required"
- : null;
-
- final departureCityError =
- _departureCityTouched && _departureCityController.text.trim().isEmpty
- ? "Required"
- : null;
-
- final destinationsError =
- _destinationsTouched &&
- _destinationsController.text.trim().isEmpty &&
- !_needsRecommendations
- ? "Required (or check 'I need recommendations')"
- : null;
-
- final departureDateError =
- _departureDateTouched && _departureDateController.text.trim().isEmpty
- ? "Required"
- : null;
-
- final returnDateError =
- _returnDateTouched && _returnDateController.text.trim().isEmpty
- ? "Required"
- : null;
-
- final tripLengthError =
- _tripLengthTouched && _tripLengthController.text.trim().isEmpty
- ? "Required"
- : null;
-
- final travelBudgetText = _travelBudgetController.text.trim();
- final travelBudgetVal = int.tryParse(travelBudgetText);
- final travelBudgetError =
- _travelBudgetTouched &&
- (travelBudgetText.isEmpty ||
- travelBudgetVal == null ||
- travelBudgetVal < 1000)
- ? "Minimum budget is 1,000 EUR"
- : null;
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (!isDesktop)
- StepRow(
- count: 4,
- current: 3,
- width: MediaQuery.of(context).size.width - 32,
- ),
- if (!isDesktop) const SizedBox(height: 14),
- Text(
- "Travel request",
- style: isDesktop
- ? STextStyles.desktopH2(context)
- : STextStyles.pageTitleH1(context),
- ),
- SizedBox(height: isDesktop ? 16 : 8),
- Text(
- "Tell us about your trip and we'll arrange everything.",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- SizedBox(height: isDesktop ? 32 : 24),
-
- // === Trip Type ===
- Text(
- "Trip type",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelDropdown(
- value: _selectedArrangement,
- items: const [
- "Flights Only",
- "Hotels Only",
- "Flights + Hotels",
- "Full Service",
- ],
- hint: "Arrangement type",
- onChanged: (val) => setState(() => _selectedArrangement = val),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- TextField(
- controller: _arrangementDetailsController,
- focusNode: _arrangementDetailsFocusNode,
- minLines: 3,
- maxLines: 6,
- autocorrect: false,
- enableSuggestions: false,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Describe your specific requirements "
- "(luggage, cabin class, hotel stars, etc.)",
- _arrangementDetailsFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText:
- _arrangementDetailsTouched &&
- _arrangementDetailsController.text.trim().length < 10
- ? "Minimum 10 characters"
- : null,
- ),
- ),
-
- // === Where ===
- SizedBox(height: isDesktop ? 24 : 16),
- Text(
- "Where",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildDepartureCountryPicker(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
- TextField(
- controller: _departureCityController,
- focusNode: _departureCityFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Departure city",
- _departureCityFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: departureCityError,
- ),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- TextField(
- controller: _destinationsController,
- focusNode: _destinationsFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- enabled: !_needsRecommendations,
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "e.g. Paris, France; Rome, Italy",
- _destinationsFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: destinationsError,
- ),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- GestureDetector(
- onTap: () {
- setState(() {
- _needsRecommendations = !_needsRecommendations;
- });
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: IgnorePointer(
- child: Checkbox(
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- value: _needsRecommendations,
- onChanged: (_) {},
- ),
- ),
- ),
- const SizedBox(width: 12),
- Text(
- "I need recommendations",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- ],
- ),
- ),
- ),
-
- // === When ===
- SizedBox(height: isDesktop ? 24 : 16),
- Text(
- "When",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelDropdown(
- value: _selectedDateMode,
- items: const ["Exact dates", "Flexible dates"],
- hint: "Date mode",
- onChanged: (val) => setState(() => _selectedDateMode = val),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 16 : 12),
-
- if (_selectedDateMode == "Exact dates") ...[
- TextField(
- controller: _departureDateController,
- focusNode: _departureDateFocusNode,
- readOnly: true,
- onTap: () async {
- final picked = await showDatePicker(
- context: context,
- initialDate: DateTime.now(),
- firstDate: DateTime.now(),
- lastDate: DateTime.now().add(const Duration(days: 3650)),
- );
- if (picked != null) {
- final formatted =
- "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}";
- setState(() {
- _departureDateController.text = formatted;
- _departureDateTouched = true;
- });
- }
- },
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "DD/MM/YYYY",
- _departureDateFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- labelText: "Departure date",
- suffixIcon: const Icon(Icons.calendar_today, size: 18),
- errorText: departureDateError,
- ),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- TextField(
- controller: _returnDateController,
- focusNode: _returnDateFocusNode,
- readOnly: true,
- onTap: () async {
- final picked = await showDatePicker(
- context: context,
- initialDate: DateTime.now(),
- firstDate: DateTime.now(),
- lastDate: DateTime.now().add(const Duration(days: 3650)),
- );
- if (picked != null) {
- final formatted =
- "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}";
- setState(() {
- _returnDateController.text = formatted;
- _returnDateTouched = true;
- });
- }
- },
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "DD/MM/YYYY",
- _returnDateFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- labelText: "Return date",
- suffixIcon: const Icon(Icons.calendar_today, size: 18),
- errorText: returnDateError,
- ),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- _buildTravelDropdown(
- value: _selectedFlexibility,
- items: const [
- "Exact",
- "\u00B1 1 day",
- "\u00B1 2-3 days",
- "+ 1 week",
- ],
- hint: "Flexibility",
- onChanged: (val) => setState(() => _selectedFlexibility = val),
- isDesktop: isDesktop,
- ),
- ],
-
- if (_selectedDateMode == "Flexible dates") ...[
- _buildTravelDropdown(
- value: _selectedYear,
- items: ["${DateTime.now().year}", "${DateTime.now().year + 1}"],
- hint: "Year",
- onChanged: (val) => setState(() => _selectedYear = val),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- _buildTravelDropdown(
- value: _selectedMonthSeason,
- items: const [
- "January",
- "February",
- "March",
- "April",
- "May",
- "June",
- "July",
- "August",
- "September",
- "October",
- "November",
- "December",
- ],
- hint: "Month or season",
- onChanged: (val) => setState(() => _selectedMonthSeason = val),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- TextField(
- controller: _tripLengthController,
- focusNode: _tripLengthFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- keyboardType: TextInputType.number,
- inputFormatters: [FilteringTextInputFormatter.digitsOnly],
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Number of nights",
- _tripLengthFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- errorText: tripLengthError,
- ),
- ),
- ],
-
- // === Who ===
- SizedBox(height: isDesktop ? 24 : 16),
- Text(
- "Who",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelerCounter(
- label: "Adults",
- value: _adults,
- min: 1,
- max: 20,
- onChanged: (v) => setState(() => _adults = v),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelerCounter(
- label: "Children",
- value: _children,
- min: 0,
- max: 20,
- onChanged: (v) => setState(() => _children = v),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelerCounter(
- label: "Infants",
- value: _infants,
- min: 0,
- max: 20,
- onChanged: (v) => setState(() => _infants = v),
- isDesktop: isDesktop,
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- _buildTravelerCounter(
- label: "Pets",
- value: _pets,
- min: 0,
- max: 20,
- onChanged: (v) => setState(() => _pets = v),
- isDesktop: isDesktop,
- ),
-
- // === Budget ===
- SizedBox(height: isDesktop ? 24 : 16),
- Text(
- "Budget",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.w500_14(context),
- ),
- SizedBox(height: isDesktop ? 12 : 8),
- TextField(
- controller: _travelBudgetController,
- focusNode: _travelBudgetFocusNode,
- autocorrect: false,
- enableSuggestions: false,
- keyboardType: TextInputType.number,
- inputFormatters: [FilteringTextInputFormatter.digitsOnly],
- onChanged: (_) => setState(() {}),
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration:
- standardInputDecoration(
- "Minimum 1000 EUR",
- _travelBudgetFocusNode,
- context,
- desktopMed: isDesktop,
- ).copyWith(
- filled: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 12,
- ),
- suffixText: "EUR",
- errorText: travelBudgetError,
- ),
- ),
+class _ShopInBitStep4MobileShell extends StatelessWidget {
+ const _ShopInBitStep4MobileShell({required this.content});
- // Travel doesn't need delivery country: destinations are in the form.
- SizedBox(height: isDesktop ? 16 : 12),
- _buildPrivacyCheckbox(isDesktop),
- SizedBox(height: isDesktop ? 16 : 12),
- _buildSubmitButton(),
- ],
- );
- }
+ final Widget content;
@override
Widget build(BuildContext context) {
- final isDesktop = Util.isDesktop;
-
- final Widget content;
- switch (widget.model.category) {
- case ShopInBitCategory.concierge:
- content = _buildConciergeContent(isDesktop);
- break;
- case ShopInBitCategory.car:
- content = _buildCarContent(isDesktop);
- break;
- case ShopInBitCategory.travel:
- content = _buildTravelContent(isDesktop);
- break;
- case null:
- content = _buildGenericContent(isDesktop);
- break;
- }
-
- if (isDesktop) {
- return DesktopDialog(
- maxWidth: 580,
- maxHeight: 750,
- child: Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Row(
- children: [
- const AppBarBackButton(isCompact: true, iconSize: 23),
- Text("ShopinBit", style: STextStyles.desktopH3(context)),
- ],
- ),
- const DesktopDialogCloseButton(),
- ],
- ),
- Expanded(
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 32,
- vertical: 16,
- ),
- child: SingleChildScrollView(child: content),
- ),
- ),
- ],
- ),
- );
- }
-
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart
new file mode 100644
index 0000000..bf4da50
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart
@@ -0,0 +1,347 @@
+import "dart:async";
+
+import "package:flutter/material.dart";
+import "package:flutter/services.dart";
+
+import "../../../db/isar/main_db.dart";
+import "../../../models/shopinbit/shopinbit_order_model.dart";
+import "../../../themes/stack_colors.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+import "../../../widgets/rounded_white_container.dart";
+import "../shopinbit_car_fee_view.dart";
+import "../shopinbit_tickets_view.dart";
+import "shopinbit_country_picker.dart";
+import "shopinbit_labeled_checkbox.dart";
+import "shopinbit_privacy_checkbox.dart";
+import "shopinbit_step4_dropdown.dart";
+import "shopinbit_step4_header.dart";
+import "shopinbit_step4_submit_button.dart";
+import "shopinbit_step4_text_field.dart";
+
+const List<String> _carConditions = ["NEW", "PREOWNED"];
+
+const int _minCarBudget = 20000;
+const int _minCarFieldLength = 3;
+
+class ShopInBitCarResearchForm extends StatefulWidget {
+ const ShopInBitCarResearchForm({super.key, required this.model});
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitCarResearchForm> createState() =>
+ _ShopInBitCarResearchFormState();
+}
+
+class _ShopInBitCarResearchFormState extends State<ShopInBitCarResearchForm> {
+ final TextEditingController _brandController = TextEditingController();
+ final FocusNode _brandFocusNode = FocusNode();
+ bool _brandTouched = false;
+
+ final TextEditingController _modelController = TextEditingController();
+ final FocusNode _modelFocusNode = FocusNode();
+ bool _modelTouched = false;
+
+ final TextEditingController _carDescriptionController =
+ TextEditingController();
+ final FocusNode _carDescriptionFocusNode = FocusNode();
+ bool _carDescriptionTouched = false;
+
+ final TextEditingController _carBudgetController = TextEditingController();
+ final FocusNode _carBudgetFocusNode = FocusNode();
+ bool _carBudgetTouched = false;
+
+ String? _selectedCarCondition;
+ bool _feeAcknowledged = false;
+ String? _selectedCountryIso;
+ bool _privacyAccepted = false;
+ bool _submitting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _wireTouchOnBlur(_brandFocusNode, () => _brandTouched = true);
+ _wireTouchOnBlur(_modelFocusNode, () => _modelTouched = true);
+ _wireTouchOnBlur(
+ _carDescriptionFocusNode,
+ () => _carDescriptionTouched = true,
+ );
+ _wireTouchOnBlur(_carBudgetFocusNode, () => _carBudgetTouched = true);
+ if (widget.model.deliveryCountry.isNotEmpty) {
+ _selectedCountryIso = widget.model.deliveryCountry;
+ }
+ }
+
+ void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) {
+ node.addListener(() {
+ if (!node.hasFocus) markTouched();
+ setState(() {});
+ });
+ }
+
+ @override
+ void dispose() {
+ _brandController.dispose();
+ _brandFocusNode.dispose();
+ _modelController.dispose();
+ _modelFocusNode.dispose();
+ _carDescriptionController.dispose();
+ _carDescriptionFocusNode.dispose();
+ _carBudgetController.dispose();
+ _carBudgetFocusNode.dispose();
+ super.dispose();
+ }
+
+ bool get _canContinue {
+ final int? carBudgetValue = int.tryParse(_carBudgetController.text.trim());
+ return !_submitting &&
+ _privacyAccepted &&
+ _feeAcknowledged &&
+ _brandController.text.trim().length >= _minCarFieldLength &&
+ _modelController.text.trim().length >= _minCarFieldLength &&
+ _carDescriptionController.text.trim().length >= _minCarFieldLength &&
+ _selectedCarCondition != null &&
+ carBudgetValue != null &&
+ carBudgetValue >= _minCarBudget &&
+ _selectedCountryIso != null;
+ }
+
+ Future<void> _submit() async {
+ setState(() => _submitting = true);
+ try {
+ final String countryIso = _selectedCountryIso!;
+
+ widget.model
+ ..requestDescription =
+ "Brand: ${_brandController.text.trim()}\n"
+ "Model: ${_modelController.text.trim()}\n"
+ "Condition: $_selectedCarCondition\n"
+ "Description: ${_carDescriptionController.text.trim()}\n"
+ "Budget: ${_carBudgetController.text.trim()} EUR\n"
+ "Delivery country: $countryIso"
+ ..deliveryCountry = countryIso;
+
+ // Block if another car research flow is already in progress.
+ final existingPending = MainDB.instance
+ .getShopInBitTickets()
+ .where((t) => t.isPendingPayment)
+ .toList();
+
+ if (existingPending.isNotEmpty && mounted) {
+ final bool? resumePrevious = await showDialog<bool>(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: const Text("In-Progress Car Research"),
+ content: const Text(
+ "You have an unfinished car research payment. "
+ "Would you like to resume it or start a new search?",
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: const Text("Resume Previous"),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: const Text("Start New"),
+ ),
+ ],
+ ),
+ );
+
+ if (resumePrevious == true && mounted) {
+ unawaited(
+ Navigator.of(context).pushNamedAndRemoveUntil(
+ ShopInBitTicketsView.routeName,
+ (route) => route.isFirst,
+ ),
+ );
+ return;
+ }
+ }
+
+ if (!mounted) return;
+
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitCarFeeView(model: widget.model),
+ ),
+ );
+ } else {
+ unawaited(
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isDesktop = Util.isDesktop;
+
+ final String? brandError =
+ _brandTouched &&
+ _brandController.text.trim().length < _minCarFieldLength
+ ? "Minimum $_minCarFieldLength characters"
+ : null;
+
+ final String? modelError =
+ _modelTouched &&
+ _modelController.text.trim().length < _minCarFieldLength
+ ? "Minimum $_minCarFieldLength characters"
+ : null;
+
+ final String? carDescriptionError =
+ _carDescriptionTouched &&
+ _carDescriptionController.text.trim().length < _minCarFieldLength
+ ? "Minimum $_minCarFieldLength characters"
+ : null;
+
+ final String carBudgetText = _carBudgetController.text.trim();
+ final int? carBudgetValue = int.tryParse(carBudgetText);
+ final String? carBudgetError =
+ _carBudgetTouched &&
+ (carBudgetText.isEmpty ||
+ carBudgetValue == null ||
+ carBudgetValue < _minCarBudget)
+ ? "Minimum budget is 20,000\u20AC"
+ : null;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const ShopInBitStep4Header(
+ title: "Car Research request",
+ subtitle: "Tell us about the car you're looking for.",
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+ ShopInBitCountryPicker(
+ selectedIso: _selectedCountryIso,
+ onChanged: (iso) => setState(() => _selectedCountryIso = iso),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4TextField(
+ controller: _brandController,
+ focusNode: _brandFocusNode,
+ hintText: "Car brand (e.g., BMW, Mercedes, Toyota...)",
+ errorText: brandError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4TextField(
+ controller: _modelController,
+ focusNode: _modelFocusNode,
+ hintText: "Car model (e.g., 3 Series, E-Class, Camry...)",
+ errorText: modelError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4Dropdown(
+ value: _selectedCarCondition,
+ items: _carConditions,
+ hintText: "Condition",
+ onChanged: (value) => setState(() => _selectedCarCondition = value),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4TextField(
+ controller: _carDescriptionController,
+ focusNode: _carDescriptionFocusNode,
+ hintText:
+ "Describe your requirements "
+ "(year, mileage, features...)",
+ minLines: 3,
+ maxLines: 6,
+ errorText: carDescriptionError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4TextField(
+ controller: _carBudgetController,
+ focusNode: _carBudgetFocusNode,
+ hintText: "Budget (\u20AC, minimum 20,000)",
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ suffixText: "\u20AC",
+ errorText: carBudgetError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ _CarResearchFeeInfo(isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitLabeledCheckbox(
+ value: _feeAcknowledged,
+ onChanged: (v) => setState(() => _feeAcknowledged = v),
+ label: "I acknowledge the \u20AC223 research fee",
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitPrivacyCheckbox(
+ value: _privacyAccepted,
+ onChanged: (v) => setState(() => _privacyAccepted = v),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4SubmitButton(
+ submitting: _submitting,
+ enabled: _canContinue,
+ onPressed: _submit,
+ ),
+ ],
+ );
+ }
+}
+
+/// Info box showing the €223 (incl. VAT) research fee disclosure.
+class _CarResearchFeeInfo extends StatelessWidget {
+ const _CarResearchFeeInfo({required this.isDesktop});
+
+ final bool isDesktop;
+
+ @override
+ Widget build(BuildContext context) {
+ final TextStyle baseStyle = isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.w500_14(context);
+
+ return RoundedWhiteContainer(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.info_outline,
+ size: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconLeft,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ style: baseStyle,
+ children: [
+ TextSpan(
+ text: "Research fee: ",
+ style: baseStyle.copyWith(fontWeight: FontWeight.bold),
+ ),
+ const TextSpan(
+ text:
+ "\u20AC223 (incl. VAT): one-time payment, "
+ "credited toward your purchase.",
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart
new file mode 100644
index 0000000..282d826
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart
@@ -0,0 +1,191 @@
+import "package:flutter/material.dart";
+import "package:flutter/services.dart";
+
+import "../../../models/shopinbit/shopinbit_order_model.dart";
+import "../../../utilities/util.dart";
+import "shopinbit_country_picker.dart";
+import "shopinbit_labeled_checkbox.dart";
+import "shopinbit_privacy_checkbox.dart";
+import "shopinbit_step4_dropdown.dart";
+import "shopinbit_step4_header.dart";
+import "shopinbit_step4_submit.dart";
+import "shopinbit_step4_submit_button.dart";
+import "shopinbit_step4_text_field.dart";
+
+const List<String> _conciergeConditions = ["NEW", "USED"];
+
+const int _minConciergeBudget = 1000;
+const int _maxConciergeBudget = 100000;
+
+class ShopInBitConciergeForm extends StatefulWidget {
+ const ShopInBitConciergeForm({super.key, required this.model});
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitConciergeForm> createState() => _ShopInBitConciergeFormState();
+}
+
+class _ShopInBitConciergeFormState extends State<ShopInBitConciergeForm> {
+ final TextEditingController _whatToPurchaseController =
+ TextEditingController();
+ final FocusNode _whatToPurchaseFocusNode = FocusNode();
+ bool _whatToPurchaseTouched = false;
+
+ final TextEditingController _budgetController = TextEditingController(
+ text: "1000",
+ );
+ final FocusNode _budgetFocusNode = FocusNode();
+ bool _budgetTouched = false;
+
+ String? _selectedCondition;
+ bool _noLimit = false;
+ String? _selectedCountryIso;
+ bool _privacyAccepted = false;
+ bool _submitting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _whatToPurchaseFocusNode.addListener(() {
+ if (!_whatToPurchaseFocusNode.hasFocus) _whatToPurchaseTouched = true;
+ setState(() {});
+ });
+ _budgetFocusNode.addListener(() {
+ if (!_budgetFocusNode.hasFocus) _budgetTouched = true;
+ setState(() {});
+ });
+ if (widget.model.deliveryCountry.isNotEmpty) {
+ _selectedCountryIso = widget.model.deliveryCountry;
+ }
+ }
+
+ @override
+ void dispose() {
+ _whatToPurchaseController.dispose();
+ _whatToPurchaseFocusNode.dispose();
+ _budgetController.dispose();
+ _budgetFocusNode.dispose();
+ super.dispose();
+ }
+
+ bool get _budgetIsValid {
+ final String text = _budgetController.text.trim();
+ if (text.isEmpty) return false;
+ final int? value = int.tryParse(text);
+ return value != null &&
+ value >= _minConciergeBudget &&
+ value <= _maxConciergeBudget;
+ }
+
+ bool get _canContinue =>
+ !_submitting &&
+ _privacyAccepted &&
+ _whatToPurchaseController.text.trim().length >= 10 &&
+ _selectedCondition != null &&
+ (_noLimit || _budgetIsValid) &&
+ _selectedCountryIso != null;
+
+ Future<void> _submit() async {
+ setState(() => _submitting = true);
+
+ final String countryIso = _selectedCountryIso!;
+ final String budgetText = _noLimit
+ ? "No limit"
+ : "${_budgetController.text.trim()} EUR";
+
+ widget.model
+ ..requestDescription =
+ "What to purchase: ${_whatToPurchaseController.text.trim()}\n"
+ "Condition: $_selectedCondition\n"
+ "Budget: $budgetText\n"
+ "Delivery country: $countryIso"
+ ..deliveryCountry = countryIso;
+
+ try {
+ await submitShopInBitRequest(context, widget.model);
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isDesktop = Util.isDesktop;
+
+ final String? whatToPurchaseError =
+ _whatToPurchaseTouched &&
+ _whatToPurchaseController.text.trim().length < 10
+ ? "Minimum 10 characters"
+ : null;
+
+ final String? budgetError = _budgetTouched && !_noLimit && !_budgetIsValid
+ ? "Enter a value between 1,000 and 100,000"
+ : null;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const ShopInBitStep4Header(
+ title: "What would you like to purchase?",
+ subtitle:
+ "Tell us what you're looking for and we'll find it "
+ "for you.",
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _whatToPurchaseController,
+ focusNode: _whatToPurchaseFocusNode,
+ hintText:
+ "Describe what you'd like to purchase "
+ "(e.g., electronics, luxury goods, services...)",
+ minLines: 3,
+ maxLines: 6,
+ errorText: whatToPurchaseError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4Dropdown(
+ value: _selectedCondition,
+ items: _conciergeConditions,
+ hintText: "Condition",
+ onChanged: (value) => setState(() => _selectedCondition = value),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitStep4TextField(
+ controller: _budgetController,
+ focusNode: _budgetFocusNode,
+ hintText: "Budget (\u20AC)",
+ enabled: !_noLimit,
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ suffixText: "\u20AC",
+ errorText: budgetError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitLabeledCheckbox(
+ value: _noLimit,
+ onChanged: (v) => setState(() => _noLimit = v),
+ label: "No budget limit",
+ ),
+ SizedBox(height: isDesktop ? 12 : 12),
+ ShopInBitCountryPicker(
+ selectedIso: _selectedCountryIso,
+ onChanged: (iso) => setState(() => _selectedCountryIso = iso),
+ ),
+ SizedBox(height: isDesktop ? 12 : 12),
+ ShopInBitPrivacyCheckbox(
+ value: _privacyAccepted,
+ onChanged: (v) => setState(() => _privacyAccepted = v),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4SubmitButton(
+ submitting: _submitting,
+ enabled: _canContinue,
+ onPressed: _submit,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart
new file mode 100644
index 0000000..f5ae405
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart
@@ -0,0 +1,164 @@
+import "package:dropdown_button2/dropdown_button2.dart";
+import "package:flutter/material.dart";
+import "package:flutter_svg/svg.dart";
+
+import "../../../services/shopinbit/shopinbit_service.dart";
+import "../../../themes/stack_colors.dart";
+import "../../../utilities/assets.dart";
+import "../../../utilities/constants.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+
+class ShopInBitCountryPicker extends StatefulWidget {
+ const ShopInBitCountryPicker({
+ super.key,
+ required this.selectedIso,
+ required this.onChanged,
+ this.hintText = "Delivery country",
+ });
+
+ final String? selectedIso;
+ final ValueChanged<String?> onChanged;
+ final String hintText;
+
+ @override
+ State<ShopInBitCountryPicker> createState() => _ShopInBitCountryPickerState();
+}
+
+class _ShopInBitCountryPickerState extends State<ShopInBitCountryPicker> {
+ final TextEditingController _searchController = TextEditingController();
+ List<Map<String, dynamic>> _countries = [];
+ bool _loading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _fetchCountries();
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ Future<void> _fetchCountries() async {
+ setState(() => _loading = true);
+ try {
+ final resp = await ShopInBitService.instance.client.getCountries();
+ if (resp.hasError || resp.value == null) return;
+ _countries = resp.value!;
+ if (widget.selectedIso != null &&
+ !_countries.any((c) => c["iso"] == widget.selectedIso)) {
+ widget.onChanged(null);
+ }
+ } catch (_) {
+ // Leave list empty; user will see no items.
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final StackColors stackColors = Theme.of(context).extension<StackColors>()!;
+
+ final TextStyle itemStyle = Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(
+ context,
+ ).copyWith(color: stackColors.textFieldActiveText)
+ : STextStyles.w500_14(context);
+
+ final TextStyle hintStyle = Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(
+ context,
+ ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft)
+ : STextStyles.fieldLabel(context);
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
+ child: DropdownButtonHideUnderline(
+ child: DropdownButton2<String>(
+ value: widget.selectedIso,
+ items: _countries
+ .map(
+ (c) => DropdownMenuItem<String>(
+ value: c["iso"] as String,
+ child: Text(c["label"] as String, style: itemStyle),
+ ),
+ )
+ .toList(),
+ onMenuStateChange: (isOpen) {
+ if (!isOpen) {
+ _searchController.clear();
+ }
+ },
+ onChanged: _loading ? null : widget.onChanged,
+ hint: Text(
+ _loading ? "Loading countries..." : widget.hintText,
+ style: hintStyle,
+ ),
+ isExpanded: true,
+ buttonStyleData: ButtonStyleData(
+ decoration: BoxDecoration(
+ color: 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: stackColors.textFieldActiveSearchIconRight,
+ ),
+ ),
+ ),
+ dropdownStyleData: DropdownStyleData(
+ offset: const Offset(0, 0),
+ elevation: 0,
+ maxHeight: 300,
+ decoration: BoxDecoration(
+ color: stackColors.textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ ),
+ dropdownSearchData: DropdownSearchData<String>(
+ searchController: _searchController,
+ searchInnerWidgetHeight: 48,
+ searchInnerWidget: TextFormField(
+ controller: _searchController,
+ 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 String? 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),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart
new file mode 100644
index 0000000..f31741f
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart
@@ -0,0 +1,112 @@
+import "package:flutter/material.dart";
+
+import "../../../models/shopinbit/shopinbit_order_model.dart";
+import "../../../utilities/util.dart";
+import "shopinbit_country_picker.dart";
+import "shopinbit_privacy_checkbox.dart";
+import "shopinbit_step4_header.dart";
+import "shopinbit_step4_submit.dart";
+import "shopinbit_step4_submit_button.dart";
+import "shopinbit_step4_text_field.dart";
+
+/// Fallback Step 4 form used when no category was selected. Collects a free
+/// text description and a delivery country.
+///
+/// Note: the original code used the travel copy for this fallback; that
+/// behaviour is preserved here.
+class ShopInBitGenericForm extends StatefulWidget {
+ const ShopInBitGenericForm({super.key, required this.model});
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitGenericForm> createState() => _ShopInBitGenericFormState();
+}
+
+class _ShopInBitGenericFormState extends State<ShopInBitGenericForm> {
+ late final TextEditingController _descriptionController;
+ final FocusNode _descriptionFocusNode = FocusNode();
+
+ String? _selectedCountryIso;
+ bool _privacyAccepted = false;
+ bool _submitting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _descriptionController = TextEditingController(
+ text: widget.model.requestDescription,
+ );
+ _descriptionFocusNode.addListener(() => setState(() {}));
+
+ if (widget.model.deliveryCountry.isNotEmpty) {
+ _selectedCountryIso = widget.model.deliveryCountry;
+ }
+ }
+
+ @override
+ void dispose() {
+ _descriptionController.dispose();
+ _descriptionFocusNode.dispose();
+ super.dispose();
+ }
+
+ bool get _canContinue =>
+ !_submitting &&
+ _privacyAccepted &&
+ _descriptionController.text.trim().isNotEmpty &&
+ _selectedCountryIso != null;
+
+ Future<void> _submit() async {
+ setState(() => _submitting = true);
+ widget.model
+ ..requestDescription = _descriptionController.text.trim()
+ ..deliveryCountry = _selectedCountryIso!;
+ try {
+ await submitShopInBitRequest(context, widget.model);
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isDesktop = Util.isDesktop;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const ShopInBitStep4Header(
+ title: "Describe your travel request",
+ subtitle: "Provide details about your trip.",
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+ ShopInBitStep4TextField(
+ controller: _descriptionController,
+ focusNode: _descriptionFocusNode,
+ hintText:
+ "Describe your travel request (destinations, dates, passengers)",
+ minLines: 3,
+ maxLines: 6,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ ShopInBitCountryPicker(
+ selectedIso: _selectedCountryIso,
+ onChanged: (iso) => setState(() => _selectedCountryIso = iso),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitPrivacyCheckbox(
+ value: _privacyAccepted,
+ onChanged: (v) => setState(() => _privacyAccepted = v),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4SubmitButton(
+ submitting: _submitting,
+ enabled: _canContinue,
+ onPressed: _submit,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart
new file mode 100644
index 0000000..6f4014f
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart
@@ -0,0 +1,49 @@
+import "package:flutter/material.dart";
+
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+
+class ShopInBitLabeledCheckbox extends StatelessWidget {
+ const ShopInBitLabeledCheckbox({
+ super.key,
+ required this.value,
+ required this.onChanged,
+ required this.label,
+ });
+
+ final bool value;
+ final ValueChanged<bool> onChanged;
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ final TextStyle labelStyle = Util.isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.w500_14(context);
+
+ return GestureDetector(
+ onTap: () => onChanged(!value),
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: IgnorePointer(
+ child: Checkbox(
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ value: value,
+ onChanged: (_) {},
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(child: Text(label, style: labelStyle)),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart
new file mode 100644
index 0000000..72d9505
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart
@@ -0,0 +1,163 @@
+import "package:flutter/gestures.dart";
+import "package:flutter/material.dart";
+import "package:url_launcher/url_launcher.dart";
+
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+import "../../../widgets/desktop/desktop_dialog.dart";
+import "../../../widgets/desktop/primary_button.dart";
+import "../../../widgets/desktop/secondary_button.dart";
+import "../../../widgets/stack_dialog.dart";
+
+const String _shopInBitPrivacyUrl =
+ "https://api.shopinbit.com/static/policy/privacy.html";
+
+class ShopInBitPrivacyCheckbox extends StatelessWidget {
+ const ShopInBitPrivacyCheckbox({
+ super.key,
+ required this.value,
+ required this.onChanged,
+ });
+
+ final bool value;
+ final ValueChanged<bool> onChanged;
+
+ Future<void> _openPrivacyPolicy(BuildContext context) async {
+ final bool shouldOpen = await _showOpenBrowserWarning(
+ context,
+ _shopInBitPrivacyUrl,
+ );
+ if (shouldOpen) {
+ await launchUrl(
+ Uri.parse(_shopInBitPrivacyUrl),
+ mode: LaunchMode.externalApplication,
+ );
+ }
+ }
+
+ Future<bool> _showOpenBrowserWarning(BuildContext context, String url) async {
+ final Uri uri = Uri.parse(url);
+ final String message =
+ "You are about to open ${uri.scheme}://${uri.host} in your browser.";
+
+ final bool? shouldContinue = await showDialog<bool>(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) => Util.isDesktop
+ ? _DesktopBrowserWarning(message: message)
+ : StackDialog(
+ title: "Attention",
+ message: message,
+ leftButton: SecondaryButton(
+ label: "Cancel",
+ onPressed: () => Navigator.of(context).pop(false),
+ ),
+ rightButton: PrimaryButton(
+ label: "Continue",
+ onPressed: () => Navigator.of(context).pop(true),
+ ),
+ ),
+ );
+ return shouldContinue ?? false;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ return GestureDetector(
+ onTap: () => onChanged(!value),
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: isDesktop
+ ? CrossAxisAlignment.center
+ : CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: EdgeInsets.only(top: isDesktop ? 3 : 0),
+ child: SizedBox(
+ width: 20,
+ height: 20,
+ child: IgnorePointer(
+ child: Checkbox(
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ value: value,
+ onChanged: (_) {},
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.w500_14(context),
+ children: [
+ const TextSpan(
+ text: "I have read and agree to the ShopinBit ",
+ ),
+ TextSpan(
+ text: "Privacy Policy",
+ style: STextStyles.richLink(
+ context,
+ ).copyWith(fontSize: isDesktop ? 18 : 14),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => _openPrivacyPolicy(context),
+ ),
+ const TextSpan(text: "."),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _DesktopBrowserWarning extends StatelessWidget {
+ const _DesktopBrowserWarning({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return DesktopDialog(
+ maxWidth: 550,
+ maxHeight: 250,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
+ child: Column(
+ children: [
+ Text("Attention", style: STextStyles.desktopH2(context)),
+ const SizedBox(height: 16),
+ Text(message, style: STextStyles.desktopTextSmall(context)),
+ const SizedBox(height: 35),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SecondaryButton(
+ width: 200,
+ buttonHeight: ButtonHeight.l,
+ label: "Cancel",
+ onPressed: () => Navigator.of(context).pop(false),
+ ),
+ const SizedBox(width: 20),
+ PrimaryButton(
+ width: 200,
+ buttonHeight: ButtonHeight.l,
+ label: "Continue",
+ onPressed: () => Navigator.of(context).pop(true),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart
new file mode 100644
index 0000000..baae092
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart
@@ -0,0 +1,93 @@
+import "package:dropdown_button2/dropdown_button2.dart";
+import "package:flutter/material.dart";
+import "package:flutter_svg/svg.dart";
+
+import "../../../themes/stack_colors.dart";
+import "../../../utilities/assets.dart";
+import "../../../utilities/constants.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+
+class ShopInBitStep4Dropdown extends StatelessWidget {
+ const ShopInBitStep4Dropdown({
+ super.key,
+ required this.value,
+ required this.items,
+ required this.hintText,
+ required this.onChanged,
+ });
+
+ final String? value;
+ final List<String> items;
+ final String hintText;
+ final ValueChanged<String?> onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final stackColors = Theme.of(context).extension<StackColors>()!;
+
+ final itemStyle = Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(
+ context,
+ ).copyWith(color: stackColors.textFieldActiveText)
+ : STextStyles.w500_14(context);
+
+ final hintStyle = Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(
+ context,
+ ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft)
+ : STextStyles.fieldLabel(context);
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
+ child: DropdownButtonHideUnderline(
+ child: DropdownButton2<String>(
+ value: value,
+ items: items
+ .map(
+ (item) => DropdownMenuItem<String>(
+ value: item,
+ child: Text(item, style: itemStyle),
+ ),
+ )
+ .toList(),
+ onChanged: onChanged,
+ hint: Text(hintText, style: hintStyle),
+ isExpanded: true,
+ buttonStyleData: ButtonStyleData(
+ decoration: BoxDecoration(
+ color: 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: stackColors.textFieldActiveSearchIconRight,
+ ),
+ ),
+ ),
+ dropdownStyleData: DropdownStyleData(
+ offset: const Offset(0, 0),
+ elevation: 0,
+ decoration: BoxDecoration(
+ color: stackColors.textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ ),
+ menuItemStyleData: const MenuItemStyleData(
+ padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart
new file mode 100644
index 0000000..4c81d7d
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart
@@ -0,0 +1,46 @@
+import "package:flutter/material.dart";
+
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+import "../../exchange_view/sub_widgets/step_row.dart";
+
+class ShopInBitStep4Header extends StatelessWidget {
+ const ShopInBitStep4Header({
+ super.key,
+ required this.title,
+ required this.subtitle,
+ });
+
+ final String title;
+ final String subtitle;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!Util.isDesktop) ...[
+ StepRow(
+ count: 4,
+ current: 3,
+ width: MediaQuery.of(context).size.width - 32,
+ ),
+ const SizedBox(height: 14),
+ ],
+ Text(
+ title,
+ style: Util.isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: Util.isDesktop ? 16 : 8),
+ Text(
+ subtitle,
+ style: Util.isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart
new file mode 100644
index 0000000..ede1424
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart
@@ -0,0 +1,96 @@
+import "dart:async";
+
+import "package:flutter/material.dart";
+
+import "../../../db/isar/main_db.dart";
+import "../../../models/shopinbit/shopinbit_order_model.dart";
+import "../../../notifications/show_flush_bar.dart";
+import "../../../services/shopinbit/shopinbit_service.dart";
+import "../../../utilities/util.dart";
+import "../shopinbit_order_created.dart";
+
+/// Submits a ShopinBit request to the API and navigates to the order-created
+/// view on success.
+///
+/// Used by the concierge, travel and generic flows. The car flow has its own
+/// pre-payment branching (fee view) and does not call this helper.
+Future<void> submitShopInBitRequest(
+ BuildContext context,
+ ShopInBitOrderModel model,
+) async {
+ try {
+ final ShopInBitService service = ShopInBitService.instance;
+ final String customerKey = await service.ensureCustomerKey();
+
+ assert(
+ model.category != null,
+ "Step 4 reached with null category: Step 2 must set category before"
+ " reaching Step 4",
+ );
+
+ // API service_type: travel requests use "concierge" because the
+ // ShopinBit API routes both through the same concierge pipeline.
+ // Travel-specific details are captured in the structured comment field.
+ final String categoryStr = switch (model.category) {
+ ShopInBitCategory.concierge => "concierge",
+ ShopInBitCategory.travel => "concierge",
+ ShopInBitCategory.car => "car",
+ null => throw StateError("category must be non-null at Step 4 submit"),
+ };
+
+ final resp = await service.client.createRequest(
+ customerPseudonym: model.displayName,
+ externalCustomerKey: customerKey,
+ serviceType: categoryStr,
+ comment: model.requestDescription,
+ deliveryCountry: model.deliveryCountry,
+ );
+
+ if (resp.hasError) {
+ if (context.mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: resp.exception?.message ?? "Failed to create request",
+ context: context,
+ ),
+ );
+ }
+ return;
+ }
+
+ final ref = resp.value!;
+ model
+ ..apiTicketId = ref.id
+ ..ticketId = ref.number
+ ..status = ShopInBitOrderStatus.pending;
+ await MainDB.instance.putShopInBitTicket(model.toIsarTicket());
+
+ if (!context.mounted) return;
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitOrderCreated(model: model),
+ ),
+ );
+ } else {
+ unawaited(
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitOrderCreated.routeName, arguments: model),
+ );
+ }
+ } catch (e) {
+ if (context.mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Failed to create request: $e",
+ context: context,
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart
new file mode 100644
index 0000000..ac38c46
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart
@@ -0,0 +1,25 @@
+import "package:flutter/material.dart";
+
+import "../../../widgets/desktop/primary_button.dart";
+
+class ShopInBitStep4SubmitButton extends StatelessWidget {
+ const ShopInBitStep4SubmitButton({
+ super.key,
+ required this.submitting,
+ required this.enabled,
+ required this.onPressed,
+ });
+
+ final bool submitting;
+ final bool enabled;
+ final VoidCallback? onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ return PrimaryButton(
+ label: submitting ? "Submitting..." : "Submit request",
+ enabled: enabled,
+ onPressed: enabled ? onPressed : null,
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart
new file mode 100644
index 0000000..7cdc97a
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart
@@ -0,0 +1,89 @@
+import "package:flutter/material.dart";
+import "package:flutter/services.dart";
+
+import "../../../themes/stack_colors.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+import "../../../widgets/stack_text_field.dart";
+
+class ShopInBitStep4TextField extends StatelessWidget {
+ const ShopInBitStep4TextField({
+ super.key,
+ required this.controller,
+ required this.focusNode,
+ required this.hintText,
+ this.errorText,
+ this.minLines,
+ this.maxLines = 1,
+ this.keyboardType,
+ this.inputFormatters,
+ this.enabled = true,
+ this.suffixText,
+ this.suffixIcon,
+ this.labelText,
+ this.readOnly = false,
+ this.onTap,
+ this.onChanged,
+ });
+
+ final TextEditingController controller;
+ final FocusNode focusNode;
+ final String hintText;
+ final String? errorText;
+ final int? minLines;
+ final int? maxLines;
+ final TextInputType? keyboardType;
+ final List<TextInputFormatter>? inputFormatters;
+ final bool enabled;
+ final String? suffixText;
+ final Widget? suffixIcon;
+ final String? labelText;
+ final bool readOnly;
+ final VoidCallback? onTap;
+ final ValueChanged<String>? onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final TextStyle style = Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
+ height: 1.8,
+ )
+ : STextStyles.field(context);
+
+ return TextField(
+ controller: controller,
+ focusNode: focusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ enabled: enabled,
+ readOnly: readOnly,
+ onTap: onTap,
+ minLines: minLines,
+ maxLines: maxLines,
+ keyboardType: keyboardType,
+ inputFormatters: inputFormatters,
+ onChanged: onChanged,
+ style: style,
+ decoration:
+ standardInputDecoration(
+ hintText,
+ focusNode,
+ context,
+ desktopMed: Util.isDesktop,
+ ).copyWith(
+ filled: true,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ errorText: errorText,
+ suffixText: suffixText,
+ suffixIcon: suffixIcon,
+ labelText: labelText,
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart
new file mode 100644
index 0000000..bfddd18
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart
@@ -0,0 +1,544 @@
+import "package:flutter/material.dart";
+import "package:flutter/services.dart";
+
+import "../../../models/shopinbit/shopinbit_order_model.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+import "shopinbit_country_picker.dart";
+import "shopinbit_labeled_checkbox.dart";
+import "shopinbit_privacy_checkbox.dart";
+import "shopinbit_step4_dropdown.dart";
+import "shopinbit_step4_header.dart";
+import "shopinbit_step4_submit.dart";
+import "shopinbit_step4_submit_button.dart";
+import "shopinbit_step4_text_field.dart";
+import "shopinbit_traveler_counter.dart";
+
+const String _exactDates = "Exact dates";
+const String _flexibleDates = "Flexible dates";
+
+const List<String> _arrangements = [
+ "Flights Only",
+ "Hotels Only",
+ "Flights + Hotels",
+ "Full Service",
+];
+
+const List<String> _dateModes = [_exactDates, _flexibleDates];
+
+const List<String> _flexibilities = [
+ "Exact",
+ "\u00B1 1 day",
+ "\u00B1 2-3 days",
+ "+ 1 week",
+];
+
+const List<String> _months = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+const int _minTravelBudget = 1000;
+const int _minArrangementDetailsLength = 10;
+
+/// Travel request form. Collects arrangement type, departure / destinations,
+/// dates (either exact or flexible), travelers and budget, then submits via
+/// the shared submit helper.
+class ShopInBitTravelForm extends StatefulWidget {
+ const ShopInBitTravelForm({super.key, required this.model});
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitTravelForm> createState() => _ShopInBitTravelFormState();
+}
+
+class _ShopInBitTravelFormState extends State<ShopInBitTravelForm> {
+ final TextEditingController _arrangementDetailsController =
+ TextEditingController();
+ final FocusNode _arrangementDetailsFocusNode = FocusNode();
+ bool _arrangementDetailsTouched = false;
+
+ final TextEditingController _departureCityController =
+ TextEditingController();
+ final FocusNode _departureCityFocusNode = FocusNode();
+ bool _departureCityTouched = false;
+
+ final TextEditingController _destinationsController = TextEditingController();
+ final FocusNode _destinationsFocusNode = FocusNode();
+ bool _destinationsTouched = false;
+
+ final TextEditingController _departureDateController =
+ TextEditingController();
+ final FocusNode _departureDateFocusNode = FocusNode();
+ bool _departureDateTouched = false;
+
+ final TextEditingController _returnDateController = TextEditingController();
+ final FocusNode _returnDateFocusNode = FocusNode();
+ bool _returnDateTouched = false;
+
+ final TextEditingController _tripLengthController = TextEditingController();
+ final FocusNode _tripLengthFocusNode = FocusNode();
+ bool _tripLengthTouched = false;
+
+ final TextEditingController _travelBudgetController = TextEditingController(
+ text: "5000",
+ );
+ final FocusNode _travelBudgetFocusNode = FocusNode();
+ bool _travelBudgetTouched = false;
+
+ String? _selectedArrangement;
+ String? _selectedDepartureCountryIso;
+ String? _selectedDateMode;
+ String? _selectedFlexibility;
+ String? _selectedYear;
+ String? _selectedMonthSeason;
+ bool _needsRecommendations = false;
+
+ int _adults = 1;
+ int _children = 0;
+ int _infants = 0;
+ int _pets = 0;
+
+ bool _privacyAccepted = false;
+ bool _submitting = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _wireTouchOnBlur(
+ _arrangementDetailsFocusNode,
+ () => _arrangementDetailsTouched = true,
+ );
+ _wireTouchOnBlur(
+ _departureCityFocusNode,
+ () => _departureCityTouched = true,
+ );
+ _wireTouchOnBlur(_destinationsFocusNode, () => _destinationsTouched = true);
+ _wireTouchOnBlur(
+ _departureDateFocusNode,
+ () => _departureDateTouched = true,
+ );
+ _wireTouchOnBlur(_returnDateFocusNode, () => _returnDateTouched = true);
+ _wireTouchOnBlur(_tripLengthFocusNode, () => _tripLengthTouched = true);
+ _wireTouchOnBlur(_travelBudgetFocusNode, () => _travelBudgetTouched = true);
+ }
+
+ void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) {
+ node.addListener(() {
+ if (!node.hasFocus) markTouched();
+ setState(() {});
+ });
+ }
+
+ @override
+ void dispose() {
+ _arrangementDetailsController.dispose();
+ _arrangementDetailsFocusNode.dispose();
+ _departureCityController.dispose();
+ _departureCityFocusNode.dispose();
+ _destinationsController.dispose();
+ _destinationsFocusNode.dispose();
+ _departureDateController.dispose();
+ _departureDateFocusNode.dispose();
+ _returnDateController.dispose();
+ _returnDateFocusNode.dispose();
+ _tripLengthController.dispose();
+ _tripLengthFocusNode.dispose();
+ _travelBudgetController.dispose();
+ _travelBudgetFocusNode.dispose();
+ super.dispose();
+ }
+
+ bool get _hasValidDates => switch (_selectedDateMode) {
+ _flexibleDates =>
+ _selectedYear != null &&
+ _selectedMonthSeason != null &&
+ _tripLengthController.text.trim().isNotEmpty,
+ _exactDates =>
+ _departureDateController.text.trim().isNotEmpty &&
+ _returnDateController.text.trim().isNotEmpty,
+ _ => false,
+ };
+
+ bool get _canContinue {
+ final int? travelBudgetValue = int.tryParse(
+ _travelBudgetController.text.trim(),
+ );
+ return !_submitting &&
+ _privacyAccepted &&
+ _selectedArrangement != null &&
+ _arrangementDetailsController.text.trim().length >=
+ _minArrangementDetailsLength &&
+ _selectedDepartureCountryIso != null &&
+ _departureCityController.text.trim().isNotEmpty &&
+ (_needsRecommendations ||
+ _destinationsController.text.trim().isNotEmpty) &&
+ _selectedDateMode != null &&
+ _hasValidDates &&
+ _adults >= 1 &&
+ travelBudgetValue != null &&
+ travelBudgetValue >= _minTravelBudget;
+ }
+
+ Future<void> _pickDate(
+ TextEditingController target,
+ VoidCallback onPicked,
+ ) async {
+ final DateTime? picked = await showDatePicker(
+ context: context,
+ initialDate: DateTime.now(),
+ firstDate: DateTime.now(),
+ lastDate: DateTime.now().add(const Duration(days: 3650)),
+ );
+ if (picked != null) {
+ setState(() {
+ target.text = _formatDate(picked);
+ onPicked();
+ });
+ }
+ }
+
+ String _formatDate(DateTime date) {
+ final String day = date.day.toString().padLeft(2, "0");
+ final String month = date.month.toString().padLeft(2, "0");
+ return "$day/$month/${date.year}";
+ }
+
+ String _buildRequestDescription() {
+ final List<String> parts = [
+ "Arrangement: $_selectedArrangement",
+ "Details: ${_arrangementDetailsController.text.trim()}",
+ "Departure: ${_departureCityController.text.trim()}, "
+ "${_selectedDepartureCountryIso ?? ''}",
+ ];
+
+ if (_needsRecommendations) {
+ parts.add("Destinations: Recommendations requested");
+ } else {
+ parts.add("Destinations: ${_destinationsController.text.trim()}");
+ }
+
+ if (_selectedDateMode == _exactDates) {
+ final String flex =
+ _selectedFlexibility != null && _selectedFlexibility != "Exact"
+ ? " ($_selectedFlexibility)"
+ : "";
+ parts.add(
+ "Dates: ${_departureDateController.text.trim()} - "
+ "${_returnDateController.text.trim()}$flex",
+ );
+ } else if (_selectedDateMode == _flexibleDates) {
+ parts.add(
+ "Dates: $_selectedMonthSeason $_selectedYear, "
+ "${_tripLengthController.text.trim()} nights",
+ );
+ }
+
+ final List<String> travelers = ["$_adults adult${_adults > 1 ? 's' : ''}"];
+ if (_children > 0) {
+ travelers.add("$_children child${_children > 1 ? 'ren' : ''}");
+ }
+ if (_infants > 0) {
+ travelers.add("$_infants infant${_infants > 1 ? 's' : ''}");
+ }
+ if (_pets > 0) {
+ travelers.add("$_pets pet${_pets > 1 ? 's' : ''}");
+ }
+ parts.add("Travelers: ${travelers.join(', ')}");
+
+ parts.add("Budget: ${_travelBudgetController.text.trim()} EUR");
+
+ return parts.join("\n");
+ }
+
+ Future<void> _submit() async {
+ setState(() => _submitting = true);
+ widget.model
+ ..requestDescription = _buildRequestDescription()
+ // Travel doesn't collect a delivery country: default to "DE" since the
+ // API requires the field. Travel destinations are captured in the
+ // structured comment field.
+ ..deliveryCountry = "DE";
+ try {
+ await submitShopInBitRequest(context, widget.model);
+ } finally {
+ if (mounted) setState(() => _submitting = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isDesktop = Util.isDesktop;
+
+ final String? arrangementDetailsError =
+ _arrangementDetailsTouched &&
+ _arrangementDetailsController.text.trim().length <
+ _minArrangementDetailsLength
+ ? "Minimum $_minArrangementDetailsLength characters"
+ : null;
+
+ final String? departureCityError =
+ _departureCityTouched && _departureCityController.text.trim().isEmpty
+ ? "Required"
+ : null;
+
+ final String? destinationsError =
+ _destinationsTouched &&
+ !_needsRecommendations &&
+ _destinationsController.text.trim().isEmpty
+ ? "Required (or check 'I need recommendations')"
+ : null;
+
+ final String? departureDateError =
+ _departureDateTouched && _departureDateController.text.trim().isEmpty
+ ? "Required"
+ : null;
+
+ final String? returnDateError =
+ _returnDateTouched && _returnDateController.text.trim().isEmpty
+ ? "Required"
+ : null;
+
+ final String? tripLengthError =
+ _tripLengthTouched && _tripLengthController.text.trim().isEmpty
+ ? "Required"
+ : null;
+
+ final String travelBudgetText = _travelBudgetController.text.trim();
+ final int? travelBudgetValue = int.tryParse(travelBudgetText);
+ final String? travelBudgetError =
+ _travelBudgetTouched &&
+ (travelBudgetText.isEmpty ||
+ travelBudgetValue == null ||
+ travelBudgetValue < _minTravelBudget)
+ ? "Minimum budget is 1,000 EUR"
+ : null;
+
+ final int currentYear = DateTime.now().year;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const ShopInBitStep4Header(
+ title: "Travel request",
+ subtitle: "Tell us about your trip and we'll arrange everything.",
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+
+ _TravelSectionLabel(text: "Trip type", isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitStep4Dropdown(
+ value: _selectedArrangement,
+ items: _arrangements,
+ hintText: "Arrangement type",
+ onChanged: (value) => setState(() => _selectedArrangement = value),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _arrangementDetailsController,
+ focusNode: _arrangementDetailsFocusNode,
+ hintText:
+ "Describe your specific requirements "
+ "(luggage, cabin class, hotel stars, etc.)",
+ minLines: 3,
+ maxLines: 6,
+ errorText: arrangementDetailsError,
+ onChanged: (_) => setState(() {}),
+ ),
+
+ SizedBox(height: isDesktop ? 24 : 16),
+ _TravelSectionLabel(text: "Where", isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitCountryPicker(
+ selectedIso: _selectedDepartureCountryIso,
+ onChanged: (iso) =>
+ setState(() => _selectedDepartureCountryIso = iso),
+ hintText: "Departure country",
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _departureCityController,
+ focusNode: _departureCityFocusNode,
+ hintText: "Departure city",
+ errorText: departureCityError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _destinationsController,
+ focusNode: _destinationsFocusNode,
+ hintText: "e.g. Paris, France; Rome, Italy",
+ enabled: !_needsRecommendations,
+ errorText: destinationsError,
+ onChanged: (_) => setState(() {}),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitLabeledCheckbox(
+ value: _needsRecommendations,
+ onChanged: (v) => setState(() => _needsRecommendations = v),
+ label: "I need recommendations",
+ ),
+
+ SizedBox(height: isDesktop ? 24 : 16),
+ _TravelSectionLabel(text: "When", isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitStep4Dropdown(
+ value: _selectedDateMode,
+ items: _dateModes,
+ hintText: "Date mode",
+ onChanged: (value) => setState(() => _selectedDateMode = value),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+
+ if (_selectedDateMode == _exactDates) ...[
+ ShopInBitStep4TextField(
+ controller: _departureDateController,
+ focusNode: _departureDateFocusNode,
+ hintText: "DD/MM/YYYY",
+ labelText: "Departure date",
+ readOnly: true,
+ onTap: () => _pickDate(
+ _departureDateController,
+ () => _departureDateTouched = true,
+ ),
+ suffixIcon: const Icon(Icons.calendar_today, size: 18),
+ errorText: departureDateError,
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _returnDateController,
+ focusNode: _returnDateFocusNode,
+ hintText: "DD/MM/YYYY",
+ labelText: "Return date",
+ readOnly: true,
+ onTap: () => _pickDate(
+ _returnDateController,
+ () => _returnDateTouched = true,
+ ),
+ suffixIcon: const Icon(Icons.calendar_today, size: 18),
+ errorText: returnDateError,
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4Dropdown(
+ value: _selectedFlexibility,
+ items: _flexibilities,
+ hintText: "Flexibility",
+ onChanged: (value) => setState(() => _selectedFlexibility = value),
+ ),
+ ],
+
+ if (_selectedDateMode == _flexibleDates) ...[
+ ShopInBitStep4Dropdown(
+ value: _selectedYear,
+ items: ["$currentYear", "${currentYear + 1}"],
+ hintText: "Year",
+ onChanged: (value) => setState(() => _selectedYear = value),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4Dropdown(
+ value: _selectedMonthSeason,
+ items: _months,
+ hintText: "Month or season",
+ onChanged: (value) => setState(() => _selectedMonthSeason = value),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4TextField(
+ controller: _tripLengthController,
+ focusNode: _tripLengthFocusNode,
+ hintText: "Number of nights",
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ errorText: tripLengthError,
+ onChanged: (_) => setState(() {}),
+ ),
+ ],
+
+ SizedBox(height: isDesktop ? 24 : 16),
+ _TravelSectionLabel(text: "Who", isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitTravelerCounter(
+ label: "Adults",
+ value: _adults,
+ min: 1,
+ onChanged: (v) => setState(() => _adults = v),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitTravelerCounter(
+ label: "Children",
+ value: _children,
+ onChanged: (v) => setState(() => _children = v),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitTravelerCounter(
+ label: "Infants",
+ value: _infants,
+ onChanged: (v) => setState(() => _infants = v),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitTravelerCounter(
+ label: "Pets",
+ value: _pets,
+ onChanged: (v) => setState(() => _pets = v),
+ ),
+
+ SizedBox(height: isDesktop ? 24 : 16),
+ _TravelSectionLabel(text: "Budget", isDesktop: isDesktop),
+ SizedBox(height: isDesktop ? 12 : 8),
+ ShopInBitStep4TextField(
+ controller: _travelBudgetController,
+ focusNode: _travelBudgetFocusNode,
+ hintText: "Minimum 1000 EUR",
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ suffixText: "EUR",
+ errorText: travelBudgetError,
+ onChanged: (_) => setState(() {}),
+ ),
+
+ // Travel doesn't collect delivery country: destinations are in the
+ // form and the API field is set to "DE" on submit.
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitPrivacyCheckbox(
+ value: _privacyAccepted,
+ onChanged: (v) => setState(() => _privacyAccepted = v),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ ShopInBitStep4SubmitButton(
+ submitting: _submitting,
+ enabled: _canContinue,
+ onPressed: _submit,
+ ),
+ ],
+ );
+ }
+}
+
+/// Bold-ish section header used inside the travel form ("Trip type", "Where",
+/// "When", "Who", "Budget").
+class _TravelSectionLabel extends StatelessWidget {
+ const _TravelSectionLabel({required this.text, required this.isDesktop});
+
+ final String text;
+ final bool isDesktop;
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(
+ text,
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.w500_14(context),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart
new file mode 100644
index 0000000..fb5ab6d
--- /dev/null
+++ b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart
@@ -0,0 +1,85 @@
+import "package:flutter/material.dart";
+
+import "../../../themes/stack_colors.dart";
+import "../../../utilities/constants.dart";
+import "../../../utilities/text_styles.dart";
+import "../../../utilities/util.dart";
+
+/// Label + minus/value/plus counter row used in the travel form to set the
+/// number of adults, children, infants and pets.
+class ShopInBitTravelerCounter extends StatelessWidget {
+ const ShopInBitTravelerCounter({
+ super.key,
+ required this.label,
+ required this.value,
+ required this.onChanged,
+ this.min = 0,
+ this.max = 20,
+ });
+
+ final String label;
+ final int value;
+ final int min;
+ final int max;
+ final ValueChanged<int> onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final TextStyle textStyle = Util.isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.w500_14(context);
+
+ return Row(
+ children: [
+ Text(label, style: textStyle),
+ const Spacer(),
+ _CounterButton(
+ symbol: "-",
+ onTap: value > min ? () => onChanged(value - 1) : null,
+ textStyle: textStyle,
+ ),
+ const SizedBox(width: 16),
+ SizedBox(
+ width: 24,
+ child: Center(child: Text("$value", style: textStyle)),
+ ),
+ const SizedBox(width: 16),
+ _CounterButton(
+ symbol: "+",
+ onTap: value < max ? () => onChanged(value + 1) : null,
+ textStyle: textStyle,
+ ),
+ ],
+ );
+ }
+}
+
+class _CounterButton extends StatelessWidget {
+ const _CounterButton({
+ required this.symbol,
+ required this.onTap,
+ required this.textStyle,
+ });
+
+ final String symbol;
+ final VoidCallback? onTap;
+ final TextStyle textStyle;
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: onTap,
+ child: Container(
+ width: 32,
+ height: 32,
+ decoration: BoxDecoration(
+ color: Theme.of(context).extension<StackColors>()!.textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ child: Center(child: Text(symbol, style: textStyle)),
+ ),
+ );
+ }
+}
Why this scored 10/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.