fix(ui): desktop shopinbit dialog flow nested navigation
What changed, and why it matters
This commit is a user-interface refactor for the desktop ShopinBit (crypto concierge shopping) flow. It replaces a brittle pattern of opening and closing separate dialogs for each step with a single dialog that contains its own nested navigator. There is no clear security bug being fixed; the changes are about navigation consistency and code maintainability. The only security-relevant observation is that the new route generator validates that navigation arguments are the expected order model, which slightly hardens type handling, but this is a normal defensive coding practice rather than a fix for a known vulnerability.
No security action required. Treat as a normal UI/maintenance change. If reviewing further, verify that the nested navigator correctly preserves state across orientation changes and that the route generator's type validation covers all expected argument shapes in production builds.
Security signals we found
UI-only refactor with no changes to crypto, network, storage, or permission logic
New route generator performs runtime type validation of navigation arguments
Removal of ad-hoc rootNavigator: true pop/showDialog chains reduces risk of dialog stack corruption
No mention of security, CVE, bug bounty, or researcher attribution in commit or diff
Evidence from the diff
The patch refactors desktop ShopinBit onboarding/order creation to use a new NestedNavigatorDialog widget backed by NestedNavigatorDialogRouteGenerator. Previously, desktop steps manually popped the current dialog and pushed a new showDialog for each step, which caused navigation state fragmentation. Now the desktop view opens one dialog with an internal Navigator and pushes named routes (DesktopShopinBitFirstRun, ShopInBitStep1-4) inside it. The shared mobile/desktop step pages were simplified by removing Util.isDesktop branches and custom back-button handlers. A route generator validates arguments are ShopInBitOrderModel and shows an error dialog otherwise. File moves reorganize desktop-specific service views into cakepay/ and shopin_bit/ subdirectories. No cryptographic, network, or permission changes are present.
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/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dartlib/pages_desktop_specific/services/desktop_services_view.dartlib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dartlib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dartlib/route_generator.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dartInspect captured patch +996 / −867
diff --git a/lib/pages/shopinbit/shopinbit_step_1.dart b/lib/pages/shopinbit/shopinbit_step_1.dart
index 9a975ae..a1fa236 100644
--- a/lib/pages/shopinbit/shopinbit_step_1.dart
+++ b/lib/pages/shopinbit/shopinbit_step_1.dart
@@ -32,18 +32,9 @@ class _ShopInBitStep1State extends State<ShopInBitStep1> {
void _continue() {
widget.model.displayName = _nameController.text.trim();
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep2(model: widget.model),
- );
- } else {
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep2.routeName, arguments: widget.model);
- }
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep2.routeName, arguments: widget.model);
}
@override
diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart
index 79d6a20..b69cc87 100644
--- a/lib/pages/shopinbit/shopinbit_step_2.dart
+++ b/lib/pages/shopinbit/shopinbit_step_2.dart
@@ -15,7 +15,6 @@ import '../../widgets/desktop/primary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
import '../../widgets/rounded_container.dart';
import '../exchange_view/sub_widgets/step_row.dart';
-import 'shopinbit_step_1.dart';
import 'shopinbit_step_3.dart';
import 'shopinbit_step_4.dart';
@@ -33,46 +32,19 @@ class ShopInBitStep2 extends StatefulWidget {
class _ShopInBitStep2State extends State<ShopInBitStep2> {
ShopInBitCategory? _selected;
- void _popBack() {
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep1(model: widget.model),
- );
- } else {
- Navigator.of(context).pop();
- }
- }
-
void _continue() {
widget.model.category = _selected;
final skipGuidelines = ShopInBitService.instance.loadGuidelinesAccepted();
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- if (skipGuidelines) {
- widget.model.guidelinesAccepted = true;
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
- } else {
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model);
- }
+ if (skipGuidelines) {
+ widget.model.guidelinesAccepted = true;
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
} else {
- if (skipGuidelines) {
- widget.model.guidelinesAccepted = true;
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
- } else {
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model);
- }
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model);
}
}
@@ -101,11 +73,7 @@ class _ShopInBitStep2State extends State<ShopInBitStep2> {
children: [
Row(
children: [
- AppBarBackButton(
- isCompact: true,
- iconSize: 23,
- onPressed: _popBack,
- ),
+ const AppBarBackButton(isCompact: true, iconSize: 23),
Text("ShopinBit", style: STextStyles.desktopH3(context)),
],
),
@@ -125,40 +93,29 @@ class _ShopInBitStep2State extends State<ShopInBitStep2> {
child: ConditionalParent(
condition: !isDesktop,
builder: (content) => Background(
- child: PopScope(
- canPop: false,
- onPopInvokedWithResult: (bool didPop, dynamic result) {
- if (!didPop) {
- _popBack();
- }
- },
- child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
- appBar: AppBar(
- leading: AppBarBackButton(onPressed: _popBack),
- 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),
+ child: Scaffold(
+ backgroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: const AppBarBackButton(),
+ 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
index 21f7b14..4f6b799 100644
--- a/lib/pages/shopinbit/shopinbit_step_3.dart
+++ b/lib/pages/shopinbit/shopinbit_step_3.dart
@@ -12,7 +12,6 @@ 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_2.dart';
import 'shopinbit_step_4.dart';
class ShopInBitStep3 extends StatefulWidget {
@@ -74,35 +73,14 @@ class _ShopInBitStep3State extends State<ShopInBitStep3> {
}
}
- void _popBack() {
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep2(model: widget.model),
- );
- } else {
- Navigator.of(context).pop();
- }
- }
-
void _continue() {
widget.model.guidelinesAccepted = true;
// Persist acceptance.
ShopInBitService.instance.setGuidelinesAccepted(true);
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep4(model: widget.model),
- );
- } else {
- Navigator.of(
- context,
- ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
- }
+
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model);
}
@override
@@ -184,11 +162,7 @@ class _ShopInBitStep3State extends State<ShopInBitStep3> {
children: [
Row(
children: [
- AppBarBackButton(
- isCompact: true,
- iconSize: 23,
- onPressed: _popBack,
- ),
+ const AppBarBackButton(isCompact: true, iconSize: 23),
Text("ShopinBit", style: STextStyles.desktopH3(context)),
],
),
@@ -213,9 +187,7 @@ class _ShopInBitStep3State extends State<ShopInBitStep3> {
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
- leading: AppBarBackButton(
- onPressed: () => Navigator.of(context).pop(),
- ),
+ leading: const AppBarBackButton(),
title: Text("ShopinBit", style: STextStyles.navBarTitle(context)),
),
body: SafeArea(
diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart
index 3ead68b..ee5c411 100644
--- a/lib/pages/shopinbit/shopinbit_step_4.dart
+++ b/lib/pages/shopinbit/shopinbit_step_4.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@@ -5,8 +7,6 @@ import 'package:flutter/services.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';
@@ -17,16 +17,15 @@ 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/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_step_3.dart';
import 'shopinbit_car_fee_view.dart';
import 'shopinbit_order_created.dart';
import 'shopinbit_tickets_view.dart';
@@ -130,7 +129,7 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
final shouldContinue = await showDialog<bool>(
context: context,
barrierDismissible: false,
- builder: (_) => Util.isDesktop
+ builder: (context) => Util.isDesktop
? DesktopDialog(
maxWidth: 550,
maxHeight: 250,
@@ -157,24 +156,14 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
width: 200,
buttonHeight: ButtonHeight.l,
label: "Cancel",
- onPressed: () {
- Navigator.of(
- context,
- rootNavigator: true,
- ).pop(false);
- },
+ onPressed: () => Navigator.of(context).pop(false),
),
const SizedBox(width: 20),
PrimaryButton(
width: 200,
buttonHeight: ButtonHeight.l,
label: "Continue",
- onPressed: () {
- Navigator.of(
- context,
- rootNavigator: true,
- ).pop(true);
- },
+ onPressed: () => Navigator.of(context).pop(true),
),
],
),
@@ -188,27 +177,13 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
"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,
- ),
- ),
+ leftButton: SecondaryButton(
+ label: "Cancel",
+ onPressed: () => Navigator.of(context).pop(false),
),
- rightButton: TextButton(
- style: Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryEnabledButtonStyle(context),
- onPressed: () {
- Navigator.of(context).pop(true);
- },
- child: Text("Continue", style: STextStyles.button(context)),
+ rightButton: PrimaryButton(
+ label: "Continue",
+ onPressed: () => Navigator.of(context).pop(true),
),
),
);
@@ -445,19 +420,6 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
super.dispose();
}
- void _popBack() {
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep3(model: widget.model),
- );
- } else {
- Navigator.of(context).pop();
- }
- }
-
Future<void> _fetchCountries() async {
setState(() => _loadingCountries = true);
try {
@@ -624,7 +586,8 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
assert(
widget.model.category != null,
- 'Step 4 reached with null category: Step 2 must set category before reaching Step 4',
+ 'Step 4 reached with null category: Step 2 must set category before'
+ ' reaching Step 4',
);
// API service_type: travel requests use "concierge" because the
@@ -1063,7 +1026,8 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
: STextStyles.field(context),
decoration:
standardInputDecoration(
- "Describe what you'd like to purchase (e.g., electronics, luxury goods, services...)",
+ "Describe what you'd like to purchase "
+ "(e.g., electronics, luxury goods, services...)",
_whatToPurchaseFocusNode,
context,
desktopMed: isDesktop,
@@ -1555,7 +1519,8 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
),
const TextSpan(
text:
- "\u20AC223 (incl. VAT): one-time payment, credited toward your purchase.",
+ "\u20AC223 (incl. VAT): one-time payment, "
+ "credited toward your purchase.",
),
],
),
@@ -1968,7 +1933,8 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
: STextStyles.field(context),
decoration:
standardInputDecoration(
- "Describe your specific requirements (luggage, cabin class, hotel stars, etc.)",
+ "Describe your specific requirements "
+ "(luggage, cabin class, hotel stars, etc.)",
_arrangementDetailsFocusNode,
context,
desktopMed: isDesktop,
@@ -2407,11 +2373,7 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
children: [
Row(
children: [
- AppBarBackButton(
- isCompact: true,
- iconSize: 23,
- onPressed: _popBack,
- ),
+ const AppBarBackButton(isCompact: true, iconSize: 23),
Text("ShopinBit", style: STextStyles.desktopH3(context)),
],
),
@@ -2433,37 +2395,27 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
}
return Background(
- child: PopScope(
- canPop: false,
- onPopInvokedWithResult: (bool didPop, dynamic result) {
- if (!didPop) {
- _popBack();
- }
- },
- child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
- appBar: AppBar(
- leading: AppBarBackButton(onPressed: _popBack),
- 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),
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: const AppBarBackButton(),
+ 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_desktop_specific/services/cakepay/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart
new file mode 100644
index 0000000..964028a
--- /dev/null
+++ b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart
@@ -0,0 +1,137 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../app_config.dart';
+import '../../../pages/cakepay/cakepay_orders_view.dart';
+import '../../../pages/cakepay/cakepay_vendors_view.dart';
+import '../../../services/event_bus/events/global/tor_connection_status_changed_event.dart';
+import '../../../services/tor_service.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/desktop/secondary_button.dart';
+import '../../../widgets/icon_widgets/credit_card_icon.dart';
+import '../../../widgets/rounded_white_container.dart';
+import '../../../widgets/tor_subscription.dart';
+
+class DesktopGiftCardsView extends ConsumerStatefulWidget {
+ const DesktopGiftCardsView({super.key});
+
+ static const String routeName = "/desktopGiftCardsView";
+
+ @override
+ ConsumerState<DesktopGiftCardsView> createState() =>
+ _DesktopGiftCardsViewState();
+}
+
+class _DesktopGiftCardsViewState extends ConsumerState<DesktopGiftCardsView> {
+ late bool _torEnabled;
+
+ @override
+ void initState() {
+ _torEnabled = AppConfig.hasFeature(AppFeature.tor)
+ ? ref.read(pTorService).status != TorConnectionStatus.disconnected
+ : false;
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return TorSubscription(
+ onTorStatusChanged: (status) {
+ setState(() {
+ _torEnabled = status != TorConnectionStatus.disconnected;
+ });
+ },
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(right: 30),
+ child: RoundedWhiteContainer(
+ radiusMultiplier: 2,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.all(8.0),
+ child: CreditCardIcon(width: 48, height: 48),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: RichText(
+ textAlign: TextAlign.start,
+ text: TextSpan(
+ children: [
+ TextSpan(
+ text: "CakePay",
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ TextSpan(
+ text:
+ "\n\nPurchase gift cards with cryptocurrency.",
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (_torEnabled)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 10),
+ child: Text(
+ "CakePay is not available while Tor is enabled",
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 6,
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: PrimaryButton(
+ buttonHeight: ButtonHeight.m,
+ label: "Browse Gift Cards",
+ enabled: !_torEnabled,
+ onPressed: () {
+ showDialog<void>(
+ context: context,
+ builder: (_) => const CakePayVendorsView(),
+ );
+ },
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: SecondaryButton(
+ buttonHeight: ButtonHeight.m,
+ label: "My Orders",
+ onPressed: () {
+ showDialog<void>(
+ context: context,
+ builder: (_) => const CakePayOrdersView(),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages_desktop_specific/services/desktop_services_view.dart b/lib/pages_desktop_specific/services/desktop_services_view.dart
index 26b6e9e..f94f708 100644
--- a/lib/pages_desktop_specific/services/desktop_services_view.dart
+++ b/lib/pages_desktop_specific/services/desktop_services_view.dart
@@ -9,8 +9,8 @@ import '../../utilities/text_styles.dart';
import '../../widgets/desktop/desktop_app_bar.dart';
import '../../widgets/desktop/desktop_scaffold.dart';
import '../settings/settings_menu_item.dart';
-import 'sub_widgets/desktop_gift_cards_view.dart';
-import 'sub_widgets/desktop_shopinbit_view.dart';
+import 'cakepay/desktop_gift_cards_view.dart';
+import 'shopin_bit/desktop_shopinbit_view.dart';
final selectedServicesMenuItemStateProvider = StateProvider<int>((_) => 0);
diff --git a/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart
new file mode 100644
index 0000000..956a77c
--- /dev/null
+++ b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart
@@ -0,0 +1,475 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/svg.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../../app_config.dart';
+import '../../../db/isar/main_db.dart';
+import '../../../models/shopinbit/shopinbit_order_model.dart';
+import '../../../notifications/show_flush_bar.dart';
+import '../../../pages/shopinbit/shopinbit_step_1.dart';
+import '../../../pages/shopinbit/shopinbit_tickets_view.dart';
+import '../../../providers/desktop/current_desktop_menu_item.dart';
+import '../../../services/shopinbit/shopinbit_service.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/assets.dart';
+import '../../../utilities/text_styles.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/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart';
+import '../../../widgets/rounded_container.dart';
+import '../../../widgets/rounded_white_container.dart';
+import '../../../widgets/textfields/adaptive_text_field.dart';
+import '../../desktop_menu.dart';
+import '../../settings/settings_menu.dart';
+import 'sub_widgets/desktop_shopin_bit_first_run.dart';
+
+class DesktopShopInBitView extends ConsumerStatefulWidget {
+ const DesktopShopInBitView({super.key});
+
+ static const String routeName = "/desktopShopInBitView";
+
+ @override
+ ConsumerState<DesktopShopInBitView> createState() =>
+ _DesktopServicesViewState();
+}
+
+class _DesktopServicesViewState extends ConsumerState<DesktopShopInBitView> {
+ Future<bool> _showOpenBrowserWarning(BuildContext context, String url) async {
+ final uri = Uri.parse(url);
+ final shouldContinue = await showDialog<bool>(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => 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);
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ return shouldContinue ?? false;
+ }
+
+ Future<void> _showShopDialog(BuildContext context) async {
+ final service = ShopInBitService.instance;
+ final model = ShopInBitOrderModel();
+ bool isFirstRun = false;
+
+ if (!service.loadSetupComplete()) {
+ // First-time user: show setup.
+ final completed = await showDialog<bool>(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => _ShopInBitDesktopSetupDialog(model: model),
+ );
+ if (completed != true) return; // user cancelled
+ isFirstRun = true;
+ } else {
+ // Returning user: restore display name.
+ final savedName = service.loadDisplayName();
+ if (savedName != null && savedName.isNotEmpty) {
+ model.displayName = savedName;
+ }
+ }
+
+ if (!context.mounted) return;
+
+ if (isFirstRun) {
+ // First run: show service overview then go directly to Step2
+ // (name was just entered in setup dialog, no need to show Step1 again).
+ await showDialog<void>(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => NestedNavigatorDialog(
+ initialRoute: DesktopShopinBitFirstRun.routeName,
+ initialRouteArgs: model,
+ ),
+ );
+ } else {
+ // Returning user: go directly to Step1 (skip service overview dialog).
+ await showDialog<void>(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => NestedNavigatorDialog(
+ initialRoute: ShopInBitStep1.routeName,
+ initialRouteArgs: model,
+ ),
+ );
+
+ // TODO: figure out and comment why this is needed
+ if (mounted) setState(() {});
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(right: 30),
+ child: RoundedWhiteContainer(
+ radiusMultiplier: 2,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: SvgPicture.asset(
+ Assets.svg.circleSliders,
+ width: 48,
+ height: 48,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: RichText(
+ textAlign: TextAlign.start,
+ text: TextSpan(
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ children: [
+ TextSpan(
+ text: "ShopinBit",
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ const TextSpan(
+ text:
+ "\n\nTurn your crypto into Electronics, Flights, Hotel, "
+ "Cars or any other legal product or service... "
+ "ShopinBit is a concierge shopping service that helps "
+ "you 'live the good life with crypto'..."
+ "\n\n"
+ "Minimum order value of 1,000 EUR. "
+ "A 10% service fee applies to all orders.\n\n"
+ "By using ShopinBit, you agree to their ",
+ ),
+ TextSpan(
+ text: "Terms & Conditions",
+ style: STextStyles.richLink(
+ context,
+ ).copyWith(fontSize: 14),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () async {
+ const url =
+ "https://api.shopinbit.com/static/policy/terms.html";
+ final shouldOpen = await _showOpenBrowserWarning(
+ context,
+ url,
+ );
+ if (shouldOpen) {
+ await launchUrl(
+ Uri.parse(url),
+ mode: LaunchMode.externalApplication,
+ );
+ }
+ },
+ ),
+ const TextSpan(text: " and "),
+ TextSpan(
+ text: "Privacy Policy",
+ style: STextStyles.richLink(
+ context,
+ ).copyWith(fontSize: 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: "."),
+ ],
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: Row(
+ children: [
+ PrimaryButton(
+ width: 250,
+ buttonHeight: ButtonHeight.m,
+ enabled: true,
+ label: "Shop with ShopinBit",
+ onPressed: () => _showShopDialog(context),
+ ),
+ const SizedBox(width: 16),
+ Builder(
+ builder: (context) {
+ final count = MainDB.instance
+ .getShopInBitTickets()
+ .length;
+ return SecondaryButton(
+ width: 200,
+ buttonHeight: ButtonHeight.m,
+ label: count > 0
+ ? "My requests ($count)"
+ : "My requests",
+ onPressed: () async {
+ await showDialog<void>(
+ context: context,
+ builder: (_) => const ShopInBitTicketsView(),
+ );
+ if (mounted) setState(() {});
+ },
+ );
+ },
+ ),
+ const SizedBox(width: 16),
+ SecondaryButton(
+ width: 140,
+ buttonHeight: ButtonHeight.m,
+ label: "Settings",
+ onPressed: () {
+ // ShopInBit is the last settings menu item.
+ var idx = 8;
+ if (AppConfig.hasFeature(AppFeature.themeSelection)) {
+ idx++;
+ }
+ ref
+ .read(
+ selectedSettingsMenuItemStateProvider.state,
+ )
+ .state =
+ idx;
+ ref.read(currentDesktopMenuItemProvider.state).state =
+ DesktopMenuItemId.settings;
+ },
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ShopInBitDesktopSetupDialog extends StatefulWidget {
+ const _ShopInBitDesktopSetupDialog({required this.model});
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<_ShopInBitDesktopSetupDialog> createState() =>
+ _ShopInBitDesktopSetupDialogState();
+}
+
+class _ShopInBitDesktopSetupDialogState
+ extends State<_ShopInBitDesktopSetupDialog> {
+ late final Future<String> _keyFuture;
+ late final TextEditingController _nameController;
+
+ bool get _canContinue => _nameController.text.trim().isNotEmpty;
+
+ @override
+ void initState() {
+ super.initState();
+ _keyFuture = ShopInBitService.instance.ensureCustomerKey();
+ final existingName = ShopInBitService.instance.loadDisplayName();
+ _nameController = TextEditingController(text: existingName ?? '');
+ }
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ Future<void> _completeSetup() async {
+ final name = _nameController.text.trim();
+ widget.model.displayName = name;
+ await ShopInBitService.instance.setDisplayName(name);
+ await ShopInBitService.instance.setSetupComplete(true);
+ if (mounted) {
+ Navigator.of(context, rootNavigator: true).pop(true);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final maxDialogHeight = MediaQuery.sizeOf(context).height - 64;
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: maxDialogHeight,
+ child: Column(
+ mainAxisSize: .min,
+ crossAxisAlignment: .stretch,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopinBit Setup",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Flexible(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: Column(
+ mainAxisSize: .min,
+ crossAxisAlignment: .stretch,
+ children: [
+ const SizedBox(height: 16),
+ Text(
+ "Your Customer Key",
+ style: STextStyles.w600_20(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "This is your ShopinBit customer key: save it "
+ "somewhere safe, you'll need it to recover "
+ "your ShopinBit account on a new device.",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ ),
+ const SizedBox(height: 12),
+ FutureBuilder<String>(
+ future: _keyFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState != ConnectionState.done) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (snapshot.hasError) {
+ return RoundedContainer(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.warningBackground,
+ child: Text(
+ "Failed to generate key. Please try again.",
+ style: STextStyles.label700(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.warningForeground,
+ fontSize: 14,
+ ),
+ ),
+ );
+ }
+ final key = snapshot.data!;
+ return RoundedWhiteContainer(
+ borderColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle6,
+ child: Row(
+ children: [
+ const SizedBox(width: 10),
+ Expanded(
+ child: SelectableText(
+ key,
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.copy, size: 20),
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: key));
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard!",
+ context: context,
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 24),
+ Text(
+ "Display Name",
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
+ ),
+ ),
+ const SizedBox(height: 8),
+ AdaptiveTextField(
+ controller: _nameController,
+ showPasteClearButton: true,
+ maxLines: 1,
+ onChangedComprehensive: (_) => setState(() {}),
+ ),
+ const SizedBox(height: 40),
+ Row(
+ mainAxisAlignment: .end,
+ children: [
+ PrimaryButton(
+ label: "Complete Setup",
+ enabled: _canContinue,
+ onPressed: _canContinue ? _completeSetup : null,
+ horizontalContentPadding: 20,
+ ),
+ ],
+ ),
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart
new file mode 100644
index 0000000..69b6dff
--- /dev/null
+++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart
@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+
+import '../../../../models/shopinbit/shopinbit_order_model.dart';
+import '../../../../pages/shopinbit/shopinbit_step_1.dart';
+import '../../../../utilities/text_styles.dart';
+import '../../../../widgets/desktop/primary_button.dart';
+import '../../../../widgets/desktop/secondary_button.dart';
+import '../../../../widgets/dialogs/s_dialog.dart';
+
+class DesktopShopinBitFirstRun extends StatelessWidget {
+ const DesktopShopinBitFirstRun({super.key, required this.model});
+
+ static const routeName = "/desktopShopinBitFirstRun";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ Widget build(BuildContext context) {
+ return SDialog(
+ child: SizedBox(
+ width: 580,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text("ShopinBit", style: STextStyles.desktopH2(context)),
+ const SizedBox(height: 16),
+ RichText(
+ text: TextSpan(
+ style: STextStyles.desktopTextSmall(context),
+ children: const [
+ TextSpan(
+ text:
+ "Please note the following before proceeding:"
+ "\n\n\u2022 Minimum order amount: 1,000 EUR"
+ "\n\u2022 Service fee: 10% of the order total",
+ ),
+ ],
+ ),
+ ),
+ const Spacer(),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ SecondaryButton(
+ width: 220,
+ buttonHeight: ButtonHeight.l,
+ label: "Cancel",
+ onPressed: Navigator.of(context).pop,
+ ),
+ PrimaryButton(
+ width: 220,
+ buttonHeight: ButtonHeight.l,
+ label: "Continue",
+ onPressed: () => Navigator.of(
+ context,
+ ).pushNamed(ShopInBitStep1.routeName, arguments: model),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart
deleted file mode 100644
index 964028a..0000000
--- a/lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart
+++ /dev/null
@@ -1,137 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../../app_config.dart';
-import '../../../pages/cakepay/cakepay_orders_view.dart';
-import '../../../pages/cakepay/cakepay_vendors_view.dart';
-import '../../../services/event_bus/events/global/tor_connection_status_changed_event.dart';
-import '../../../services/tor_service.dart';
-import '../../../themes/stack_colors.dart';
-import '../../../utilities/text_styles.dart';
-import '../../../widgets/desktop/primary_button.dart';
-import '../../../widgets/desktop/secondary_button.dart';
-import '../../../widgets/icon_widgets/credit_card_icon.dart';
-import '../../../widgets/rounded_white_container.dart';
-import '../../../widgets/tor_subscription.dart';
-
-class DesktopGiftCardsView extends ConsumerStatefulWidget {
- const DesktopGiftCardsView({super.key});
-
- static const String routeName = "/desktopGiftCardsView";
-
- @override
- ConsumerState<DesktopGiftCardsView> createState() =>
- _DesktopGiftCardsViewState();
-}
-
-class _DesktopGiftCardsViewState extends ConsumerState<DesktopGiftCardsView> {
- late bool _torEnabled;
-
- @override
- void initState() {
- _torEnabled = AppConfig.hasFeature(AppFeature.tor)
- ? ref.read(pTorService).status != TorConnectionStatus.disconnected
- : false;
- super.initState();
- }
-
- @override
- Widget build(BuildContext context) {
- return TorSubscription(
- onTorStatusChanged: (status) {
- setState(() {
- _torEnabled = status != TorConnectionStatus.disconnected;
- });
- },
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.only(right: 30),
- child: RoundedWhiteContainer(
- radiusMultiplier: 2,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Padding(
- padding: EdgeInsets.all(8.0),
- child: CreditCardIcon(width: 48, height: 48),
- ),
- Padding(
- padding: const EdgeInsets.all(10),
- child: RichText(
- textAlign: TextAlign.start,
- text: TextSpan(
- children: [
- TextSpan(
- text: "CakePay",
- style: STextStyles.desktopTextSmall(context),
- ),
- TextSpan(
- text:
- "\n\nPurchase gift cards with cryptocurrency.",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- ),
- ],
- ),
- ),
- ),
- if (_torEnabled)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 10),
- child: Text(
- "CakePay is not available while Tor is enabled",
- style: STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 10,
- vertical: 6,
- ),
- child: Row(
- children: [
- Expanded(
- child: PrimaryButton(
- buttonHeight: ButtonHeight.m,
- label: "Browse Gift Cards",
- enabled: !_torEnabled,
- onPressed: () {
- showDialog<void>(
- context: context,
- builder: (_) => const CakePayVendorsView(),
- );
- },
- ),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: SecondaryButton(
- buttonHeight: ButtonHeight.m,
- label: "My Orders",
- onPressed: () {
- showDialog<void>(
- context: context,
- builder: (_) => const CakePayOrdersView(),
- );
- },
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart
deleted file mode 100644
index 010adc5..0000000
--- a/lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart
+++ /dev/null
@@ -1,519 +0,0 @@
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_svg/svg.dart';
-import 'package:url_launcher/url_launcher.dart';
-
-import '../../../app_config.dart';
-import '../../../db/isar/main_db.dart';
-import '../../../models/shopinbit/shopinbit_order_model.dart';
-import '../../../notifications/show_flush_bar.dart';
-import '../../../pages/shopinbit/shopinbit_step_1.dart';
-import '../../../pages/shopinbit/shopinbit_tickets_view.dart';
-import '../../../providers/desktop/current_desktop_menu_item.dart';
-import '../../../services/shopinbit/shopinbit_service.dart';
-import '../../../themes/stack_colors.dart';
-import '../../../utilities/assets.dart';
-import '../../../utilities/text_styles.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_container.dart';
-import '../../../widgets/rounded_white_container.dart';
-import '../../../widgets/textfields/adaptive_text_field.dart';
-import '../../desktop_menu.dart';
-import '../../settings/settings_menu.dart';
-
-class DesktopShopInBitView extends ConsumerStatefulWidget {
- const DesktopShopInBitView({super.key});
-
- static const String routeName = "/desktopShopInBitView";
-
- @override
- ConsumerState<DesktopShopInBitView> createState() =>
- _DesktopServicesViewState();
-}
-
-class _DesktopServicesViewState extends ConsumerState<DesktopShopInBitView> {
- Future<bool> _showOpenBrowserWarning(BuildContext context, String url) async {
- final uri = Uri.parse(url);
- final shouldContinue = await showDialog<bool>(
- context: context,
- barrierDismissible: false,
- builder: (_) => 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);
- },
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- return shouldContinue ?? false;
- }
-
- Future<void> _showShopDialog(BuildContext context) async {
- final service = ShopInBitService.instance;
- final model = ShopInBitOrderModel();
- bool isFirstRun = false;
-
- if (!service.loadSetupComplete()) {
- // First-time user: show setup.
- final completed = await showDialog<bool>(
- context: context,
- barrierDismissible: false,
- builder: (_) => _ShopInBitDesktopSetupDialog(model: model),
- );
- if (completed != true) return; // user cancelled
- isFirstRun = true;
- } else {
- // Returning user: restore display name.
- final savedName = service.loadDisplayName();
- if (savedName != null && savedName.isNotEmpty) {
- model.displayName = savedName;
- }
- }
-
- if (!context.mounted) return;
-
- if (isFirstRun) {
- // First run: show service overview then go directly to Step2
- // (name was just entered in setup dialog, no need to show Step1 again).
- await showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (dialogContext) => DesktopDialog(
- maxWidth: 550,
- maxHeight: 300,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text("ShopinBit", style: STextStyles.desktopH2(dialogContext)),
- const SizedBox(height: 16),
- RichText(
- text: TextSpan(
- style: STextStyles.desktopTextSmall(dialogContext),
- children: const [
- TextSpan(
- text:
- "Please note the following before proceeding:"
- "\n\n\u2022 Minimum order amount: 1,000 EUR"
- "\n\u2022 Service fee: 10% of the order total",
- ),
- ],
- ),
- ),
- const Spacer(),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- SecondaryButton(
- width: 200,
- buttonHeight: ButtonHeight.l,
- label: "Cancel",
- onPressed: () {
- Navigator.of(dialogContext, rootNavigator: true).pop();
- },
- ),
- PrimaryButton(
- width: 200,
- buttonHeight: ButtonHeight.l,
- label: "Continue",
- onPressed: () async {
- Navigator.of(dialogContext, rootNavigator: true).pop();
- await showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep1(model: model),
- );
- if (mounted) setState(() {});
- },
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- } else {
- // Returning user: go directly to Step1 (skip service overview dialog).
- await showDialog<void>(
- context: context,
- barrierDismissible: false,
- builder: (_) => ShopInBitStep1(model: model),
- );
- if (mounted) setState(() {});
- }
- }
-
- @override
- Widget build(BuildContext context) {
- return Column(
- children: [
- Padding(
- padding: const EdgeInsets.only(right: 30),
- child: RoundedWhiteContainer(
- radiusMultiplier: 2,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: SvgPicture.asset(
- Assets.svg.circleSliders,
- width: 48,
- height: 48,
- ),
- ),
- Padding(
- padding: const EdgeInsets.all(10),
- child: RichText(
- textAlign: TextAlign.start,
- text: TextSpan(
- style: STextStyles.desktopTextExtraExtraSmall(context),
- children: [
- TextSpan(
- text: "ShopinBit",
- style: STextStyles.desktopTextSmall(context),
- ),
- const TextSpan(
- text:
- "\n\nTurn your crypto into Electronics, Flights, Hotel, "
- "Cars or any other legal product or service... "
- "ShopinBit is a concierge shopping service that helps "
- "you 'live the good life with crypto'..."
- "\n\n"
- "Minimum order value of 1,000 EUR. "
- "A 10% service fee applies to all orders.\n\n"
- "By using ShopinBit, you agree to their ",
- ),
- TextSpan(
- text: "Terms & Conditions",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: 14),
- recognizer: TapGestureRecognizer()
- ..onTap = () async {
- const url =
- "https://api.shopinbit.com/static/policy/terms.html";
- final shouldOpen = await _showOpenBrowserWarning(
- context,
- url,
- );
- if (shouldOpen) {
- await launchUrl(
- Uri.parse(url),
- mode: LaunchMode.externalApplication,
- );
- }
- },
- ),
- const TextSpan(text: " and "),
- TextSpan(
- text: "Privacy Policy",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: 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: "."),
- ],
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.all(10),
- child: Row(
- children: [
- PrimaryButton(
- width: 250,
- buttonHeight: ButtonHeight.m,
- enabled: true,
- label: "Shop with ShopinBit",
- onPressed: () => _showShopDialog(context),
- ),
- const SizedBox(width: 16),
- Builder(
- builder: (context) {
- final count = MainDB.instance
- .getShopInBitTickets()
- .length;
- return SecondaryButton(
- width: 200,
- buttonHeight: ButtonHeight.m,
- label: count > 0
- ? "My requests ($count)"
- : "My requests",
- onPressed: () async {
- await showDialog<void>(
- context: context,
- builder: (_) => const ShopInBitTicketsView(),
- );
- if (mounted) setState(() {});
- },
- );
- },
- ),
- const SizedBox(width: 16),
- SecondaryButton(
- width: 140,
- buttonHeight: ButtonHeight.m,
- label: "Settings",
- onPressed: () {
- // ShopInBit is the last settings menu item.
- var idx = 8;
- if (AppConfig.hasFeature(AppFeature.themeSelection)) {
- idx++;
- }
- ref
- .read(
- selectedSettingsMenuItemStateProvider.state,
- )
- .state =
- idx;
- ref.read(currentDesktopMenuItemProvider.state).state =
- DesktopMenuItemId.settings;
- },
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- );
- }
-}
-
-class _ShopInBitDesktopSetupDialog extends StatefulWidget {
- const _ShopInBitDesktopSetupDialog({required this.model});
-
- final ShopInBitOrderModel model;
-
- @override
- State<_ShopInBitDesktopSetupDialog> createState() =>
- _ShopInBitDesktopSetupDialogState();
-}
-
-class _ShopInBitDesktopSetupDialogState
- extends State<_ShopInBitDesktopSetupDialog> {
- late final Future<String> _keyFuture;
- late final TextEditingController _nameController;
-
- bool get _canContinue => _nameController.text.trim().isNotEmpty;
-
- @override
- void initState() {
- super.initState();
- _keyFuture = ShopInBitService.instance.ensureCustomerKey();
- final existingName = ShopInBitService.instance.loadDisplayName();
- _nameController = TextEditingController(text: existingName ?? '');
- }
-
- @override
- void dispose() {
- _nameController.dispose();
- super.dispose();
- }
-
- Future<void> _completeSetup() async {
- final name = _nameController.text.trim();
- widget.model.displayName = name;
- await ShopInBitService.instance.setDisplayName(name);
- await ShopInBitService.instance.setSetupComplete(true);
- if (mounted) {
- Navigator.of(context, rootNavigator: true).pop(true);
- }
- }
-
- @override
- Widget build(BuildContext context) {
- final maxDialogHeight = MediaQuery.sizeOf(context).height - 64;
- return DesktopDialog(
- maxWidth: 580,
- maxHeight: maxDialogHeight,
- child: Column(
- mainAxisSize: .min,
- crossAxisAlignment: .stretch,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Padding(
- padding: const EdgeInsets.only(left: 32),
- child: Text(
- "ShopinBit Setup",
- style: STextStyles.desktopH3(context),
- ),
- ),
- const DesktopDialogCloseButton(),
- ],
- ),
- Flexible(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 32),
- child: Column(
- mainAxisSize: .min,
- crossAxisAlignment: .stretch,
- children: [
- const SizedBox(height: 16),
- Text(
- "Your Customer Key",
- style: STextStyles.w600_20(context),
- ),
- const SizedBox(height: 8),
- Text(
- "This is your ShopinBit customer key: save it "
- "somewhere safe, you'll need it to recover "
- "your ShopinBit account on a new device.",
- style: STextStyles.desktopTextExtraExtraSmall(context),
- ),
- const SizedBox(height: 12),
- FutureBuilder<String>(
- future: _keyFuture,
- builder: (context, snapshot) {
- if (snapshot.connectionState != ConnectionState.done) {
- return const Center(child: CircularProgressIndicator());
- }
- if (snapshot.hasError) {
- return RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningBackground,
- child: Text(
- "Failed to generate key. Please try again.",
- style: STextStyles.label700(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
- fontSize: 14,
- ),
- ),
- );
- }
- final key = snapshot.data!;
- return RoundedWhiteContainer(
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle6,
- child: Row(
- children: [
- const SizedBox(width: 10),
- Expanded(
- child: SelectableText(
- key,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- ),
- ),
- IconButton(
- icon: const Icon(Icons.copy, size: 20),
- onPressed: () {
- Clipboard.setData(ClipboardData(text: key));
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "Copied to clipboard!",
- context: context,
- );
- },
- ),
- ],
- ),
- );
- },
- ),
- const SizedBox(height: 24),
- Text(
- "Display Name",
- style: STextStyles.desktopTextExtraExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveSearchIconRight,
- ),
- ),
- const SizedBox(height: 8),
- AdaptiveTextField(
- controller: _nameController,
- showPasteClearButton: true,
- maxLines: 1,
- onChangedComprehensive: (_) => setState(() {}),
- ),
- const SizedBox(height: 40),
- Row(
- mainAxisAlignment: .end,
- children: [
- PrimaryButton(
- label: "Complete Setup",
- enabled: _canContinue,
- onPressed: _canContinue ? _completeSetup : null,
- horizontalContentPadding: 20,
- ),
- ],
- ),
- const SizedBox(height: 32),
- ],
- ),
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index 0f0fbbf..d08cdb1 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -241,9 +241,9 @@ import 'pages_desktop_specific/password/create_password_view.dart';
import 'pages_desktop_specific/password/delete_password_warning_view.dart';
import 'pages_desktop_specific/password/forgot_password_desktop_view.dart';
import 'pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart';
+import 'pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart';
import 'pages_desktop_specific/services/desktop_services_view.dart';
-import 'pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart';
-import 'pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart';
+import 'pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart';
import 'pages_desktop_specific/settings/desktop_settings_view.dart';
import 'pages_desktop_specific/settings/settings_menu/advanced_settings/advanced_settings.dart';
import 'pages_desktop_specific/settings/settings_menu/appearance_settings/appearance_settings.dart';
diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart
new file mode 100644
index 0000000..ae54f23
--- /dev/null
+++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart
@@ -0,0 +1,76 @@
+import 'package:flutter/material.dart';
+
+import 'nested_navigator_dialog_route_generator.dart';
+
+class NestedNavigatorDialog extends StatefulWidget {
+ const NestedNavigatorDialog({
+ super.key,
+ required this.initialRoute,
+ this.initialRouteArgs,
+ this.navigatorKey,
+ });
+
+ final String initialRoute;
+ final Object? initialRouteArgs;
+ final GlobalKey<NavigatorState>? navigatorKey;
+
+ @override
+ State<NestedNavigatorDialog> createState() => _NestedNavigatorDialogState();
+}
+
+class _NestedNavigatorDialogState extends State<NestedNavigatorDialog> {
+ late final _CloseOnEmptyObserver _observer;
+ late final GlobalKey<NavigatorState> _navigatorKey;
+
+ NavigatorState? _parentNavigator;
+
+ void _close() {
+ if (mounted) _parentNavigator?.pop();
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _observer = _CloseOnEmptyObserver(_close);
+ _navigatorKey = widget.navigatorKey ?? GlobalKey<NavigatorState>();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _parentNavigator = Navigator.of(context);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Dialog(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ insetPadding: EdgeInsets.zero,
+ child: Navigator(
+ key: _navigatorKey,
+ observers: <NavigatorObserver>[_observer],
+ onGenerateRoute: NestedNavigatorDialogRouteGenerator.generateRoute,
+ onGenerateInitialRoutes: (_, _) => [
+ NestedNavigatorDialogRouteGenerator.generateRoute(
+ RouteSettings(
+ name: widget.initialRoute,
+ arguments: widget.initialRouteArgs,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CloseOnEmptyObserver extends NavigatorObserver {
+ _CloseOnEmptyObserver(this.onEmpty);
+
+ final VoidCallback onEmpty;
+
+ @override
+ void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
+ if (previousRoute == null) onEmpty();
+ }
+}
diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
new file mode 100644
index 0000000..7d92486
--- /dev/null
+++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
@@ -0,0 +1,160 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import '../../../models/shopinbit/shopinbit_order_model.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_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.dart';
+import '../../conditional_parent.dart';
+import '../../desktop/desktop_dialog_close_button.dart';
+import '../s_dialog.dart';
+
+abstract final class NestedNavigatorDialogRouteGenerator {
+ static Route<dynamic> generateRoute(RouteSettings settings) {
+ final args = settings.arguments;
+
+ switch (settings.name) {
+ case DesktopShopinBitFirstRun.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ builder: (_) => DesktopShopinBitFirstRun(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError(
+ "${settings.name} invalid args\n"
+ "Got ${args.runtimeType}\n"
+ "Expected ShopInBitOrderModel",
+ );
+
+ case ShopInBitStep1.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ builder: (_) => ShopInBitStep1(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError(
+ "${settings.name} invalid args\n"
+ "Got ${args.runtimeType}\n"
+ "Expected ShopInBitOrderModel",
+ );
+
+ case ShopInBitStep2.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ builder: (_) => ShopInBitStep2(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError(
+ "${settings.name} invalid args\n"
+ "Got ${args.runtimeType}\n"
+ "Expected ShopInBitOrderModel",
+ );
+
+ case ShopInBitStep3.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ builder: (_) => ShopInBitStep3(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError(
+ "${settings.name} invalid args\n"
+ "Got ${args.runtimeType}\n"
+ "Expected ShopInBitOrderModel",
+ );
+
+ case ShopInBitStep4.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ builder: (_) => ShopInBitStep4(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError(
+ "${settings.name} invalid args\n"
+ "Got ${args.runtimeType}\n"
+ "Expected ShopInBitOrderModel",
+ );
+
+ default:
+ return _routeError("Unknown route name: ${settings.name}");
+ }
+ }
+
+ static Route<T> getRoute<T>({
+ required WidgetBuilder builder,
+ RouteSettings? settings,
+ }) {
+ return PageRouteBuilder<T>(
+ settings: settings,
+ opaque: false,
+ barrierColor: Colors.transparent,
+ transitionDuration: const Duration(milliseconds: 220),
+ reverseTransitionDuration: const Duration(milliseconds: 220),
+ pageBuilder: (BuildContext context, _, __) => builder(context),
+ transitionsBuilder:
+ (
+ BuildContext context,
+ Animation<double> animation,
+ Animation<double> secondaryAnimation,
+ Widget child,
+ ) {
+ return FadeTransition(
+ opacity: animation,
+ child: FadeTransition(
+ opacity: Tween<double>(
+ begin: 1,
+ end: 0,
+ ).animate(secondaryAnimation),
+ child: child,
+ ),
+ );
+ },
+ );
+ }
+
+ static Route<T> _routeError<T>(String message) {
+ return getRoute<T>(
+ builder: (context) => SDialog(
+ child: ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => SizedBox(
+ width: 580,
+ child: Column(
+ mainAxisSize: .min,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Navigation Error",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ child,
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ child: Text(
+ "Error handling route, this is not supposed to happen. "
+ "Contact developers.\n$message",
+ ),
+ ),
+ ),
+ );
+ }
+}
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.