feat(shopinbit): add ShopInBit settings page
What changed, and why it matters
This commit adds a new settings page for a third-party shopping feature called ShopInBit. It lets users generate, view, copy, and restore a 'customer key' used to identify them to the ShopInBit service. The change is mostly user-interface wiring and does not, on its own, appear to fix or introduce a security vulnerability. It does surface a sensitive-looking key in the UI and allows it to be copied to the system clipboard, which could matter if the key is later shown to be a secret, but the commit itself does not change how that key is stored or generated.
Treat this as a feature commit, not a security patch. If reviewing for security, verify separately how ShopInBitService.setCustomerKey(), ensureCustomerKey(), and client.externalCustomerKey handle key generation entropy, storage encryption, and transmission. Also confirm whether the 'customer key' is a secret that should be masked rather than displayed as selectable text.
Security signals we found
New UI surfaces a customer identifier/key and permits clipboard copy
Key change flow requires the user to re-type the current key for confirmation, which is a mild anti-misconfiguration control
No changes to key generation, storage encryption, or transport security are visible in this diff
The key is read from shared preferences (DB.boxNamePrefs) and set on the external client object
Evidence from the diff
The patch introduces mobile and desktop settings views for ShopInBit, registers routes, and exposes ShopInBitService.loadCustomerKey(). The views support generating a new customer key via ShopInBitService.instance.client.generateKey()/ensureCustomerKey(), setting a manually entered key with setCustomerKey(), and copying the current key to the clipboard. The feature is gated behind a ‘familiarity >= 6’ preference check. No cryptographic, storage, or network logic is modified beyond adding the loadCustomerKey() helper that reads the existing ‘shopInBitCustomerKey’ preference and assigns it to the client.
Changed components
lib/pages/shopinbit/shopinbit_settings_view.dartlib/pages_desktop_specific/settings/desktop_settings_view.dartlib/pages_desktop_specific/settings/settings_menu.dartlib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dartlib/route_generator.dartlib/services/shopinbit/shopinbit_service.dartInspect captured patch +1010 / −106
diff --git a/lib/pages/settings_views/global_settings_view/global_settings_view.dart b/lib/pages/settings_views/global_settings_view/global_settings_view.dart
index 5dc6d41..ac67091 100644
--- a/lib/pages/settings_views/global_settings_view/global_settings_view.dart
+++ b/lib/pages/settings_views/global_settings_view/global_settings_view.dart
@@ -11,8 +11,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app_config.dart';
+import '../../../providers/providers.dart';
import '../../../route_generator.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/assets.dart';
@@ -22,6 +24,7 @@ import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/rounded_white_container.dart';
import '../../address_book_views/address_book_view.dart';
import '../../pinpad_views/lock_screen_view.dart';
+import '../../shopinbit/shopinbit_settings_view.dart';
import '../sub_widgets/settings_list_button.dart';
import 'about_view.dart';
import 'advanced_views/advanced_settings_view.dart';
@@ -96,21 +99,19 @@ class GlobalSettingsView extends StatelessWidget {
Navigator.push(
context,
RouteGenerator.getRoute(
- shouldUseMaterialRoute:
- RouteGenerator
- .useMaterialPageRoute,
- builder:
- (_) => const LockscreenView(
- showBackButton: true,
- routeOnSuccess:
- StackBackupView.routeName,
- biometricsCancelButtonString:
- "CANCEL",
- biometricsLocalizedReason:
- "Authenticate to access ${AppConfig.prefix} backup & restore settings",
- biometricsAuthenticationTitle:
- "${AppConfig.prefix} backup",
- ),
+ shouldUseMaterialRoute: RouteGenerator
+ .useMaterialPageRoute,
+ builder: (_) => const LockscreenView(
+ showBackButton: true,
+ routeOnSuccess:
+ StackBackupView.routeName,
+ biometricsCancelButtonString:
+ "CANCEL",
+ biometricsLocalizedReason:
+ "Authenticate to access ${AppConfig.prefix} backup & restore settings",
+ biometricsAuthenticationTitle:
+ "${AppConfig.prefix} backup",
+ ),
settings: const RouteSettings(
name: "/swblockscreen",
),
@@ -247,6 +248,34 @@ class GlobalSettingsView extends StatelessWidget {
},
),
const SizedBox(height: 8),
+ Consumer(
+ builder: (_, ref, __) {
+ final familiarity = ref.watch(
+ prefsChangeNotifierProvider.select(
+ (v) => v.familiarity,
+ ),
+ );
+ if (familiarity < 6) {
+ return const SizedBox.shrink();
+ }
+ return Column(
+ children: [
+ const SizedBox(height: 8),
+ SettingsListButton(
+ iconAssetName: Assets.svg.key,
+ iconSize: 16,
+ title: "ShopInBit",
+ onPressed: () {
+ Navigator.of(context).pushNamed(
+ ShopInBitSettingsView.routeName,
+ );
+ },
+ ),
+ ],
+ );
+ },
+ ),
+ const SizedBox(height: 8),
SettingsListButton(
iconAssetName: Assets.svg.questionMessage,
iconSize: 16,
diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart
new file mode 100644
index 0000000..c6605d9
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_settings_view.dart
@@ -0,0 +1,425 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/flutter_svg.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 '../../widgets/background.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/rounded_container.dart';
+import '../../widgets/rounded_white_container.dart';
+import '../../widgets/stack_dialog.dart';
+import '../../widgets/stack_text_field.dart';
+
+class ShopInBitSettingsView extends ConsumerStatefulWidget {
+ const ShopInBitSettingsView({super.key});
+
+ static const String routeName = "/shopInBitSettings";
+
+ @override
+ ConsumerState<ShopInBitSettingsView> createState() =>
+ _ShopInBitSettingsViewState();
+}
+
+class _ShopInBitSettingsViewState extends ConsumerState<ShopInBitSettingsView> {
+ final _manualKeyController = TextEditingController();
+ final _manualKeyFocusNode = FocusNode();
+ final _verifyKeyController = TextEditingController();
+ final _verifyKeyFocusNode = FocusNode();
+
+ String? _currentKey;
+ bool _loading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _currentKey = ShopInBitService.instance.loadCustomerKey();
+ }
+
+ @override
+ void dispose() {
+ _manualKeyController.dispose();
+ _manualKeyFocusNode.dispose();
+ _verifyKeyController.dispose();
+ _verifyKeyFocusNode.dispose();
+ super.dispose();
+ }
+
+ Future<void> _generate() async {
+ if (_currentKey != null) {
+ final proceed = await _showChangeWarning();
+ if (proceed != true) return;
+ }
+
+ setState(() => _loading = true);
+ try {
+ final String key;
+ if (_currentKey != null) {
+ final resp = await ShopInBitService.instance.client.generateKey();
+ key = resp.valueOrThrow;
+ await ShopInBitService.instance.setCustomerKey(key);
+ } else {
+ key = await ShopInBitService.instance.ensureCustomerKey();
+ }
+ setState(() => _currentKey = key);
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.success,
+ message: "Customer key generated",
+ context: context,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Failed to generate key: $e",
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future<void> _setManualKey() async {
+ final newKey = _manualKeyController.text.trim();
+ if (newKey.isEmpty) return;
+
+ if (_currentKey != null) {
+ final proceed = await _showChangeWarning();
+ if (proceed != true) return;
+ }
+
+ setState(() => _loading = true);
+ try {
+ await ShopInBitService.instance.setCustomerKey(newKey);
+ setState(() {
+ _currentKey = newKey;
+ _manualKeyController.clear();
+ });
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.success,
+ message: "Customer key set",
+ context: context,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Failed to set key: $e",
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future<bool?> _showChangeWarning() async {
+ final result = await showDialog<bool>(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) => StackDialog(
+ title: "Save your current key",
+ message:
+ "Your current customer key is:\n\n$_currentKey\n\n"
+ "Changing your key will disconnect you from existing "
+ "ShopInBit conversations. Make sure you have saved your "
+ "current key before proceeding.",
+ leftButton: TextButton(
+ style: Theme.of(
+ context,
+ ).extension<StackColors>()!.getSecondaryEnabledButtonStyle(context),
+ 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(null),
+ child: Text("I saved my key", style: STextStyles.button(context)),
+ ),
+ ),
+ );
+
+ if (result == false || !mounted) return false;
+
+ return _showVerifyDialog();
+ }
+
+ Future<bool?> _showVerifyDialog() async {
+ _verifyKeyController.clear();
+ return showDialog<bool>(
+ context: context,
+ barrierDismissible: true,
+ builder: (ctx) {
+ return StatefulBuilder(
+ builder: (ctx, setDialogState) {
+ final matches = _verifyKeyController.text.trim() == _currentKey;
+ return StackDialog(
+ title: "Verify your key",
+ message:
+ "Enter your current customer key to confirm "
+ "you have saved it.",
+ leftButton: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: _verifyKeyController,
+ focusNode: _verifyKeyFocusNode,
+ style: STextStyles.field(ctx),
+ decoration: standardInputDecoration(
+ "Enter current key",
+ _verifyKeyFocusNode,
+ ctx,
+ ),
+ onChanged: (_) => setDialogState(() {}),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: TextButton(
+ style: Theme.of(ctx)
+ .extension<StackColors>()!
+ .getSecondaryEnabledButtonStyle(ctx),
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(ctx).copyWith(
+ color: Theme.of(
+ ctx,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: TextButton(
+ style: matches
+ ? Theme.of(ctx)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(ctx)
+ : Theme.of(ctx)
+ .extension<StackColors>()!
+ .getPrimaryDisabledButtonStyle(ctx),
+ onPressed: matches
+ ? () => Navigator.of(ctx).pop(true)
+ : null,
+ child: Text(
+ "Confirm",
+ style: STextStyles.button(ctx),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ 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.only(left: 12, top: 12, right: 12),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 24,
+ ),
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Customer Key",
+ style: STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "Your customer key identifies you "
+ "to ShopInBit. Save it to restore "
+ "access to your conversations on "
+ "another device. If you change it, "
+ "you will lose access to existing "
+ "conversations.",
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ const SizedBox(height: 16),
+ if (_currentKey != null) ...[
+ RoundedContainer(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ child: Row(
+ children: [
+ Expanded(
+ child: SelectableText(
+ _currentKey!,
+ style: STextStyles.field(context),
+ ),
+ ),
+ const SizedBox(width: 8),
+ GestureDetector(
+ onTap: () async {
+ await Clipboard.setData(
+ ClipboardData(
+ text: _currentKey!,
+ ),
+ );
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message:
+ "Key copied to clipboard",
+ context: context,
+ ),
+ );
+ }
+ },
+ child: SvgPicture.asset(
+ Assets.svg.copy,
+ width: 20,
+ height: 20,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ] else
+ Text(
+ "No key set",
+ style: STextStyles.itemSubtitle(context),
+ ),
+ const SizedBox(height: 16),
+ PrimaryButton(
+ label: _currentKey == null
+ ? "Generate key"
+ : "Generate new key",
+ enabled: !_loading,
+ onPressed: _generate,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Restore key",
+ style: STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "Enter a previously saved customer "
+ "key to restore access to your "
+ "ShopInBit conversations.",
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ const SizedBox(height: 12),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: _manualKeyController,
+ focusNode: _manualKeyFocusNode,
+ style: STextStyles.field(context),
+ decoration: standardInputDecoration(
+ "Enter customer key",
+ _manualKeyFocusNode,
+ context,
+ ),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ const SizedBox(height: 12),
+ PrimaryButton(
+ label: "Set key",
+ enabled:
+ !_loading &&
+ _manualKeyController.text
+ .trim()
+ .isNotEmpty,
+ onPressed: _setManualKey,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 12),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart
index d0747f7..4247186 100644
--- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart
+++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart
@@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_config.dart';
+import '../../providers/providers.dart';
import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/text_styles.dart';
@@ -25,6 +26,7 @@ import 'settings_menu/currency_settings/currency_settings.dart';
import 'settings_menu/language_settings/language_settings.dart';
import 'settings_menu/nodes_settings.dart';
import 'settings_menu/security_settings.dart';
+import 'settings_menu/shopinbit_settings.dart';
import 'settings_menu/syncing_preferences_settings.dart';
import 'settings_menu/tor_settings/tor_settings.dart';
@@ -39,69 +41,72 @@ class DesktopSettingsView extends ConsumerStatefulWidget {
}
class _DesktopSettingsViewState extends ConsumerState<DesktopSettingsView> {
- final List<Widget> contentViews = [
- const Navigator(
- key: Key("settingsBackupRestoreDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: BackupRestoreSettings.routeName,
- ), //b+r
- const Navigator(
- key: Key("settingsSecurityDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: SecuritySettings.routeName,
- ), //security
- const Navigator(
- key: Key("settingsCurrencyDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: CurrencySettings.routeName,
- ), //currency
- const Navigator(
- key: Key("settingsLanguageDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: LanguageOptionSettings.routeName,
- ),
- const Navigator(
- key: Key("settingsTorDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: TorSettings.routeName,
- ), //tor
- const Navigator(
- key: Key("settingsNodesDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: NodesSettings.routeName,
- ), //nodes
- const Navigator(
- key: Key("settingsSyncingPreferencesDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: SyncingPreferencesSettings.routeName,
- ), //syncing prefs
- if (AppConfig.hasFeature(AppFeature.themeSelection))
- const Navigator(
- key: Key("settingsAppearanceDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: AppearanceOptionSettings.routeName,
- ), //appearance
- const Navigator(
- key: Key("settingsAdvancedDesktopKey"),
- onGenerateRoute: RouteGenerator.generateRoute,
- initialRoute: AdvancedSettings.routeName,
- ), //advanced
- ];
-
@override
Widget build(BuildContext context) {
+ final familiarity = ref.watch(
+ prefsChangeNotifierProvider.select((v) => v.familiarity),
+ );
+
+ final List<Widget> contentViews = [
+ const Navigator(
+ key: Key("settingsBackupRestoreDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: BackupRestoreSettings.routeName,
+ ), //b+r
+ const Navigator(
+ key: Key("settingsSecurityDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: SecuritySettings.routeName,
+ ), //security
+ const Navigator(
+ key: Key("settingsCurrencyDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: CurrencySettings.routeName,
+ ), //currency
+ const Navigator(
+ key: Key("settingsLanguageDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: LanguageOptionSettings.routeName,
+ ),
+ const Navigator(
+ key: Key("settingsTorDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: TorSettings.routeName,
+ ), //tor
+ const Navigator(
+ key: Key("settingsNodesDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: NodesSettings.routeName,
+ ), //nodes
+ const Navigator(
+ key: Key("settingsSyncingPreferencesDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: SyncingPreferencesSettings.routeName,
+ ), //syncing prefs
+ if (AppConfig.hasFeature(AppFeature.themeSelection))
+ const Navigator(
+ key: Key("settingsAppearanceDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: AppearanceOptionSettings.routeName,
+ ), //appearance
+ const Navigator(
+ key: Key("settingsAdvancedDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: AdvancedSettings.routeName,
+ ), //advanced
+ if (familiarity >= 6)
+ const Navigator(
+ key: Key("settingsShopInBitDesktopKey"),
+ onGenerateRoute: RouteGenerator.generateRoute,
+ initialRoute: ShopInBitDesktopSettings.routeName,
+ ), //shopinbit
+ ];
return DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: const DesktopAppBar(
isCompactHeight: true,
leading: Row(
- children: [
- SizedBox(
- width: 24,
- height: 24,
- ),
- DesktopSettingsTitle(),
- ],
+ children: [SizedBox(width: 24, height: 24), DesktopSettingsTitle()],
),
),
body: Row(
@@ -110,14 +115,14 @@ class _DesktopSettingsViewState extends ConsumerState<DesktopSettingsView> {
padding: EdgeInsets.all(15.0),
child: Align(
alignment: Alignment.topLeft,
- child: SingleChildScrollView(
- child: SettingsMenu(),
- ),
+ child: SingleChildScrollView(child: SettingsMenu()),
),
),
Expanded(
- child: contentViews[
- ref.watch(selectedSettingsMenuItemStateProvider.state).state],
+ child:
+ contentViews[ref
+ .watch(selectedSettingsMenuItemStateProvider.state)
+ .state],
),
],
),
@@ -130,9 +135,6 @@ class DesktopSettingsTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return Text(
- "Settings",
- style: STextStyles.desktopH3(context),
- );
+ return Text("Settings", style: STextStyles.desktopH3(context));
}
}
diff --git a/lib/pages_desktop_specific/settings/settings_menu.dart b/lib/pages_desktop_specific/settings/settings_menu.dart
index 1ec12e5..27f816e 100644
--- a/lib/pages_desktop_specific/settings/settings_menu.dart
+++ b/lib/pages_desktop_specific/settings/settings_menu.dart
@@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import '../../app_config.dart';
+import '../../providers/providers.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
import 'settings_menu_item.dart';
@@ -20,31 +21,34 @@ import 'settings_menu_item.dart';
final selectedSettingsMenuItemStateProvider = StateProvider<int>((_) => 0);
class SettingsMenu extends ConsumerStatefulWidget {
- const SettingsMenu({
- super.key,
- });
+ const SettingsMenu({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SettingsMenuState();
}
class _SettingsMenuState extends ConsumerState<SettingsMenu> {
- final List<String> labels = [
- "Backup and restore",
- "Security",
- "Currency",
- "Language",
- "Tor settings",
- "Nodes",
- "Syncing preferences",
- if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance",
- "Advanced",
- ];
-
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final familiarity = ref.watch(
+ prefsChangeNotifierProvider.select((v) => v.familiarity),
+ );
+
+ final List<String> labels = [
+ "Backup and restore",
+ "Security",
+ "Currency",
+ "Language",
+ "Tor settings",
+ "Nodes",
+ "Syncing preferences",
+ if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance",
+ "Advanced",
+ if (familiarity >= 6) "ShopInBit",
+ ];
+
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -57,25 +61,23 @@ class _SettingsMenuState extends ConsumerState<SettingsMenu> {
Column(
mainAxisSize: MainAxisSize.min,
children: [
- if (i > 0)
- const SizedBox(
- height: 2,
- ),
+ if (i > 0) const SizedBox(height: 2),
SettingsMenuItem<int>(
icon: SvgPicture.asset(
Assets.svg.polygon,
width: 11,
height: 11,
- color: ref
+ color:
+ ref
.watch(
selectedSettingsMenuItemStateProvider
.state,
)
.state ==
i
- ? Theme.of(context)
- .extension<StackColors>()!
- .accentColorBlue
+ ? Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorBlue
: Colors.transparent,
),
label: labels[i],
@@ -83,9 +85,13 @@ class _SettingsMenuState extends ConsumerState<SettingsMenu> {
group: ref
.watch(selectedSettingsMenuItemStateProvider.state)
.state,
- onChanged: (newValue) => ref
- .read(selectedSettingsMenuItemStateProvider.state)
- .state = newValue,
+ onChanged: (newValue) =>
+ ref
+ .read(
+ selectedSettingsMenuItemStateProvider.state,
+ )
+ .state =
+ newValue,
),
],
),
diff --git a/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart
new file mode 100644
index 0000000..a3f45c7
--- /dev/null
+++ b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart
@@ -0,0 +1,412 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/svg.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 '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/rounded_white_container.dart';
+import '../../../widgets/stack_text_field.dart';
+
+class ShopInBitDesktopSettings extends ConsumerStatefulWidget {
+ const ShopInBitDesktopSettings({super.key});
+
+ static const String routeName = "/settingsMenuShopInBit";
+
+ @override
+ ConsumerState<ShopInBitDesktopSettings> createState() =>
+ _ShopInBitDesktopSettingsState();
+}
+
+class _ShopInBitDesktopSettingsState
+ extends ConsumerState<ShopInBitDesktopSettings> {
+ final _manualKeyController = TextEditingController();
+ final _manualKeyFocusNode = FocusNode();
+ final _verifyKeyController = TextEditingController();
+ final _verifyKeyFocusNode = FocusNode();
+
+ String? _currentKey;
+ bool _loading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _currentKey = ShopInBitService.instance.loadCustomerKey();
+ }
+
+ @override
+ void dispose() {
+ _manualKeyController.dispose();
+ _manualKeyFocusNode.dispose();
+ _verifyKeyController.dispose();
+ _verifyKeyFocusNode.dispose();
+ super.dispose();
+ }
+
+ Future<void> _generate() async {
+ if (_currentKey != null) {
+ final proceed = await _showChangeWarning();
+ if (proceed != true) return;
+ }
+
+ setState(() => _loading = true);
+ try {
+ final String key;
+ if (_currentKey != null) {
+ final resp = await ShopInBitService.instance.client.generateKey();
+ key = resp.valueOrThrow;
+ await ShopInBitService.instance.setCustomerKey(key);
+ } else {
+ key = await ShopInBitService.instance.ensureCustomerKey();
+ }
+ setState(() => _currentKey = key);
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.success,
+ message: "Customer key generated",
+ context: context,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Failed to generate key: $e",
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future<void> _setManualKey() async {
+ final newKey = _manualKeyController.text.trim();
+ if (newKey.isEmpty) return;
+
+ if (_currentKey != null) {
+ final proceed = await _showChangeWarning();
+ if (proceed != true) return;
+ }
+
+ setState(() => _loading = true);
+ try {
+ await ShopInBitService.instance.setCustomerKey(newKey);
+ setState(() {
+ _currentKey = newKey;
+ _manualKeyController.clear();
+ });
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.success,
+ message: "Customer key set",
+ context: context,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Failed to set key: $e",
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ setState(() => _loading = false);
+ }
+ }
+
+ Future<bool?> _showChangeWarning() async {
+ final result = await showDialog<bool>(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) => AlertDialog(
+ title: Text(
+ "Save your current key",
+ style: STextStyles.desktopH3(context),
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Your current customer key is:",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ ),
+ const SizedBox(height: 8),
+ SelectableText(
+ _currentKey!,
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ "Changing your key will disconnect you from existing "
+ "ShopInBit conversations. Make sure you have saved "
+ "your current key before proceeding.",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(null),
+ child: Text("I saved my key", style: STextStyles.button(context)),
+ ),
+ ],
+ ),
+ );
+
+ if (result == false || !mounted) return false;
+
+ return _showVerifyDialog();
+ }
+
+ Future<bool?> _showVerifyDialog() async {
+ _verifyKeyController.clear();
+ return showDialog<bool>(
+ context: context,
+ barrierDismissible: true,
+ builder: (ctx) {
+ return StatefulBuilder(
+ builder: (ctx, setDialogState) {
+ final matches = _verifyKeyController.text.trim() == _currentKey;
+ return AlertDialog(
+ title: Text("Verify your key", style: STextStyles.desktopH3(ctx)),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Enter your current customer key to confirm "
+ "you have saved it.",
+ style: STextStyles.desktopTextExtraExtraSmall(ctx),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: 400,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: _verifyKeyController,
+ focusNode: _verifyKeyFocusNode,
+ style: STextStyles.field(ctx),
+ decoration: standardInputDecoration(
+ "Enter current key",
+ _verifyKeyFocusNode,
+ ctx,
+ ),
+ onChanged: (_) => setDialogState(() {}),
+ ),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(ctx).copyWith(
+ color: Theme.of(
+ ctx,
+ ).extension<StackColors>()!.accentColorDark,
+ ),
+ ),
+ ),
+ TextButton(
+ onPressed: matches ? () => Navigator.of(ctx).pop(true) : null,
+ child: Text("Confirm", style: STextStyles.button(ctx)),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+
+ @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.key,
+ width: 48,
+ height: 48,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Customer Key",
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ "Your customer key identifies you to ShopInBit. "
+ "Save it to restore access to your conversations "
+ "on another device. If you change it, you will "
+ "lose access to existing conversations.",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ ),
+ const SizedBox(height: 20),
+ if (_currentKey != null) ...[
+ Text(
+ "Current key",
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark3,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ SelectableText(
+ _currentKey!,
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ const SizedBox(width: 12),
+ GestureDetector(
+ onTap: () async {
+ await Clipboard.setData(
+ ClipboardData(text: _currentKey!),
+ );
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Key copied to clipboard",
+ context: context,
+ ),
+ );
+ }
+ },
+ child: SvgPicture.asset(
+ Assets.svg.copy,
+ width: 20,
+ height: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark3,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ ] else
+ Padding(
+ padding: const EdgeInsets.only(bottom: 20),
+ child: Text(
+ "No key set",
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
+ ),
+ ),
+ PrimaryButton(
+ width: 210,
+ buttonHeight: ButtonHeight.m,
+ enabled: !_loading,
+ label: _currentKey == null
+ ? "Generate key"
+ : "Generate new key",
+ onPressed: _generate,
+ ),
+ const Padding(
+ padding: EdgeInsets.all(10.0),
+ child: Divider(thickness: 0.5),
+ ),
+ Text(
+ "Restore key",
+ style: STextStyles.desktopTextSmall(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ "Enter a previously saved customer key to "
+ "restore access to your ShopInBit "
+ "conversations.",
+ style: STextStyles.desktopTextExtraExtraSmall(context),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: 512,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: _manualKeyController,
+ focusNode: _manualKeyFocusNode,
+ style: STextStyles.field(context),
+ decoration: standardInputDecoration(
+ "Enter customer key",
+ _manualKeyFocusNode,
+ context,
+ ),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ PrimaryButton(
+ width: 210,
+ buttonHeight: ButtonHeight.m,
+ enabled:
+ !_loading &&
+ _manualKeyController.text.trim().isNotEmpty,
+ label: "Set key",
+ onPressed: _setManualKey,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index c1d72b3..6f55a90 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -97,6 +97,7 @@ import 'pages/shopinbit/shopinbit_step_4.dart';
import 'pages/shopinbit/shopinbit_ticket_detail.dart';
import 'pages/shopinbit/shopinbit_tickets_view.dart';
import 'pages/shopinbit/shopinbit_order_created.dart';
+import 'pages/shopinbit/shopinbit_settings_view.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';
@@ -244,6 +245,7 @@ import 'pages_desktop_specific/settings/settings_menu/desktop_support_view.dart'
import 'pages_desktop_specific/settings/settings_menu/language_settings/language_settings.dart';
import 'pages_desktop_specific/settings/settings_menu/nodes_settings.dart';
import 'pages_desktop_specific/settings/settings_menu/security_settings.dart';
+import 'pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart';
import 'pages_desktop_specific/settings/settings_menu/syncing_preferences_settings.dart';
import 'pages_desktop_specific/settings/settings_menu/tor_settings/tor_settings.dart';
import 'pages_desktop_specific/spark_coins/spark_coins_view.dart';
@@ -1117,6 +1119,13 @@ class RouteGenerator {
settings: RouteSettings(name: settings.name),
);
+ case ShopInBitSettingsView.routeName:
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => const ShopInBitSettingsView(),
+ settings: RouteSettings(name: settings.name),
+ );
+
case ShopInBitTicketDetail.routeName:
if (args is ShopInBitOrderModel) {
return getRoute(
@@ -2630,6 +2639,13 @@ class RouteGenerator {
settings: RouteSettings(name: settings.name),
);
+ case ShopInBitDesktopSettings.routeName:
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => const ShopInBitDesktopSettings(),
+ settings: RouteSettings(name: settings.name),
+ );
+
case DesktopSupportView.routeName:
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 279f0b1..75ec3c0 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -20,6 +20,20 @@ class ShopInBitService {
String? get customerKey => _customerKey;
+ String? loadCustomerKey() {
+ if (_customerKey != null) return _customerKey;
+ _customerKey =
+ DB.instance.get<dynamic>(
+ boxName: DB.boxNamePrefs,
+ key: "shopInBitCustomerKey",
+ )
+ as String?;
+ if (_customerKey != null) {
+ client.externalCustomerKey = _customerKey;
+ }
+ return _customerKey;
+ }
+
Future<String> ensureCustomerKey() async {
if (_customerKey != null) return _customerKey!;
_customerKey =
Why this scored 18/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.