feat(shopinbit): add ShopInBit order creation flow (steps 1-4)
What changed, and why it matters
This commit adds four new screens that walk a user through creating a ShopInBit order inside the Stack Wallet app. It is purely a feature addition: collecting a display name, showing service guidelines, and submitting a request description and delivery country to the ShopInBit service. There is no indication in the commit that this fixes a security bug or introduces a vulnerability.
No security action required. Treat as a normal feature review. If ShopInBitService.client.createRequest or ensureCustomerKey are newly introduced, review them separately for input validation, authentication, and transport security.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff introduces four Flutter page classes (ShopInBitStep1-4), registers their named routes in route_generator.dart, and wires them to a ShopInBitOrderModel and ShopInBitService. Step 1 captures a display name, Step 2 (currently skipped) selects a category, Step 3 displays static service guidelines, and Step 4 collects a request description, country selection, privacy-policy acceptance, and calls createRequest via ShopInBitService. The code uses standard Flutter UI patterns and does not contain obvious injection, cryptographic, or authorization flaws based on the supplied diff alone.
Changed components
lib/pages/shopinbit/shopinbit_step_1.dartlib/pages/shopinbit/shopinbit_step_2.dartlib/pages/shopinbit/shopinbit_step_3.dartlib/pages/shopinbit/shopinbit_step_4.dartlib/route_generator.dartInspect captured patch +1286 / −0
diff --git a/lib/pages/shopinbit/shopinbit_step_1.dart b/lib/pages/shopinbit/shopinbit_step_1.dart
new file mode 100644
index 0000000..c7b35e4
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_step_1.dart
@@ -0,0 +1,201 @@
+import 'package:flutter/material.dart';
+
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../themes/stack_colors.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/stack_text_field.dart';
+import '../exchange_view/sub_widgets/step_row.dart';
+import 'shopinbit_step_3.dart';
+
+class ShopInBitStep1 extends StatefulWidget {
+ const ShopInBitStep1({super.key, required this.model});
+
+ static const String routeName = "/shopInBitStep1";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitStep1> createState() => _ShopInBitStep1State();
+}
+
+class _ShopInBitStep1State extends State<ShopInBitStep1> {
+ late final TextEditingController _nameController;
+ late final FocusNode _nameFocusNode;
+
+ bool get _canContinue => _nameController.text.trim().isNotEmpty;
+
+ @override
+ void initState() {
+ super.initState();
+ _nameController = TextEditingController(text: widget.model.displayName);
+ _nameFocusNode = FocusNode();
+
+ _nameFocusNode.addListener(() {
+ setState(() {});
+ });
+ }
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _nameFocusNode.dispose();
+ super.dispose();
+ }
+
+ void _continue() {
+ widget.model.displayName = _nameController.text.trim();
+ // Skip step 2 (category selection): only concierge is available initially
+ widget.model.category = ShopInBitCategory.concierge;
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitStep3(model: widget.model),
+ );
+ } else {
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final content = Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!isDesktop)
+ StepRow(
+ count: 4,
+ current: 0,
+ width: MediaQuery.of(context).size.width - 32,
+ ),
+ if (!isDesktop) const SizedBox(height: 14),
+ Text(
+ "Create your profile",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ Text(
+ "Enter a display name to use with ShopInBit.",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: _nameController,
+ focusNode: _nameFocusNode,
+ 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(
+ "Display name",
+ _nameFocusNode,
+ context,
+ desktopMed: isDesktop,
+ ).copyWith(
+ filled: true,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ ),
+ ),
+ ),
+ const Spacer(),
+ PrimaryButton(
+ label: "Next",
+ enabled: _canContinue,
+ onPressed: _canContinue ? _continue : null,
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 400,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("ShopInBit", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
+ ),
+ child: IntrinsicHeight(child: content),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart
new file mode 100644
index 0000000..c7aaf82
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_step_2.dart
@@ -0,0 +1,242 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/assets.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 '../exchange_view/sub_widgets/step_row.dart';
+import 'shopinbit_step_3.dart';
+
+class ShopInBitStep2 extends StatefulWidget {
+ const ShopInBitStep2({super.key, required this.model});
+
+ static const String routeName = "/shopInBitStep2";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitStep2> createState() => _ShopInBitStep2State();
+}
+
+class _ShopInBitStep2State extends State<ShopInBitStep2> {
+ ShopInBitCategory? _selected;
+
+ @override
+ void initState() {
+ super.initState();
+ _selected = widget.model.category;
+ }
+
+ void _continue() {
+ widget.model.category = _selected;
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitStep3(model: widget.model),
+ );
+ } else {
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model);
+ }
+ }
+
+ Widget _categoryCard({
+ required ShopInBitCategory category,
+ required String title,
+ required String description,
+ required String iconAsset,
+ required bool isDesktop,
+ }) {
+ final isSelected = _selected == category;
+ return GestureDetector(
+ onTap: () => setState(() => _selected = category),
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(isDesktop ? 16 : 12),
+ border: Border.all(
+ color: isSelected
+ ? Theme.of(context).extension<StackColors>()!.accentColorBlue
+ : Theme.of(context).extension<StackColors>()!.background,
+ width: 2,
+ ),
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ ),
+ padding: EdgeInsets.all(isDesktop ? 20 : 16),
+ child: Row(
+ children: [
+ SvgPicture.asset(
+ iconAsset,
+ width: isDesktop ? 32 : 24,
+ height: isDesktop ? 32 : 24,
+ ),
+ SizedBox(width: isDesktop ? 16 : 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ description,
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (isSelected)
+ Icon(
+ Icons.check_circle,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorBlue,
+ size: isDesktop ? 24 : 20,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final content = Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!isDesktop)
+ StepRow(
+ count: 4,
+ current: 1,
+ width: MediaQuery.of(context).size.width - 32,
+ ),
+ if (!isDesktop) const SizedBox(height: 14),
+ Text(
+ "Choose a service",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ Text(
+ "Select the type of service you need.",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+ _categoryCard(
+ category: ShopInBitCategory.concierge,
+ title: "Concierge",
+ description: "Purchase products and services online.",
+ iconAsset: Assets.svg.dollarSign,
+ isDesktop: isDesktop,
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ _categoryCard(
+ category: ShopInBitCategory.travel,
+ title: "Travel",
+ description: "Book flights, hotels, and more.",
+ iconAsset: Assets.svg.circleArrowUpRight,
+ isDesktop: isDesktop,
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ _categoryCard(
+ category: ShopInBitCategory.car,
+ title: "Car",
+ description: "Find and purchase vehicles.",
+ iconAsset: Assets.svg.boxAuto,
+ isDesktop: isDesktop,
+ ),
+ const Spacer(),
+ PrimaryButton(
+ label: "Next",
+ enabled: _selected != null,
+ onPressed: _selected != null ? _continue : null,
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 580,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("ShopInBit", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
+ ),
+ child: IntrinsicHeight(child: content),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart
new file mode 100644
index 0000000..13aa38d
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_step_3.dart
@@ -0,0 +1,199 @@
+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/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/rounded_white_container.dart';
+import '../exchange_view/sub_widgets/step_row.dart';
+import 'shopinbit_step_4.dart';
+
+class ShopInBitStep3 extends StatefulWidget {
+ const ShopInBitStep3({super.key, required this.model});
+
+ static const String routeName = "/shopInBitStep3";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitStep3> createState() => _ShopInBitStep3State();
+}
+
+class _ShopInBitStep3State extends State<ShopInBitStep3> {
+ String _guidelinesText() {
+ switch (widget.model.category) {
+ case ShopInBitCategory.concierge:
+ return "Concierge Service Guidelines:\n\n"
+ "\u2022 Minimum: fee of 100 EUR or minimum order "
+ "value of 1,000 EUR.\n\n"
+ "\u2022 Service Fee: 10% of the order total.\n\n"
+ "\u2022 Only legal products and services are allowed.\n\n"
+ "\u2022 Prohibited: precious metals, prescription "
+ "medicine, live animals, weapons, adult "
+ "entertainment, EU real estate.\n\n"
+ "\u2022 Provide a clear and detailed description of the "
+ "product or service you want to purchase.\n\n"
+ "\u2022 Include links to the exact item when possible.";
+ case ShopInBitCategory.travel:
+ return "Travel Service Guidelines:\n\n"
+ "\u2022 Recommended budget: 2,500 EUR and above "
+ "for custom trips.\n\n"
+ "\u2022 Minimum: fee of 100 EUR or booking value "
+ "of 1,000 EUR.\n\n"
+ "\u2022 Service Fee: 10% of the booking amount.\n\n"
+ "\u2022 Only legal travel services are allowed.\n\n"
+ "\u2022 Prohibited: sanctioned destinations, illegal "
+ "bookings, adult entertainment, real estate "
+ "disguised as travel.\n\n"
+ "\u2022 Provide full details of your travel request "
+ "including dates, destinations, and preferences.";
+ case ShopInBitCategory.car:
+ return "Car Service Guidelines:\n\n"
+ "\u2022 Minimum Order: \u20AC20,000.\n\n"
+ "\u2022 Research Fee: \u20AC223 (incl. VAT) \u2014 "
+ "one-time, credited toward purchase.\n\n"
+ "\u2022 Service Fee: 10% of the vehicle value.\n\n"
+ "\u2022 Only legal vehicle transactions are allowed.\n\n"
+ "\u2022 Prohibited: export to sanctioned regions, "
+ "armored/military vehicles without licensing, "
+ "weapons/tactical accessories, real estate "
+ "disguised as vehicle purchases.\n\n"
+ "\u2022 Provide details about the make, model, year, "
+ "and any specific requirements.";
+ case null:
+ return "";
+ }
+ }
+
+ void _continue() {
+ widget.model.guidelinesAccepted = true;
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitStep4(model: widget.model),
+ );
+ } else {
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final content = Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!isDesktop)
+ StepRow(
+ count: 4,
+ current: 2,
+ width: MediaQuery.of(context).size.width - 32,
+ ),
+ if (!isDesktop) const SizedBox(height: 14),
+ Text(
+ "Service guidelines",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ Text(
+ "Please read the following carefully before continuing.",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ Flexible(
+ child: RoundedWhiteContainer(
+ child: SingleChildScrollView(
+ child: Text(
+ _guidelinesText(),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ PrimaryButton(label: "Next", onPressed: _continue),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 650,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("ShopInBit", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
+ ),
+ child: IntrinsicHeight(child: content),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart
new file mode 100644
index 0000000..3a83364
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_step_4.dart
@@ -0,0 +1,599 @@
+import 'package:dropdown_button2/dropdown_button2.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import 'dart:async';
+
+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/stack_dialog.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/stack_text_field.dart';
+import '../exchange_view/sub_widgets/step_row.dart';
+import 'shopinbit_car_fee_view.dart';
+import 'shopinbit_order_created.dart';
+
+class ShopInBitStep4 extends StatefulWidget {
+ const ShopInBitStep4({super.key, required this.model});
+
+ static const String routeName = "/shopInBitStep4";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitStep4> createState() => _ShopInBitStep4State();
+}
+
+class _ShopInBitStep4State extends State<ShopInBitStep4> {
+ late final TextEditingController _descriptionController;
+ late final FocusNode _descriptionFocusNode;
+ final TextEditingController _countrySearchController =
+ TextEditingController();
+
+ 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: (_) => 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,
+ rootNavigator: true,
+ ).pop(false);
+ },
+ ),
+ const SizedBox(width: 20),
+ PrimaryButton(
+ width: 200,
+ buttonHeight: ButtonHeight.l,
+ label: "Continue",
+ onPressed: () {
+ Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop(true);
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ )
+ : StackDialog(
+ title: "Attention",
+ message:
+ "You are about to open "
+ "${uri.scheme}://${uri.host} "
+ "in your browser.",
+ leftButton: TextButton(
+ onPressed: () {
+ Navigator.of(context).pop(false);
+ },
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ rightButton: TextButton(
+ style: Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(context),
+ onPressed: () {
+ Navigator.of(context).pop(true);
+ },
+ child: Text("Continue", style: STextStyles.button(context)),
+ ),
+ ),
+ );
+ return shouldContinue ?? false;
+ }
+
+ bool get _canContinue =>
+ !_submitting &&
+ _privacyAccepted &&
+ _descriptionController.text.trim().isNotEmpty &&
+ _selectedCountryIso != null;
+
+ @override
+ void initState() {
+ super.initState();
+ _descriptionController = TextEditingController(
+ text: widget.model.requestDescription,
+ );
+ _descriptionFocusNode = FocusNode();
+ _descriptionFocusNode.addListener(() => setState(() {}));
+ if (widget.model.deliveryCountry.isNotEmpty) {
+ _selectedCountryIso = widget.model.deliveryCountry;
+ }
+ _fetchCountries();
+ }
+
+ @override
+ void dispose() {
+ _descriptionController.dispose();
+ _descriptionFocusNode.dispose();
+ _countrySearchController.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 {
+ widget.model.requestDescription = _descriptionController.text.trim();
+ widget.model.deliveryCountry = _selectedCountryIso!;
+
+ if (widget.model.category == ShopInBitCategory.car) {
+ 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();
+
+ final categoryStr = switch (widget.model.category) {
+ ShopInBitCategory.concierge => "concierge",
+ ShopInBitCategory.travel => "travel",
+ ShopInBitCategory.car => "car",
+ null => "concierge",
+ };
+
+ 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);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final content = 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(
+ "Describe your request",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ Text(
+ "Provide details about what you'd like to purchase.",
+ 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(
+ "What would you like to purchase?",
+ _descriptionFocusNode,
+ context,
+ desktopMed: isDesktop,
+ ).copyWith(
+ filled: true,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ 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, -10),
+ 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),
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ GestureDetector(
+ onTap: () {
+ setState(() {
+ _privacyAccepted = !_privacyAccepted;
+ });
+ },
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 20,
+ height: 26,
+ 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 : null),
+ 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: "."),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ PrimaryButton(
+ label: _submitting ? "Submitting..." : "Submit request",
+ enabled: _canContinue,
+ onPressed: _canContinue ? _submit : null,
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 560,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("ShopInBit", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
+ ),
+ child: IntrinsicHeight(child: content),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index d114699..cc4e731 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -20,6 +20,7 @@ import 'models/add_wallet_list_entity/sub_classes/eth_token_entity.dart';
import 'models/add_wallet_list_entity/sub_classes/sol_token_entity.dart';
import 'models/buy/response_objects/quote.dart';
import 'models/exchange/incomplete_exchange.dart';
+import 'models/shopinbit/shopinbit_order_model.dart';
import 'models/exchange/response_objects/trade.dart';
import 'models/isar/models/blockchain_data/v2/transaction_v2.dart';
import 'models/isar/models/contact_entry.dart';
@@ -84,6 +85,10 @@ import 'pages/masternodes/masternodes_home_view.dart';
import 'pages/monkey/monkey_view.dart';
import 'pages/more_view/gift_cards_view.dart';
import 'pages/more_view/services_view.dart';
+import 'pages/shopinbit/shopinbit_step_1.dart';
+import 'pages/shopinbit/shopinbit_step_2.dart';
+import 'pages/shopinbit/shopinbit_step_3.dart';
+import 'pages/shopinbit/shopinbit_step_4.dart';
import 'pages/namecoin_names/buy_domain_view.dart';
import 'pages/namecoin_names/confirm_name_transaction_view.dart';
import 'pages/namecoin_names/manage_domain_view.dart';
@@ -1047,6 +1052,46 @@ class RouteGenerator {
settings: RouteSettings(name: settings.name),
);
+ case ShopInBitStep1.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitStep1(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case ShopInBitStep2.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitStep2(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case ShopInBitStep3.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitStep3(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case ShopInBitStep4.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitStep4(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case GlobalSettingsView.routeName:
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
Why this scored 15/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.