Files
hash-wallet/lib/new-ui/pages/settings_page.dart
T
jwinterm dd449f2594 Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
2026-10-24 23:49:29 -04:00

471 lines
15 KiB
Dart

import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/coin_control_page.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
bool _alwaysVisible(DashboardViewModel _) => true;
bool _hasLightning(DashboardViewModel vm) => vm.hasLightning;
bool _hasSilentPayments(DashboardViewModel vm) => vm.hasSilentPayments;
bool _hasMweb(DashboardViewModel vm) => vm.hasMweb;
bool _hasWalletConnect(DashboardViewModel vm) => vm.hasWalletConnect;
bool _hasAccounts(DashboardViewModel vm) => vm.wallet.hasAccountsSupport;
bool _hasCoinControl(DashboardViewModel vm) => vm.wallet.hasCoinControl;
bool _requiresKeyImageSync(DashboardViewModel vm) =>
vm.wallet.type == WalletType.monero &&
[HardwareWalletType.cupcake, HardwareWalletType.trezor].contains(vm.wallet.hardwareWalletType);
class SettingsListItem {
const SettingsListItem(
this.iconPath,
this.title,
this.route, {
this.isCore = false,
this.requireAuth = false,
this.use2fa = _neverUse2fa,
this.condition = _alwaysVisible,
this.routeArgs,
this.routeArgsBuilder,
});
static bool _neverUse2fa(DashboardViewModel _) => false;
final String iconPath;
final String title;
final String route;
final bool isCore;
final Object? routeArgs;
final Object? Function(DashboardViewModel)? routeArgsBuilder;
final bool requireAuth;
final bool Function(DashboardViewModel) use2fa;
final bool Function(DashboardViewModel) condition;
}
class SettingsSectionData {
const SettingsSectionData(
this.title,
this.titleIconPath,
this.groups, {
this.titleColor,
this.headerSpacing = 16,
});
final String title;
final String titleIconPath;
final Map<String, List<ListItem>> groups;
final Color? titleColor;
final double headerSpacing;
}
class WalletSettingsResolver {
const WalletSettingsResolver();
String titleFor(WalletType walletType, S strings) =>
'${walletTypeToString(walletType)} ${strings.settings_title}'.trim();
String iconPathFor(WalletType walletType) =>
"assets/new-ui/network_icons/${walletType.name.toLowerCase()}.svg";
List<List<SettingsListItem>> resolveSections(DashboardViewModel viewModel) {
final visibleItems = <SettingsListItem>[
SettingsListItem(
"assets/new-ui/settings_row_icons/accounts.svg",
viewModel.wallet.type == WalletType.bitcoin ? S.current.accounts_onchain : S.current.accounts,
Routes.walletAccountsPage,
isCore: true,
condition: _hasAccounts,
routeArgsBuilder: (vm) => vm,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/nodes.svg",
S.current.nodes,
Routes.manageNodes,
isCore: true,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/coin-control.svg",
S.current.coin_control_settings,
Routes.unspentCoinsList,
isCore: true,
condition: _hasCoinControl,
routeArgs: const CoinControlPageArgs(canEdit: false),
),
SettingsListItem(
"assets/new-ui/settings_row_icons/lightning_username.svg",
"Lightning ${S.current.username}",
Routes.lightningUsernamePage,
condition: _hasLightning,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/silent-payments.svg",
S.current.silent_payments,
Routes.silentPaymentsSettings,
condition: _hasSilentPayments,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/mweb.svg",
S.current.litecoin_mweb,
Routes.mwebSettings,
condition: _hasMweb,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/wc.svg",
S.current.walletConnect,
Routes.walletConnectConnectionsListing,
condition: _hasWalletConnect,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/sync-balance.svg",
S.current.resync_device,
Routes.syncKeyImagesDevices,
routeArgs: const {"export-outputs": "export-outputs"},
condition: _requiresKeyImageSync,
),
].where((item) => item.condition(viewModel)).toList();
final coreItems = visibleItems.where((item) => item.isCore).toList();
final featureItems = visibleItems.where((item) => !item.isCore).toList();
return [
if (coreItems.isNotEmpty) coreItems,
if (featureItems.isNotEmpty) featureItems,
];
}
}
class SettingsPageSectionsResolver {
const SettingsPageSectionsResolver();
List<SettingsListItem> walletGeneral(S strings) => [
SettingsListItem(
'assets/new-ui/settings_row_icons/privacy.svg',
strings.privacy,
Routes.privacyPage,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/seed.svg',
strings.recovery_and_keys,
Routes.showKeysDisclaimer,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/other.svg',
strings.other,
Routes.otherSettingsPage,
),
];
List<SettingsListItem> appSettings() => [
SettingsListItem(
"assets/new-ui/settings_row_icons/connections.svg",
S.current.connections,
Routes.connectionSync,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/display.svg",
S.current.display,
Routes.displaySettingsPage,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/security.svg",
S.current.security,
Routes.securityBackupPage,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/backup.svg",
S.current.backup,
Routes.backup,
requireAuth: true,
use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings,
),
];
List<SettingsListItem> supportAndAbout(S strings) => [
SettingsListItem(
'assets/new-ui/settings_row_icons/support.svg',
strings.settings_support,
Routes.support,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/info.svg',
strings.about,
Routes.aboutPage,
),
];
}
class NewSettingsPage extends StatelessWidget {
const NewSettingsPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) {
return ModalNavigator(
parentContext: context,
rootPage: SettingsMainPage(
dashboardViewModel: dashboardViewModel,
authService: authService,
),
);
}
}
class SettingsMainPage extends StatelessWidget {
const SettingsMainPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
static const _walletSettingsResolver = WalletSettingsResolver();
static const _sectionsResolver = SettingsPageSectionsResolver();
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) => Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).settings_title,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context, rootNavigator: true).pop,
),
Expanded(
child: ListView(
controller: ModalScrollController.of(context),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 32),
children: [
for (final (index, section) in _sections(context).indexed) ...[
if (index > 0) ...[
const SizedBox(height: 24),
Divider(color: Theme.of(context).colorScheme.surfaceContainerHighest),
const SizedBox(height: 24),
],
_SettingsSectionHeader(
iconPath: section.titleIconPath,
title: section.title,
foregroundColor: section.titleColor,
),
SizedBox(height: section.headerSpacing),
for (final (groupIndex, group) in section.groups.entries.indexed) ...[
if (groupIndex > 0) const SizedBox(height: 24),
NewListSections(sections: {group.key: group.value}),
],
],
],
),
),
],
),
);
List<SettingsSectionData> _sections(BuildContext context) => [
SettingsSectionData(
dashboardViewModel.wallet.name,
"assets/new-ui/wallet-setting.svg",
{
"wallet_type_settings": [
ListItemRegularRow(
keyValue: "wallet_type_settings",
label: _walletSettingsResolver.titleFor(
dashboardViewModel.wallet.type,
S.of(context),
),
iconPath: _walletSettingsResolver.iconPathFor(dashboardViewModel.wallet.type),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => WalletSettingsPage(
dashboardViewModel: dashboardViewModel,
authService: authService,
),
),
),
),
],
"wallet_general": _buildRows(context, _sectionsResolver.walletGeneral(S.of(context))),
},
titleColor: Theme.of(context).colorScheme.primary,
headerSpacing: 20,
),
SettingsSectionData(
S.of(context).app_settings,
"assets/new-ui/hash-setting.svg",
{
"app_settings": _buildRows(context, _sectionsResolver.appSettings()),
"support_and_about":
_buildRows(context, _sectionsResolver.supportAndAbout(S.of(context))),
},
),
];
List<ListItem> _buildRows(BuildContext context, List<SettingsListItem> items) => items
.where((item) => item.condition(dashboardViewModel))
.map((item) => _buildRow(context, item))
.toList();
ListItemRegularRow _buildRow(BuildContext context, SettingsListItem item) => ListItemRegularRow(
keyValue: item.route,
label: item.title,
iconPath: item.iconPath,
onTap: () => _openItem(context, item),
);
Future<void> _openItem(BuildContext context, SettingsListItem item) async {
final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs;
if (item.requireAuth) {
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel),
route: item.route,
arguments: arguments,
);
return;
}
await Navigator.of(context).pushNamed(item.route, arguments: arguments);
}
}
class WalletSettingsPage extends StatelessWidget {
const WalletSettingsPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
static const _resolver = WalletSettingsResolver();
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) {
final strings = S.of(context);
final sections = _resolver.resolveSections(dashboardViewModel);
return Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: _resolver.titleFor(dashboardViewModel.wallet.type, strings),
titleLeadingWidget: HashImageWidget(
imageUrl: _resolver.iconPathFor(dashboardViewModel.wallet.type),
width: 24,
height: 24,
),
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: strings.seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: ListView(
controller: ModalScrollController.of(context),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 32),
children: [
for (var index = 0; index < sections.length; index++) ...[
if (index > 0) const SizedBox(height: 24),
NewListSections(
sections: {
'wallet_settings_$index': sections[index]
.map(
(item) => ListItemRegularRow(
keyValue: item.route,
label: item.title,
iconPath: item.iconPath,
onTap: () => _openItem(context, item),
),
)
.toList(),
},
),
],
],
),
),
],
),
);
}
Future<void> _openItem(BuildContext context, SettingsListItem item) async {
final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs;
if (item.requireAuth) {
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel),
route: item.route,
arguments: arguments,
);
return;
}
await Navigator.of(context).pushNamed(item.route, arguments: arguments);
}
}
class _SettingsSectionHeader extends StatelessWidget {
const _SettingsSectionHeader({
required this.iconPath,
required this.title,
this.foregroundColor,
});
final String iconPath;
final String title;
final Color? foregroundColor;
@override
Widget build(BuildContext context) {
final color = foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
HashImageWidget(
imageUrl: iconPath,
height: 20,
width: 20,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color),
),
),
],
),
);
}
}