This commit is contained in:
Serhii 2025-06-23 14:24:24 +00:00 committed by GitHub
commit 5cf1da7518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 4289 additions and 1518 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/images/fio.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.7872 5.31335C22.4522 2.73468 20.2819 0.702486 17.7091 0.308701C17.275 0.242161 15.6304 0 11.8207 0H11.7923C7.98156 0 7.16402 0.242161 6.72995 0.308701C4.2288 0.691576 1.94467 2.5176 1.3905 5.12682C1.12395 6.41183 1.0955 7.83642 1.14502 9.14324C1.21561 11.0172 1.2293 12.888 1.39366 14.7544C1.50728 15.9941 1.7055 17.224 1.98681 18.4348C2.51359 20.671 4.646 22.5319 6.7352 23.2911C8.972 24.0829 11.3775 24.2143 13.6824 23.6707C13.9359 23.6096 14.1863 23.5387 14.4336 23.458C14.9941 23.2737 15.6504 23.0675 16.133 22.7054C16.1396 22.7003 16.145 22.6937 16.1488 22.6862C16.1527 22.6786 16.1548 22.6702 16.1551 22.6617V20.8531C16.155 20.8452 16.1531 20.8373 16.1497 20.8302C16.1463 20.8231 16.1414 20.8168 16.1353 20.8119C16.1292 20.807 16.1222 20.8035 16.1147 20.8018C16.1072 20.8001 16.0994 20.8001 16.0919 20.8019C14.6152 21.1671 13.102 21.3502 11.5837 21.3473C8.97086 21.3473 8.26815 20.0634 8.0669 19.5289C7.90516 19.0669 7.80246 18.5851 7.76138 18.0956C7.76094 18.0873 7.76241 18.0791 7.76564 18.0716C7.76884 18.0641 7.77373 18.0574 7.7799 18.0522C7.78606 18.047 7.7933 18.0434 7.80108 18.0416C7.80885 18.0399 7.81689 18.04 7.8246 18.0421C9.2767 18.4048 10.7653 18.5879 12.259 18.5875C12.6183 18.5875 12.9765 18.5875 13.3358 18.5777C14.8381 18.5341 16.4216 18.4544 17.8998 18.1556C17.9367 18.1479 17.9735 18.1414 18.0051 18.1316C20.3367 17.6679 22.5555 16.2128 22.7809 12.528C22.7894 12.383 22.8104 11.0085 22.8104 10.858C22.8115 10.3464 22.9695 7.22883 22.7872 5.31335Z" fill="url(#paint0_linear_3451_1074)"/>
<path d="M19.0934 8.21051V14.6169H16.6418V8.39923C16.6418 7.09026 16.115 6.42267 15.0436 6.42267C13.8657 6.42267 13.2757 7.21242 13.2757 8.7723V12.1756H10.8388V8.7723C10.8388 7.21242 10.2478 6.42267 9.0699 6.42267C8.00473 6.42267 7.47267 7.09026 7.47267 8.39923V14.6169H5.02209V8.21051C5.02209 6.90154 5.34483 5.86164 5.99033 5.09079C6.65617 4.32176 7.52959 3.92688 8.61368 3.92688C9.86848 3.92688 10.8167 4.42647 11.4488 5.42457L12.0588 6.48485L12.6699 5.42457C13.302 4.42647 14.2502 3.92688 15.5029 3.92688C16.586 3.92688 17.4594 4.32176 18.1273 5.09079C18.7721 5.86091 19.0941 6.90081 19.0934 8.21051Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_3451_1074" x1="12" y1="0" x2="12" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#6364FF"/>
<stop offset="1" stop-color="#563ACC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/images/nostr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/images/ud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/images/x_social.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

View file

@ -42,10 +42,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.11.0"
version: "2.12.0"
blockchain_utils:
dependency: transitive
description:
@ -59,10 +59,10 @@ packages:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
build:
dependency: transitive
description:
@ -140,10 +140,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -156,10 +156,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@ -172,10 +172,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@ -243,10 +243,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.2"
ffi:
dependency: "direct main"
description:
@ -410,18 +410,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@ -450,10 +450,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@ -466,10 +466,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@ -532,10 +532,10 @@ packages:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
@ -715,26 +715,26 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
stream_transform:
dependency: transitive
description:
@ -747,26 +747,26 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.4"
timing:
dependency: transitive
description:
@ -820,10 +820,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.3.1"
watcher:
dependency: "direct overridden"
description:
@ -873,5 +873,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.6.0 <4.0.0"
dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.24.0"

View file

@ -30,7 +30,6 @@ class AuthService with Store {
Routes.modify2FAPage,
Routes.newWallet,
Routes.newWalletType,
Routes.addressBookAddContact,
Routes.restoreOptions,
];

View file

@ -29,8 +29,16 @@ import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/exchange_api_mode.dart';
import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart';
import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:cake_wallet/haven/cw_haven.dart';
import 'package:cake_wallet/src/screens/address_book/edit_address_page.dart';
import 'package:cake_wallet/src/screens/address_book/edit_contact_group_page.dart';
import 'package:cake_wallet/src/screens/address_book/contact_page.dart';
import 'package:cake_wallet/src/screens/address_book/edit_new_contact_group_page.dart';
import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart';
import 'package:cake_wallet/src/screens/address_book/supported_handles_page.dart';
import 'package:cake_wallet/src/screens/address_book/contact_list_page.dart';
import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart';
import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart';
import 'package:cake_wallet/src/screens/dev/network_requests.dart';
@ -92,8 +100,6 @@ import 'package:cake_wallet/src/screens/backup/backup_page.dart';
import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart';
import 'package:cake_wallet/src/screens/buy/buy_webview_page.dart';
import 'package:cake_wallet/src/screens/buy/webview_page.dart';
import 'package:cake_wallet/src/screens/contact/contact_list_page.dart';
import 'package:cake_wallet/src/screens/contact/contact_page.dart';
import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart';
import 'package:cake_wallet/src/screens/dashboard/desktop_dashboard_page.dart';
import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_sidebar_wrapper.dart';
@ -968,24 +974,68 @@ Future<void> setup({
getIt.registerFactory(() => WalletKeysViewModel(getIt.get<AppStore>()));
getIt.registerFactory(() => WalletKeysPage(getIt.get<WalletKeysViewModel>()));
getIt.registerFactory(() => AnimatedURModel(getIt.get<AppStore>()));
getIt.registerFactoryParam<AnimatedURPage, String, void>((String urQr, _) =>
AnimatedURPage(getIt.get<AnimatedURModel>(), urQr: urQr));
getIt.registerFactoryParam<ContactViewModel, ContactRecord?, void>(
(ContactRecord? contact, _) => ContactViewModel(_contactSource, contact: contact));
getIt.registerFactoryParam<ContactViewModel, AddressEditRequest?, void>(
(req, _) => ContactViewModel(_contactSource,getIt<AppStore>().wallet!,getIt<SettingsStore>(), request: req,),
);
getIt.registerFactoryParam<ContactListViewModel, CryptoCurrency?, void>(
(CryptoCurrency? cur, _) =>
ContactListViewModel(_contactSource, _walletInfoSource, cur, getIt.get<SettingsStore>()));
(cur, _) => ContactListViewModel(
_contactSource,
_walletInfoSource,
getIt<AppStore>().wallet!,
cur,
getIt<SettingsStore>(),
),
);
getIt.registerFactoryParam<ContactListPage, CryptoCurrency?, void>((CryptoCurrency? cur, _) =>
ContactListPage(getIt.get<ContactListViewModel>(param1: cur), getIt.get<AuthService>()));
getIt.registerFactoryParam<ContactListPage, CryptoCurrency?, void>(
(cur, _) => ContactListPage(
getIt.get<ContactListViewModel>(param1: cur),
getIt<AuthService>(),
),
);
getIt.registerFactoryParam<ContactPage, ContactRecord?, void>(
(ContactRecord? contact, _) => ContactPage(getIt.get<ContactViewModel>(param1: contact)));
getIt.registerFactoryParam<ContactPage, ContactRecord, void>(
(contact, _) => ContactPage(
contactViewModel: getIt.get<ContactViewModel>(
param1: AddressEditRequest.contact(contact),
),
),
);
getIt.registerFactoryParam<EditAddressPage, AddressEditRequest, void>(
(request, _) => EditAddressPage(
contactViewModel: getIt.get<ContactViewModel>(param1: request),
),
);
getIt.registerFactory<SupportedHandlesPage>(
() => SupportedHandlesPage(contactViewModel: getIt<ContactViewModel>()),
);
getIt.registerFactoryParam<EditContactGroupPage, ContactViewModel, void>(
(vm, _) => EditContactGroupPage(contactViewModel: vm),
);
getIt.registerFactoryParam<EditNewContactGroupPage, ParsedAddress, ContactRecord?>(
(parsedAddress, record) {
final vm = getIt<ContactViewModel>(
param1: AddressEditRequest.contact(record),
);
return EditNewContactGroupPage(
selectedParsedAddress: parsedAddress,
contactViewModel : vm,
);
},
);
getIt.registerFactory(() => AddressListPage(getIt.get<WalletAddressListViewModel>()));
@ -1368,10 +1418,12 @@ Future<void> setup({
getIt.registerFactory(() => YatService());
getIt.registerFactory(() => AddressResolver(
yatService: getIt.get<YatService>(),
wallet: getIt.get<AppStore>().wallet!,
settingsStore: getIt.get<SettingsStore>()));
getIt.registerLazySingleton<AddressResolverService>(
() => AddressResolverService(
yatService: getIt<YatService>(),
settingsStore: getIt<SettingsStore>(),
),
);
getIt.registerFactoryParam<FullscreenQRPage, QrViewData, void>(
(QrViewData viewData, _) => FullscreenQRPage(qrViewData: viewData));
@ -1509,9 +1561,9 @@ Future<void> setup({
getIt.registerFactory(() => DevSharedPreferencesPage(getIt.get<DevSharedPreferences>()));
getIt.registerFactory(() => DevSecurePreferencesPage(getIt.get<DevSecurePreferences>()));
getIt.registerFactory(() => BackgroundSyncLogsViewModel());
getIt.registerFactory(() => DevBackgroundSyncLogsPage(getIt.get<BackgroundSyncLogsViewModel>()));
getIt.registerFactory(() => DevNetworkRequests());

View file

@ -82,7 +82,6 @@ class Bip353Record {
}
static Future<String?> pickBip353AddressChoice(
BuildContext context,
String bip353Name,
Map<String, String> addressMap,
) async {
@ -90,13 +89,12 @@ class Bip353Record {
return addressMap.values.first;
}
final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap);
final chosenAddress = await _showAddressChoiceDialog(bip353Name, addressMap);
return chosenAddress;
}
static Future<String?> _showAddressChoiceDialog(
BuildContext context,
String bip353Name,
Map<String, String> addressMap,
) async {
@ -118,24 +116,24 @@ class Bip353Record {
String? selectedOriginalValue;
if (context.mounted) {
await showPopUp<void>(
context: context,
builder: (dialogContext) {
return AlertWithPickerOption(
alertTitle: S.of(context).multiple_addresses_detected + '\n$bip353Name',
alertTitleTextSize: 14,
alertSubtitle: S.of(context).please_choose_one + ':',
options: displayItems,
onOptionSelected: (Map<String, String> chosenItem) {
selectedOriginalValue = chosenItem['originalValue'];
},
alertBarrierDismissible: true,
);
},
);
}
return selectedOriginalValue;
// if (context.mounted) {
// await showPopUp<void>(
// context: context,
// builder: (dialogContext) {
// return AlertWithPickerOption(
// alertTitle: S.of(context).multiple_addresses_detected + '\n$bip353Name',
// alertTitleTextSize: 14,
// alertSubtitle: S.of(context).please_choose_one + ':',
// options: displayItems,
// onOptionSelected: (Map<String, String> chosenItem) {
// selectedOriginalValue = chosenItem['originalValue'];
// },
// alertBarrierDismissible: true,
// );
// },
// );
// }
return selectedOriginalValue = displayItems.first['originalValue'];
}
static String _truncate(String value, {int front = 6, int back = 6}) {

View file

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/hive_type_ids.dart';
import 'package:cw_core/keyable.dart';
@ -7,12 +10,25 @@ part 'contact.g.dart';
@HiveType(typeId: Contact.typeId)
class Contact extends HiveObject with Keyable {
Contact({required this.name, required this.address, CryptoCurrency? type, DateTime? lastChange})
: lastChange = lastChange ?? DateTime.now() {
if (type != null) {
raw = type.raw;
}
}
Contact({
required this.name,
this.address = '',
CryptoCurrency? type,
Map<String, Map<int, Map<String, String>>> parsedByHandle = const {},
Map<int, Map<String, String>> manualAddresses = const {},
Map<String, String> extraBlobs = const {},
AddressSource source = AddressSource.notParsed,
this.handle = '',
this.imagePath = '',
this.profileName = '',
this.description = '',
DateTime? lastChange,
}) : raw = type?.raw ?? 0,
_parsedJson = _encode(parsedByHandle),
_manualJson = _encode(manualAddresses),
extraJsonBlobs = extraBlobs,
sourceRaw = source.raw,
lastChange = lastChange ?? DateTime.now();
static const typeId = CONTACT_TYPE_ID;
static const boxName = 'Contacts';
@ -24,13 +40,66 @@ class Contact extends HiveObject with Keyable {
String address;
@HiveField(2, defaultValue: 0)
late int raw;
int raw;
@HiveField(3)
DateTime lastChange;
@HiveField(4, defaultValue: '')
String _parsedJson;
@HiveField(5, defaultValue: '')
String _manualJson;
@HiveField(6, defaultValue: '')
String handle;
@HiveField(7, defaultValue: '')
String imagePath;
@HiveField(8, defaultValue: '')
String profileName;
@HiveField(9, defaultValue: '')
String description;
@HiveField(10, defaultValue: 0)
int sourceRaw;
@HiveField(11, defaultValue: {})
Map<String, String> extraJsonBlobs;
AddressSource get source => AddressSourceIndex.fromRaw(sourceRaw);
set source(AddressSource v) => sourceRaw = v.raw;
CryptoCurrency get type => CryptoCurrency.deserialize(raw: raw);
set type(CryptoCurrency v) => raw = v.raw;
Map<String, Map<int, Map<String, String>>> get parsedByHandle => _decodeParsed(_parsedJson);
set parsedByHandle(Map<String, Map<int, Map<String, String>>> v) => _parsedJson = _encode(v);
Map<int, Map<String, String>> get manualAddresses => _decodeManual(_manualJson);
set manualAddresses(Map<int, Map<String, String>> v) => _manualJson = _encode(v);
Map<CryptoCurrency, Map<String, String>> get manualByCurrency => manualAddresses.map(
(k, v) => MapEntry(CryptoCurrency.deserialize(raw: k), v),
);
Map<CryptoCurrency, Map<String, String>> get parsedByCurrency {
final out = <CryptoCurrency, Map<String, String>>{};
for (final block in parsedByHandle.values) {
block.forEach((curRaw, lblMap) {
final cur = CryptoCurrency.deserialize(raw: curRaw);
out.putIfAbsent(cur, () => {})..addAll(lblMap);
});
}
return out;
}
@override
dynamic get keyIndex => key;
@ -40,5 +109,64 @@ class Contact extends HiveObject with Keyable {
@override
int get hashCode => key.hashCode;
void updateCryptoCurrency({required CryptoCurrency currency}) => raw = currency.raw;
static String _encode(Object value) => jsonEncode(_stringifyKeys(value));
static dynamic _stringifyKeys(dynamic obj) {
if (obj is Map) {
return obj.map(
(k, v) => MapEntry(k.toString(), _stringifyKeys(v)),
);
}
if (obj is Iterable) return obj.map(_stringifyKeys).toList();
return obj;
}
static Map<String, Map<int, Map<String, String>>> _decodeParsed(String s) {
if (s.isEmpty) return {};
final Map<String, dynamic> data = jsonDecode(s) as Map<String, dynamic>;
return data.map((handle, byCur) {
final inner = (byCur as Map<String, dynamic>).map((curRaw, lblMap) {
final int cur = int.parse(curRaw);
final labels = (lblMap as Map).cast<String, String>();
return MapEntry(cur, labels);
});
return MapEntry(handle, inner);
});
}
static Map<int, Map<String, String>> _decodeManual(String s) {
if (s.isEmpty) return {};
final Map<String, dynamic> data = jsonDecode(s) as Map<String, dynamic>;
return data.map((curRaw, lblMap) {
final int cur = int.parse(curRaw);
final labels = (lblMap as Map).cast<String, String>();
return MapEntry(cur, labels);
});
}
factory Contact.fromParsed(ParsedAddress p, {String? localImage}) {
final manual = <int, Map<String, String>>{};
p.manualAddressByCurrencyMap?.forEach(
(cur, addr) => manual[cur.raw] = {cur.title: addr},
);
final parsed = <String, Map<int, Map<String, String>>>{};
if (p.parsedAddressByCurrencyMap.isNotEmpty) {
final hKey = '${p.addressSource.label}-${p.handle}';
parsed[hKey] = {
for (final e in p.parsedAddressByCurrencyMap.entries) e.key.raw: {e.key.title: e.value}
};
}
return Contact(
name: p.profileName.isNotEmpty ? p.profileName : p.handle,
profileName: p.profileName,
handle: p.handle,
description: p.description,
source: p.addressSource,
imagePath: localImage ?? '',
manualAddresses: manual,
parsedByHandle: parsed,
);
}
}

View file

@ -1,7 +1,11 @@
import 'dart:io';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_base.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/entities/record.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
@ -10,39 +14,143 @@ part 'contact_record.g.dart';
class ContactRecord = ContactRecordBase with _$ContactRecord;
abstract class ContactRecordBase extends Record<Contact> with Store implements ContactBase {
ContactRecordBase(Box<Contact> source, Contact original)
ContactRecordBase(Box<Contact> box, Contact original)
: name = original.name,
address = original.address,
type = original.type,
lastChange = original.lastChange,
super(source, original);
handle = original.handle,
profileName = original.profileName,
description = original.description,
imagePath = original.imagePath,
sourceType = original.source,
manual = ObservableMap.of(original.manualByCurrency),
parsedBlocks = ObservableMap.of({
for (final h in original.parsedByHandle.entries)
h.key: {
for (final cur in h.value.entries)
CryptoCurrency.deserialize(raw: cur.key): Map<String, String>.of(cur.value)
}
}),
super(box, original);
@override
@observable
String name;
@override
String name, handle, profileName, description, imagePath;
@observable
String address;
AddressSource sourceType;
@override
@observable
CryptoCurrency type;
ObservableMap<CryptoCurrency, Map<String, String>> manual;
DateTime? lastChange;
@observable
ObservableMap<String, Map<CryptoCurrency, Map<String, String>>> parsedBlocks;
@computed
Map<CryptoCurrency, Map<String, String>> get parsedByCurrency {
final out = <CryptoCurrency, Map<String, String>>{};
parsedBlocks.forEach((_, byCur) {
byCur.forEach((cur, lbl) => out.putIfAbsent(cur, () => {})..addAll(lbl));
});
return out;
}
@computed
File? get avatarFile => imagePath.isEmpty ? null : File(imagePath);
@computed
ImageProvider get avatarProvider => (avatarFile?.existsSync() ?? false)
? FileImage(avatarFile!)
: const AssetImage('assets/images/profile.png');
@override
void toBind(Contact original) {
reaction((_) => name, (String name) => original.name = name);
reaction((_) => address, (String address) => original.address = address);
reaction((_) => type,
(CryptoCurrency currency) => original.updateCryptoCurrency(currency: currency));
void toBind(Contact c) {
reaction((_) => name, (v) => c.name = v);
reaction((_) => handle, (v) => c.handle = v);
reaction((_) => profileName, (v) => c.profileName = v);
reaction((_) => description, (v) => c.description = v);
reaction((_) => imagePath, (v) => c.imagePath = v);
reaction((_) => sourceType, (v) => c.source = v);
}
@override
void fromBind(Contact original) {
name = original.name;
address = original.address;
type = original.type;
void fromBind(Contact c) {
name = c.name;
handle = c.handle;
profileName = c.profileName;
description = c.description;
imagePath = c.imagePath;
sourceType = c.source;
}
@action
void setManualAddress(CryptoCurrency cur, String label, String addr) {
manual.putIfAbsent(cur, () => {})[label] = addr;
_flushManual();
}
@action
void removeManualAddress(CryptoCurrency cur, String label) {
final map = manual[cur];
if (map == null) return;
map.remove(label);
if (map.isEmpty) manual.remove(cur);
_flushManual();
}
@action
void setParsedAddress(String blockKey, CryptoCurrency cur, String label, String addr) {
final block = parsedBlocks.putIfAbsent(blockKey, () => {});
block.putIfAbsent(cur, () => {})[label] = addr;
parsedBlocks[blockKey] = {for (final e in block.entries) e.key: Map.of(e.value)};
_flushParsed();
}
@action
void removeParsedAddress(String blockKey, CryptoCurrency? cur, String? label) {
final block = parsedBlocks[blockKey];
if (block == null) return;
if (cur == null) {
parsedBlocks.remove(blockKey);
_flushParsed();
return;
}
final map = block[cur];
if (map == null) return;
if (label == null) {
block.remove(cur);
} else {
map.remove(label);
if (map.isEmpty) block.remove(cur);
}
if (block.isEmpty) {
parsedBlocks.remove(blockKey);
} else {
parsedBlocks[blockKey] = {for (final e in block.entries) e.key: Map.of(e.value)};
}
_flushParsed();
}
void _flushManual() {
original
..manualAddresses = {
for (final e in manual.entries) e.key.raw: Map<String, String>.of(e.value)
}
..lastChange = DateTime.now();
}
void _flushParsed() {
original
..parsedByHandle = {
for (final h in parsedBlocks.entries)
h.key: {for (final cur in h.value.entries) cur.key.raw: Map<String, String>.of(cur.value)}
}
..lastChange = DateTime.now();
}
@override
String address = '';
@override
CryptoCurrency type = CryptoCurrency.btc;
}

View file

@ -432,7 +432,11 @@ Future<void> ios_migrate_address_book(Box<Contact> contactSource) async {
final address = _item["address"] as String;
final name = _item["name"] as String;
return Contact(address: address, name: name, type: CryptoCurrency.fromString(type));
return Contact(parsedByHandle: {'handle':
{
5: {'label': address} //TODO fix this hardcoded value
}
}, manualAddresses: {}, name: name);
});
await contactSource.addAll(contacts);

View file

@ -1,33 +1,29 @@
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/core/yat_service.dart';
import 'package:cake_wallet/entities/emoji_string_extension.dart';
import 'package:cake_wallet/entities/ens_record.dart';
import 'package:cake_wallet/entities/fio_address_provider.dart';
import 'package:cake_wallet/entities/openalias_record.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/entities/unstoppable_domain_address.dart';
import 'package:cake_wallet/entities/emoji_string_extension.dart';
import 'package:cake_wallet/entities/wellknown_record.dart';
import 'package:cake_wallet/entities/zano_alias.dart';
import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:cake_wallet/mastodon/mastodon_api.dart';
import 'package:cake_wallet/nostr/nostr_api.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/twitter/twitter_api.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cake_wallet/entities/fio_address_provider.dart';
import 'package:flutter/cupertino.dart';
import 'bip_353_record.dart';
class AddressResolver {
AddressResolver({required this.yatService, required this.wallet, required this.settingsStore})
: walletType = wallet.type;
class AddressResolverService {
AddressResolverService({required this.yatService, required this.settingsStore}) {
_buildLookupTable();
}
final YatService yatService;
final WalletType walletType;
final WalletBase wallet;
final SettingsStore settingsStore;
static const unstoppableDomains = [
@ -165,30 +161,146 @@ class AddressResolver {
"zone"
];
static String? extractAddressByType(
{required String raw,
required CryptoCurrency type,
bool requireSurroundingWhitespaces = true}) {
var addressPattern = AddressValidator.getAddressFromStringPattern(type);
late final List<LookupEntry> _lookupTable;
if (addressPattern == null) {
throw Exception('Unexpected token: $type for getAddressFromStringPattern');
void _buildLookupTable() {
_lookupTable = [
LookupEntry(
source: AddressSource.twitter,
currencies: AddressSource.twitter.supportedCurrencies,
applies: (q) => settingsStore.lookupsTwitter && q.startsWith('@'),
// x handle example: @username
run: _lookupTwitter,
),
LookupEntry(
source: AddressSource.zanoAlias,
currencies: AddressSource.zanoAlias.supportedCurrencies,
applies: (q) => settingsStore.lookupsZanoAlias && q.startsWith('@'),
// zano handle example: @username
run: _lookupZano,
),
LookupEntry(
source: AddressSource.mastodon,
currencies: AddressSource.mastodon.supportedCurrencies,
applies: (q) =>
settingsStore.lookupsMastodon &&
q.startsWith('@') &&
q.contains('@', 1) &&
q.contains('.', 1),
// Mastodon handle example: @username@hostname.xxx
run: _lookupMastodon,
),
LookupEntry(
source: AddressSource.wellKnown,
currencies: AddressSource.wellKnown.supportedCurrencies,
applies: (q) => settingsStore.lookupsWellKnown && q.contains('.') && q.contains('@'),
// .well-known handle example:
run: _lookupWellKnown,
),
LookupEntry(
source: AddressSource.fio,
currencies: AddressSource.fio.supportedCurrencies,
applies: (q) => settingsStore.lookupsFio && !q.startsWith('@') && q.contains('@') && !q.contains('.'),
// FIO handle example: username@domain
run: _lookupFio,
),
LookupEntry(
source: AddressSource.yatRecord,
currencies: AddressSource.yatRecord.supportedCurrencies,
applies: (q) => settingsStore.lookupsYatService && q.hasOnlyEmojis,
// Yat handle example: 🐶🐾
run: _lookupYatService,
),
LookupEntry(
source: AddressSource.thorChain,
currencies: AddressSource.thorChain.supportedCurrencies,
applies: (q) => settingsStore.lookupsThorChain && q.isNotEmpty,
run: _lookupThorChain,
),
LookupEntry(
source: AddressSource.unstoppableDomains,
currencies: AddressSource.unstoppableDomains.supportedCurrencies,
applies: (q) {
if (!settingsStore.lookupsUnstoppableDomains) return false;
// Unstoppable Domains handle example: name.crypto
final formattedName = OpenaliasRecord.formatDomainName(q);
final domainParts = formattedName.split('.');
final name = domainParts.last;
return domainParts.length > 1 &&
domainParts.first.isNotEmpty &&
name.isNotEmpty &&
unstoppableDomains.any((domain) => name.trim() == domain);
},
run: _lookupsUnstoppableDomains,
),
LookupEntry(
source: AddressSource.bip353,
currencies: AddressSource.bip353.supportedCurrencies,
applies: (q) => settingsStore.lookupsBip353,
run: _lookupsBip353,
),
LookupEntry(
source: AddressSource.ens,
currencies: AddressSource.ens.supportedCurrencies,
applies: (q) => settingsStore.lookupsENS && q.endsWith('.eth'),
// ENS handle example: name.eth
run: _lookupEns,
),
LookupEntry(
source: AddressSource.openAlias,
currencies: AddressSource.openAlias.supportedCurrencies,
applies: (q) {
if (!settingsStore.lookupsOpenAlias) return false;
// OpenAlias handle example:
final formattedName = OpenaliasRecord.formatDomainName(q);
return formattedName.contains(".");
},
run: _lookupsOpenAlias,
),
LookupEntry(
source: AddressSource.nostr,
currencies: [CryptoCurrency.btc],
applies: (q) => settingsStore.lookupsNostr && isEmailFormat(q),
// Nostr handle example: name@domain
run: _lookupsNostr,
),
];
}
static String _cleanInput(String raw) => raw
.replaceAll(RegExp(r'[\u2028\u2029]'), '\n')
.replaceAll(RegExp(r'<[^>]+>'), ' ');
static String? extractAddressByType({
required String raw,
required CryptoCurrency type,
}) {
final pat = AddressValidator.getAddressFromStringPattern(type);
if (pat == null) {
throw StateError('Unknown pattern for $type');
}
if (requireSurroundingWhitespaces)
addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX";
final text = _cleanInput(raw);
final match = RegExp(addressPattern, multiLine: true).firstMatch(raw);
return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'),
(Match match) {
String group = match.group(0)!;
if (group.startsWith('bitcoincash:') ||
group.startsWith('nano_') ||
group.startsWith('ban_')) {
return group;
}
return '';
final regex = RegExp(r'(?:^|[^0-9A-Za-z])(' + pat + r')',
multiLine: true, caseSensitive: false);
final m = regex.firstMatch(text);
if (m == null) return null;
// 3. Strip BCH / NANO prefixes and punctuation just like before
final cleaned = m
.group(1)!
.replaceAllMapped(RegExp(r'[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), (m) {
final g = m.group(0)!;
return (g.startsWith('bitcoincash:') || g.startsWith('nano_') || g.startsWith('ban_'))
? g
: '';
});
return cleaned;
}
bool isEmailFormat(String address) {
@ -199,197 +311,363 @@ class AddressResolver {
return emailRegex.hasMatch(address);
}
Future<ParsedAddress> resolve(BuildContext context, String text, CryptoCurrency currency) async {
final ticker = currency.title;
Future<List<ParsedAddress>> resolve({
required String query,
required WalletBase wallet,
CryptoCurrency? currency,
}) async {
try {
// twitter handle example: @username
if (text.startsWith('@') && !text.substring(1).contains('@')) {
if (currency == CryptoCurrency.zano && settingsStore.lookupsZanoAlias) {
final formattedName = text.substring(1);
final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName);
if (zanoAddress != null && zanoAddress.isNotEmpty) {
return ParsedAddress.zanoAddress(
address: zanoAddress,
name: text,
);
}
}
if (settingsStore.lookupsTwitter) {
final formattedName = text.substring(1);
final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
final addressFromBio = extractAddressByType(
raw: twitterUser.description,
type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency));
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.fetchTwitterAddress(
address: addressFromBio,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name);
}
final tasks = <Future<ParsedAddress?>>[];
final pinnedTweet = twitterUser.pinnedTweet?.text;
if (pinnedTweet != null) {
final addressFromPinnedTweet = extractAddressByType(
raw: pinnedTweet,
type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency));
if (addressFromPinnedTweet != null) {
return ParsedAddress.fetchTwitterAddress(
address: addressFromPinnedTweet,
name: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name);
}
}
}
for (final entry in _lookupTable) {
if (!supportedSources.contains(entry.source)) continue;
if (!entry.applies(query)) continue;
final coins = currency == null
? entry.currencies.toList()
: (entry.currencies.contains(currency) ? [currency] : const <CryptoCurrency>[]);
if (coins.isEmpty) continue;
tasks.add(entry.run(query, coins, wallet));
}
// Mastodon example: @username@hostname.xxx
if (text.startsWith('@') && text.contains('@', 1) && text.contains('.', 1)) {
if (settingsStore.lookupsMastodon) {
final subText = text.substring(1);
final hostNameIndex = subText.indexOf('@');
final hostName = subText.substring(hostNameIndex + 1);
final userName = subText.substring(0, hostNameIndex);
final results = await Future.wait(tasks);
final mastodonUser =
await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName);
if (mastodonUser != null) {
String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: currency);
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.fetchMastodonAddress(
address: addressFromBio,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
} else {
final pinnedPosts =
await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName);
if (pinnedPosts.isNotEmpty) {
final userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n');
String? addressFromPinnedPost =
extractAddressByType(raw: userPinnedPostsText, type: currency);
if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) {
return ParsedAddress.fetchMastodonAddress(
address: addressFromPinnedPost,
name: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username);
}
}
}
}
}
}
// .well-known scheme:
if (text.contains('.') && text.contains('@')) {
if (settingsStore.lookupsWellKnown) {
final record =
await WellKnownRecord.fetchAddressAndName(formattedName: text, currency: currency);
if (record != null) {
return ParsedAddress.fetchWellKnownAddress(address: record.address, name: text);
}
}
}
if (!text.startsWith('@') && text.contains('@') && !text.contains('.')) {
final bool isFioRegistered = await FioAddressProvider.checkAvail(text);
if (isFioRegistered) {
final address = await FioAddressProvider.getPubAddress(text, ticker);
return ParsedAddress.fetchFioAddress(address: address, name: text);
}
}
if (text.hasOnlyEmojis) {
if (settingsStore.lookupsYatService) {
if (walletType != WalletType.haven) {
final addresses = await yatService.fetchYatAddress(text, ticker);
return ParsedAddress.fetchEmojiAddress(addresses: addresses, name: text);
}
}
}
final thorChainAddress = await ThorChainExchangeProvider.lookupAddressByName(text);
if (thorChainAddress != null && thorChainAddress.isNotEmpty) {
String? address =
thorChainAddress[ticker] ?? (ticker == 'RUNE' ? thorChainAddress['THOR'] : null);
if (address != null) {
return ParsedAddress.thorChainAddress(address: address, name: text);
}
}
final formattedName = OpenaliasRecord.formatDomainName(text);
final domainParts = formattedName.split('.');
final name = domainParts.last;
if (domainParts.length <= 1 || domainParts.first.isEmpty || name.isEmpty) {
return ParsedAddress(addresses: [text]);
}
if (unstoppableDomains.any((domain) => name.trim() == domain)) {
if (settingsStore.lookupsUnstoppableDomains) {
final address = await fetchUnstoppableDomainAddress(text, ticker);
if (address.isNotEmpty) {
return ParsedAddress.fetchUnstoppableDomainAddress(address: address, name: text);
}
}
}
final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker);
if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap);
if (chosenAddress != null) {
return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text);
}
}
if (text.endsWith(".eth")) {
if (settingsStore.lookupsENS) {
final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet);
if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") {
return ParsedAddress.fetchEnsAddress(name: text, address: address);
}
}
}
if (formattedName.contains(".")) {
if (settingsStore.lookupsOpenAlias) {
final txtRecord = await OpenaliasRecord.lookupOpenAliasRecord(formattedName);
if (txtRecord != null) {
final record = await OpenaliasRecord.fetchAddressAndName(
formattedName: formattedName, ticker: ticker.toLowerCase(), txtRecord: txtRecord);
return ParsedAddress.fetchOpenAliasAddress(record: record, name: text);
}
}
}
if (isEmailFormat(text)) {
final nostrProfile = await NostrProfileHandler.queryProfile(context, text);
if (nostrProfile?.relays != null) {
final nostrUserData =
await NostrProfileHandler.processRelays(context, nostrProfile!, text);
if (nostrUserData != null) {
String? addressFromBio = extractAddressByType(raw: nostrUserData.about, type: currency);
if (addressFromBio != null && addressFromBio.isNotEmpty) {
return ParsedAddress.nostrAddress(
address: addressFromBio,
name: text,
profileImageUrl: nostrUserData.picture,
profileName: nostrUserData.name);
}
}
}
}
return results.whereType<ParsedAddress>().toList();
} catch (e) {
printV(e.toString());
printV('Error resolving address: $e');
return [];
}
}
Future<ParsedAddress?> _lookupTwitter(
String text, List<CryptoCurrency> currencies, WalletBase wallet) async {
final formattedName = text.substring(1);
final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName);
if (twitterUser == null) return null;
final Map<CryptoCurrency, String> result = {};
for (final cur in currencies) {
final addressFromBio = extractAddressByType(
raw: twitterUser.description, type: CryptoCurrency.fromString(cur.title));
if (addressFromBio != null && addressFromBio.isNotEmpty) {
result[cur] = addressFromBio;
}
}
return ParsedAddress(addresses: [text]);
final pinnedTweet = twitterUser.pinnedTweet?.text;
if (pinnedTweet != null) {
for (final cur in currencies) {
final addressFromPinnedTweet =
extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(cur.title));
if (addressFromPinnedTweet != null && addressFromPinnedTweet.isNotEmpty) {
result[cur] = addressFromPinnedTweet;
}
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.twitter,
handle: text,
profileImageUrl: twitterUser.profileImageUrl,
profileName: twitterUser.name,
);
}
return null;
}
Future<ParsedAddress?> _lookupZano(
String text, List<CryptoCurrency> currencies, WalletBase _) async {
final formattedName = text.substring(1);
final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName);
if (zanoAddress != null && zanoAddress.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress},
addressSource: AddressSource.zanoAlias,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupMastodon(
String text, List<CryptoCurrency> currencies, WalletBase _) async {
final subText = text.substring(1);
final hostNameIndex = subText.indexOf('@');
final hostName = subText.substring(hostNameIndex + 1);
final userName = subText.substring(0, hostNameIndex);
final Map<CryptoCurrency, String> result = {};
final mastodonUser =
await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName);
if (mastodonUser != null) {
for (final cur in currencies) {
String? addressFromBio = extractAddressByType(raw: mastodonUser.note, type: cur);
if (addressFromBio != null && addressFromBio.isNotEmpty) {
result[cur] = addressFromBio;
}
}
final pinnedPosts =
await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName);
if (pinnedPosts.isNotEmpty) {
final userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n');
for (final cur in currencies) {
String? addressFromPinnedPost = extractAddressByType(raw: userPinnedPostsText, type: cur);
if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) {
result[cur] = addressFromPinnedPost;
}
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.mastodon,
handle: text,
profileImageUrl: mastodonUser.profileImageUrl,
profileName: mastodonUser.username,
);
}
}
return null;
}
Future<ParsedAddress?> _lookupWellKnown(
String text, List<CryptoCurrency> currencies, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currencies) {
final record = await WellKnownRecord.fetchAddressAndName(formattedName: text, currency: cur);
if (record != null) {
result[cur] = record.address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.wellKnown,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupFio(
String text, List<CryptoCurrency> currencies, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
final bool isFioRegistered = await FioAddressProvider.checkAvail(text);
if (!isFioRegistered) return null;
for (final cur in currencies) {
final address = await FioAddressProvider.getPubAddress(text, cur.title);
if (address.isNotEmpty) {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.fio,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupYatService(
String text, List<CryptoCurrency> currency, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currency) {
final addresses = await yatService.fetchYatAddress(text, cur.title);
if (addresses.isNotEmpty) {
result[cur] = addresses.first.address; //TODO: Handle multiple addresses
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.yatRecord,
handle: text,
);
}
}
return null;
}
Future<ParsedAddress?> _lookupThorChain(
String text, List<CryptoCurrency> currency, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
final thorChainAddress = await ThorChainExchangeProvider.lookupAddressByName(text);
if (thorChainAddress != null && thorChainAddress.isNotEmpty) {
for (final cur in currency) {
String? address =
thorChainAddress[cur.title] ?? (cur.title == 'RUNE' ? thorChainAddress['THOR'] : null);
if (address != null && address.isNotEmpty) {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.thorChain,
handle: text,
);
}
}
return null;
}
Future<ParsedAddress?> _lookupsUnstoppableDomains(
String text, List<CryptoCurrency> currency, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currency) {
final address = await fetchUnstoppableDomainAddress(text, cur.title);
if (address.isNotEmpty) {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
profileImageUrl: 'assets/images/profile.png',
profileName: text,
addressSource: AddressSource.unstoppableDomains,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupsBip353(
String text, List<CryptoCurrency> currency, WalletBase _) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currency) {
final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, cur.title);
if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
final address = bip353AddressMap['address'];
if (address != null && address.isNotEmpty) {
result[cur] = address;
}
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.bip353,
handle: text,
);
}
//
// if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
// final chosenAddress =
// await Bip353Record.pickBip353AddressChoice(text, bip353AddressMap); //TODO fix context
// if (chosenAddress != null) {
// return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text);
// }
// }
return null;
}
Future<ParsedAddress?> _lookupEns(
String text, List<CryptoCurrency> currency, WalletBase wallet) async {
final Map<CryptoCurrency, String> result = {};
for (final cur in currency) {
final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet);
if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") {
result[cur] = address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.ens,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupsOpenAlias(
String text, List<CryptoCurrency> currency, WalletBase _) async {
final formattedName = OpenaliasRecord.formatDomainName(text);
final txtRecord = await OpenaliasRecord.lookupOpenAliasRecord(formattedName);
final Map<CryptoCurrency, String> result = {};
for (final cur in currency) {
if (txtRecord == null) continue;
final record = await OpenaliasRecord.fetchAddressAndName(
formattedName: formattedName, ticker: cur.title.toLowerCase(), txtRecord: txtRecord);
if (record.address.isNotEmpty) {
result[cur] = record.address;
}
}
if (result.isNotEmpty) {
return ParsedAddress(
parsedAddressByCurrencyMap: result,
addressSource: AddressSource.openAlias,
handle: text,
);
}
return null;
}
Future<ParsedAddress?> _lookupsNostr(
String text, List<CryptoCurrency> currency, WalletBase _) async {
//TODO implement Nostr lookup logic
// final nostrProfile = await NostrProfileHandler.queryProfile(context, text);
// if (nostrProfile?.relays != null) {
// final nostrUserData =
// await NostrProfileHandler.processRelays(context, nostrProfile!, text);
//
// if (nostrUserData != null) {
// String? addressFromBio = extractAddressByType(raw: nostrUserData.about, type: currency);
// if (addressFromBio != null && addressFromBio.isNotEmpty) {
// return ParsedAddress.nostrAddress(
// address: addressFromBio,
// name: text,
// profileImageUrl: nostrUserData.picture,
// profileName: nostrUserData.name);
// }
// }
// }
return null;
}
}
class LookupEntry {
const LookupEntry({
required this.source,
required this.currencies,
required this.applies,
required this.run,
});
final AddressSource source;
final List<CryptoCurrency> currencies;
final bool Function(String query) applies;
final Future<ParsedAddress?> Function(
String query, List<CryptoCurrency> currencies, WalletBase wallet) run;
}

View file

@ -1,181 +1,140 @@
import 'package:cake_wallet/entities/openalias_record.dart';
import 'package:cake_wallet/entities/yat_record.dart';
import 'package:cw_core/crypto_currency.dart';
enum ParseFrom {
unstoppableDomains,
openAlias,
yatRecord,
fio,
notParsed,
twitter,
ens,
contact,
mastodon,
nostr,
thorChain,
wellKnown,
zanoAlias,
bip353
const supportedSources = [
AddressSource.twitter,
AddressSource.unstoppableDomains,
AddressSource.ens,
AddressSource.mastodon,
];
enum AddressSource {
twitter(
label: 'X',
iconPath: 'assets/images/x_social.png',
alias: '@username',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
unstoppableDomains(
label: 'Unstoppable Domains',
iconPath: 'assets/images/ud.png',
alias: 'domain.tld',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
openAlias(
label: 'OpenAlias',
iconPath: 'assets/images/open_alias.png',
alias: 'oa',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
yatRecord(
label: 'Yat',
iconPath: 'assets/images/yat_mini_logo.png',
alias: '🎂🎂🎂',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
fio(
label: 'FIO',
iconPath: 'assets/images/fio.png',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
ens(
label: 'Ethereum Name Service',
iconPath: 'assets/images/ens_icon.png',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth]),
mastodon(
label: 'Mastodon',
iconPath: 'assets/images/mastodon.svg',
alias: 'user@domain.tld',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
nostr(
label: 'Nostr',
iconPath: 'assets/images/nostr.png',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
thorChain(
label: 'ThorChain',
iconPath: 'assets/images/thorchain.png',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
wellKnown(
label: '.well-known',
iconPath: 'assets/icons/wk.svg',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
zanoAlias(
label: 'Zano Alias',
iconPath: 'assets/images/zano_icon.png',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
bip353(
label: 'BIP-353',
iconPath: '',
supportedCurrencies: [CryptoCurrency.xmr, CryptoCurrency.btc]),
contact(label: 'Contact', iconPath: '', supportedCurrencies: []),
notParsed(label: 'Unknown', iconPath: '', supportedCurrencies: []);
const AddressSource({
required this.label,
required this.iconPath,
this.alias = '',
this.supportedCurrencies = const <CryptoCurrency>[],
});
final String label;
final String iconPath;
final String alias;
final List<CryptoCurrency> supportedCurrencies;
}
extension AddressSourceIndex on AddressSource {
int get raw => index;
static AddressSource fromRaw(int raw) =>
AddressSource.values[raw.clamp(0, AddressSource.values.length - 1)];
}
extension AddressSourceNameParser on AddressSource {
static AddressSource fromLabel(String? text) {
if (text == null || text.trim().isEmpty) {
return AddressSource.notParsed;
}
final needle = text.trim().toLowerCase();
return AddressSource.values.firstWhere(
(src) => src.label.toLowerCase() == needle,
orElse: () => AddressSource.notParsed,
);
}
}
class ParsedAddress {
ParsedAddress({
required this.addresses,
this.name = '',
this.description = '',
const ParsedAddress({
required this.parsedAddressByCurrencyMap,
this.manualAddressByCurrencyMap,
this.addressSource = AddressSource.notParsed,
this.handle = '',
this.profileImageUrl = '',
this.profileName = '',
this.parseFrom = ParseFrom.notParsed,
this.description = '',
});
factory ParsedAddress.fetchEmojiAddress({
List<YatRecord>? addresses,
required String name,
}) {
if (addresses?.isEmpty ?? true) {
return ParsedAddress(addresses: [name], parseFrom: ParseFrom.yatRecord);
}
return ParsedAddress(
addresses: addresses!.map((e) => e.address).toList(),
name: name,
parseFrom: ParseFrom.yatRecord,
);
}
factory ParsedAddress.fetchUnstoppableDomainAddress({
String? address,
required String name,
}) {
if (address?.isEmpty ?? true) {
return ParsedAddress(addresses: [name]);
}
return ParsedAddress(
addresses: [address!],
name: name,
parseFrom: ParseFrom.unstoppableDomains,
);
}
factory ParsedAddress.fetchBip353AddressAddress ({
required String address,
required String name,
}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.bip353,
);
}
factory ParsedAddress.fetchOpenAliasAddress(
{required OpenaliasRecord record, required String name}) {
if (record.address.isEmpty) {
return ParsedAddress(addresses: [name]);
}
return ParsedAddress(
addresses: [record.address],
name: record.name,
description: record.description,
parseFrom: ParseFrom.openAlias,
);
}
factory ParsedAddress.fetchFioAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.fio,
);
}
factory ParsedAddress.fetchTwitterAddress(
{required String address,
required String name,
required String profileImageUrl,
required String profileName,
String? description}) {
return ParsedAddress(
addresses: [address],
name: name,
description: description ?? '',
profileImageUrl: profileImageUrl,
profileName: profileName,
parseFrom: ParseFrom.twitter,
);
}
factory ParsedAddress.fetchMastodonAddress(
{required String address,
required String name,
required String profileImageUrl,
required String profileName}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.mastodon,
profileImageUrl: profileImageUrl,
profileName: profileName,
);
}
factory ParsedAddress.fetchContactAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.contact,
);
}
factory ParsedAddress.fetchEnsAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.ens,
);
}
factory ParsedAddress.nostrAddress(
{required String address,
required String name,
required String profileImageUrl,
required String profileName}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.nostr,
profileImageUrl: profileImageUrl,
profileName: profileName,
);
}
factory ParsedAddress.thorChainAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.thorChain,
);
}
factory ParsedAddress.zanoAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.zanoAlias,
);
}
factory ParsedAddress.fetchWellKnownAddress({required String address, required String name}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.wellKnown,
);
}
final List<String> addresses;
final String name;
final String description;
final Map<CryptoCurrency, String> parsedAddressByCurrencyMap;
final Map<CryptoCurrency, String>? manualAddressByCurrencyMap;
final AddressSource addressSource;
final String handle;
final String profileImageUrl;
final String profileName;
final ParseFrom parseFrom;
final String description;
ParsedAddress copyWith({
Map<CryptoCurrency, String>? parsedAddressByCurrencyMap,
Map<CryptoCurrency, String>? manualAddressByCurrencyMap,
AddressSource? addressSource,
String? handle,
String? profileImageUrl,
String? profileName,
String? description,
}) {
return ParsedAddress(
parsedAddressByCurrencyMap: parsedAddressByCurrencyMap ?? this.parsedAddressByCurrencyMap,
manualAddressByCurrencyMap: manualAddressByCurrencyMap ?? this.manualAddressByCurrencyMap,
addressSource: addressSource ?? this.addressSource,
handle: handle ?? this.handle,
profileImageUrl: profileImageUrl ?? this.profileImageUrl,
profileName: profileName ?? this.profileName,
description: description ?? this.description,
);
}
}

View file

@ -85,6 +85,10 @@ class PreferencesKey {
static const lookupsOpenAlias = 'looks_up_open_alias';
static const lookupsENS = 'looks_up_ens';
static const lookupsWellKnown = 'looks_up_well_known';
static const lookupsFio = 'looks_up_fio';
static const lookupsNostr = 'looks_up_nostr';
static const lookupsThorChain = 'looks_up_thor_chain';
static const lookupsBip353 = 'looks_up_bip353';
static const usePayjoin = 'use_payjoin';
static const showPayjoinCard = 'show_payjoin_card';
static const showCameraConsent = 'show_camera_consent';

View file

@ -26,8 +26,7 @@ import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart';
import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart';
import 'package:cake_wallet/src/screens/connect_device/monero_hardware_wallet_options_page.dart';
import 'package:cake_wallet/src/screens/connect_device/select_hardware_wallet_account_page.dart';
import 'package:cake_wallet/src/screens/contact/contact_list_page.dart';
import 'package:cake_wallet/src/screens/contact/contact_page.dart';
import 'package:cake_wallet/src/screens/address_book/contact_list_page.dart';
import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart';
import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart';
import 'package:cake_wallet/src/screens/dashboard/edit_token_page.dart';
@ -147,6 +146,7 @@ import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'entities/parsed_address.dart';
import 'src/screens/buy/buy_sell_page.dart';
import 'src/screens/dashboard/pages/nft_import_page.dart';
@ -580,11 +580,6 @@ Route<dynamic> createRoute(RouteSettings settings) {
case Routes.pickerWalletAddress:
return MaterialPageRoute<void>(builder: (_) => getIt.get<AddressListPage>());
case Routes.addressBookAddContact:
return handleRouteWithPlatformAwareness(
(context) => getIt.get<ContactPage>(param1: settings.arguments as ContactRecord?),
);
case Routes.showKeys:
return handleRouteWithPlatformAwareness(
(context) => getIt.get<WalletKeysPage>(),

View file

@ -34,7 +34,13 @@ class Routes {
static const addressBook = '/address_book';
static const pickerAddressBook = '/picker_address_book';
static const pickerWalletAddress = '/picker_wallet_address';
static const addressBookAddContact = '/address_book_add_contact';
static const newContactWelcomePage = '/new_contact_welcome_page';
static const supportedHandlesPage = '/supported_handles_page';
static const editNewContactGroupPage = '/edit_new_contact_group_page';
static const editContactGroupPage = '/edit_contact_group_page';
static const editAddressesPage = '/edit_addresses_page';
static const editAddressPage = '/edit_address_page';
static const contactPage = '/contact_page';
static const showKeys = '/show_keys';
static const exchangeConfirm = '/exchange_confirm';
static const tradeHistory = '/trade_history';

View file

@ -1,18 +1,27 @@
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/contact_base.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/entities/wallet_list_order_types.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/rounded_icon_button.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart';
import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/add_contact_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -56,16 +65,8 @@ class ContactListPage extends BasePage {
// FIX-ME: Style
//shape: CircleBorder(),
onPressed: () async {
if (contactListViewModel.shouldRequireTOTP2FAForAddingContacts) {
authService.authenticateAction(
context,
route: Routes.addressBookAddContact,
conditionToDetermineIfToUse2FA:
contactListViewModel.shouldRequireTOTP2FAForAddingContacts,
);
} else {
await Navigator.of(context).pushNamed(Routes.addressBookAddContact);
}
await _showAddressBookBottomSheet(
context: context, contactListViewModel: contactListViewModel);
},
child: Offstage(),
),
@ -316,6 +317,7 @@ class _ContactListBodyState extends State<ContactListBody> {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Container(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 0),
child: FilteredList(
list: contacts,
updateFunction: widget.contactListViewModel.reorderAccordingToContactList,
@ -323,31 +325,29 @@ class _ContactListBodyState extends State<ContactListBody> {
shrinkWrap: true,
itemBuilder: (context, index) {
final contact = contacts[index];
final contactContent =
generateContactRaw(context, contact, contacts.length == index + 1);
return GestureDetector(
key: Key('${contact.name}'),
onTap: () async {
if (!widget.contactListViewModel.isEditable) {
Navigator.of(context).pop(contact);
return;
}
final isCopied = await DialogService.showNameAndAddressDialog(context, contact);
if (isCopied) {
await Clipboard.setData(ClipboardData(text: contact.address));
await showBar<void>(context, S.of(context).copied_to_clipboard);
}
return ContactAddressesExpansionTile(
key: Key(contact.key.toString()),
contentPadding: EdgeInsets.symmetric(horizontal: 8),
manualByCurrency: contact.manual,
fillColor: Theme.of(context).colorScheme.surfaceContainer,
title: _buildContactTitle(
context: context,
contact: contact,
contactListViewModel: widget.contactListViewModel),
onEditPressed: (cur, lbl) async {
await _showAddressBookBottomSheet(
context: context,
contactListViewModel: widget.contactListViewModel,
initialRoute: Routes.editAddressPage,
initialArgs: AddressEditRequest.address(
contact: contact,
currency: cur,
label: lbl,
kindIsManual: true,
),
);
},
behavior: HitTestBehavior.opaque,
child: widget.contactListViewModel.isEditable
? Slidable(
key: Key('${contact.key}'),
endActionPane: _actionPane(context, contact),
child: contactContent,
)
: contactContent,
onCopyPressed: (addr) => Clipboard.setData(ClipboardData(text: addr)),
);
},
),
@ -358,71 +358,60 @@ class _ContactListBodyState extends State<ContactListBody> {
);
}
Widget generateContactRaw(BuildContext context, ContactRecord contact, bool isLast) {
final image = contact.type.iconPath;
final currencyIcon = image != null
? Image.asset(image, height: 24, width: 24)
: const SizedBox(height: 24, width: 24);
return Column(
Widget _buildContactTitle(
{required BuildContext context,
required ContactRecord contact,
required ContactListViewModel contactListViewModel}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
key: Key('${contact.name}'),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Theme.of(context).colorScheme.surfaceContainer,
),
margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16),
padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
currencyIcon,
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 12),
child: Text(
contact.name,
style: Theme.of(context).textTheme.bodyMedium!,
),
))
],
),
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Image(
image: contact.avatarProvider,
width: 24,
height: 24,
fit: BoxFit.cover,
),
),
SizedBox(width: 6),
Text(
contact.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
RoundedIconButton(
icon: Icons.add,
onPressed: () async => await _showAddressBookBottomSheet(
context: context,
contactListViewModel: contactListViewModel,
initialRoute: Routes.editAddressPage,
initialArgs: AddressEditRequest.address(
contact: contact,
currency: walletTypeToCryptoCurrency(widget.contactListViewModel.wallet.type),
label: null,
kindIsManual: true,
))),
const SizedBox(width: 8),
RoundedIconButton(
icon: Icons.edit,
onPressed: () async => await _showAddressBookBottomSheet(
context: context,
contactListViewModel: contactListViewModel,
initialRoute: Routes.contactPage,
initialArgs: contact)),
],
),
],
);
}
ActionPane _actionPane(BuildContext context, ContactRecord contact) {
return ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.4,
children: [
SlidableAction(
onPressed: (_) async => await Navigator.of(context)
.pushNamed(Routes.addressBookAddContact, arguments: contact),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onSurface,
icon: Icons.edit,
label: S.of(context).edit,
),
SlidableAction(
onPressed: (_) async {
final isDelete = await DialogService.showAlertDialog(context);
if (isDelete) {
await widget.contactListViewModel.delete(contact);
}
},
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onSurface,
icon: CupertinoIcons.delete,
label: S.of(context).delete,
),
],
);
}
Widget filterButtonWidget(BuildContext context, ContactListViewModel contactListViewModel) {
final filterIcon = Image.asset(
'assets/images/filter_icon.png',
@ -472,6 +461,28 @@ class _ContactListBodyState extends State<ContactListBody> {
}
}
Future<void> _showAddressBookBottomSheet(
{required BuildContext context,
required ContactListViewModel contactListViewModel,
String? initialRoute,
Object? initialArgs}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext bottomSheetContext) {
return AddressBookBottomSheet(
onHandlerSearch: (query) async {
final address = await getIt
.get<AddressResolverService>()
.resolve(query: query, wallet: contactListViewModel.wallet);
return address;
},
initialRoute: initialRoute,
initialArgs: initialArgs,
);
});
}
class DialogService {
static Future<bool> showAlertDialog(BuildContext context) async {
return await showPopUp<bool>(
@ -500,9 +511,9 @@ class DialogService {
textAlign: TextAlign.center,
walletType: cryptoCurrencyToWalletType(contact.type),
evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
decoration: TextDecoration.none,
),
fontSize: 16,
decoration: TextDecoration.none,
),
),
rightButtonText: S.of(context).copy,
leftButtonText: S.of(context).cancel,

View file

@ -0,0 +1,176 @@
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/parse_address_from_domain.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/addresses_expansion_tile_widget.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/rounded_icon_button.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class ContactPage extends BasePage {
ContactPage({required this.contactViewModel});
final ContactViewModel contactViewModel;
@override
Widget? leading(BuildContext context) => null;
@override
Widget middle(BuildContext context) {
return Observer(
builder: (_) => Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Image(
width: 24,
height: 24,
image: contactViewModel.avatar,
fit: BoxFit.cover)),
const SizedBox(width: 12),
Text(
contactViewModel.name,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
color: titleColor(context),
),
),
],
),
),
);
}
@override
Widget trailing(BuildContext context) {
final onHandlerSearch = (query) async {
final address = await getIt
.get<AddressResolverService>()
.resolve(query: query as String, wallet: contactViewModel.wallet);
return address;
};
return SizedBox(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RoundedIconButton(icon: Icons.add, onPressed: () {
Navigator.pushNamed(
context,
Routes.newContactWelcomePage,
arguments: [onHandlerSearch, true, contactViewModel.record],
);
}),
const SizedBox(width: 8),
RoundedIconButton(icon: Icons.edit, onPressed: () {
Navigator.pushNamed(
context,
Routes.editContactGroupPage,
arguments: contactViewModel,
);
}),
],
),
);
}
@override
Widget body(BuildContext context) {
final theme = Theme.of(context);
final fillColor = currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark.withAlpha(100)
: CustomThemeColors.backgroundGradientColorLight;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ContactAddressesExpansionTile(
key: ValueKey(contactViewModel.name),
title: Text('Manual Addresses'),
fillColor: fillColor,
manualByCurrency: contactViewModel.manual,
onCopyPressed: (address) async =>
await Clipboard.setData(ClipboardData(text: address)),
onEditPressed: (cur, lbl) {
Navigator.pushNamed(
context,
Routes.editAddressPage,
arguments: AddressEditRequest.address(
contact: contactViewModel.record,
currency: cur,
label: lbl,
kindIsManual: true,
),
);
},
),
const SizedBox(height: 8),
...contactViewModel.parsedBlocks.entries.map((entry) {
final String handle = entry.key;
final stringSrc = handle.split('-').first;
final Map<CryptoCurrency, Map<String, String>> byCurrency = entry.value;
final src = AddressSourceNameParser.fromLabel(stringSrc);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ContactAddressesExpansionTile(
key: ValueKey('${contactViewModel.name}'),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageFromPath(
imagePath: src.iconPath,
height: 24,
width: 24,
),
const SizedBox(width: 6),
Expanded(
child: Text(
handle,
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
fillColor: fillColor,
manualByCurrency: byCurrency,
onCopyPressed: (addr) => Clipboard.setData(ClipboardData(text: addr)),
onEditPressed: (cur, lbl) {
Navigator.pushNamed(
context,
Routes.editAddressPage,
arguments: AddressEditRequest.address(
contact: contactViewModel.record,
currency: cur,
label: lbl,
kindIsManual: false,
handle: handle,
handleKey: entry.key,
),
);
},
),
);
}).toList(),
],
),
),
);
}
}

View file

@ -0,0 +1,480 @@
import 'dart:async';
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/rounded_icon_button.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
enum _InputMode { handle, address }
class NewContactWelcomePage extends BasePage {
NewContactWelcomePage({required this.onSearch, this.handleOnly = false, this.existingContact});
final Future<List<ParsedAddress>> Function(String query) onSearch;
final bool handleOnly;
final ContactRecord? existingContact;
@override
Widget body(BuildContext context) => NewContactWelcomePageBody(
currentTheme: currentTheme,
onSearch: onSearch,
handleOnly: handleOnly,
existingContact: existingContact);
}
class NewContactWelcomePageBody extends StatefulWidget {
const NewContactWelcomePageBody(
{required this.currentTheme,
required this.onSearch,
required this.handleOnly,
required this.existingContact});
final MaterialThemeBase currentTheme;
final Future<List<ParsedAddress>> Function(String query) onSearch;
final bool handleOnly;
final ContactRecord? existingContact;
@override
State<NewContactWelcomePageBody> createState() => _NewContactWelcomePageBodyState();
}
class _NewContactWelcomePageBodyState extends State<NewContactWelcomePageBody> {
_InputMode _mode = _InputMode.handle;
CryptoCurrency _selectedCurrency = CryptoCurrency.btc;
final _handleCtl = TextEditingController();
final _addressCtl = TextEditingController();
final _controller = TextEditingController();
final _focusNode = FocusNode();
final _debouncer = Duration(milliseconds: 700);
Timer? _debounceTimer;
List<ParsedAddress> _results = [];
bool _isSearching = false;
ParsedAddress? _selected;
String _typedAddress = '';
final String contentImage = 'assets/images/add_contact_coins_img.png';
final String contentText =
'Contacts allows you to create a profile with multiple addresses, as well as detect them automatically from social media profiles. Start by entering a social handle or an address manually';
@override
void dispose() {
_debounceTimer?.cancel();
_controller.dispose();
super.dispose();
}
void _handleChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(_debouncer, () async {
if (query.trim().isEmpty) {
setState(() => _results = []);
return;
}
setState(() => _isSearching = true);
try {
final res = await widget.onSearch(query);
if (mounted) setState(() => _results = res);
} finally {
if (mounted) setState(() => _isSearching = false);
}
});
}
void _pickCurrency() {
_focusNode.unfocus();
showPopUp<void>(
context: context,
builder: (_) => CurrencyPicker(
selectedAtIndex: CryptoCurrency.all.indexOf(_selectedCurrency),
items: CryptoCurrency.all,
title: S.of(context).please_select,
hintText: S.of(context).search_currency,
onItemSelected: (Currency c) => setState(() {
_selectedCurrency = c as CryptoCurrency;
}),
),
);
}
Widget _currencyPrefix(BuildContext ctx) {
final txtStyle = Theme.of(ctx).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w600);
return InkWell(
splashFactory: NoSplash.splashFactory,
onTap: _pickCurrency,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(ctx).colorScheme.outline,
width: 1,
)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageUtil.getImageFromPath(
imagePath: _selectedCurrency.iconPath ?? '',
height: 24,
width: 24,
),
const SizedBox(width: 6),
Text(_selectedCurrency.name.toUpperCase(), style: txtStyle),
const Icon(Icons.keyboard_arrow_down_sharp, size: 24),
],
),
),
);
}
Widget _segmentedSwitcher(BuildContext ctx) {
if (widget.handleOnly) return const SizedBox.shrink();
final txt = Theme.of(ctx).textTheme.bodyMedium;
final seg = (_InputMode m, String label) => ButtonSegment<_InputMode>(
value: m,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: _mode == m ? 1 : 0,
child: const Icon(Icons.check_circle, size: 16),
),
const SizedBox(width: 16),
Text(label, style: txt),
],
),
);
return SizedBox(
width: double.infinity,
child: SegmentedButton<_InputMode>(
segments: [seg(_InputMode.handle, 'handle'), seg(_InputMode.address, 'address')],
selected: <_InputMode>{_mode},
showSelectedIcon: false,
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
minimumSize: const WidgetStatePropertyAll(Size.fromHeight(44)),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 12)),
foregroundColor: const WidgetStatePropertyAll(Colors.white),
backgroundColor: const WidgetStatePropertyAll(Colors.transparent),
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
splashFactory: NoSplash.splashFactory,
),
onSelectionChanged: (s) {
final next = s.first;
setState(() {
_mode = next;
if (next == _InputMode.address) {
_selected = null;
_results.clear();
_isSearching = false;
_debounceTimer?.cancel();
} else {
_handleChanged(_handleCtl.text);
}
});
}),
);
}
Widget _buildInputField() {
final fillColor = widget.currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark
: CustomThemeColors.backgroundGradientColorLight;
final isHandleMode = widget.handleOnly ? true : _mode == _InputMode.handle;
final hasDropdown = _results.isNotEmpty || _isSearching;
final radius = BorderRadius.vertical(
top: const Radius.circular(12),
bottom: hasDropdown ? Radius.zero : const Radius.circular(12),
);
final outline = OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
);
return StandardTextFormFieldWidget(
focusNode: _focusNode,
controller: isHandleMode ? _handleCtl : _addressCtl,
labelText: isHandleMode ? 'Enter handle' : 'Enter address',
fillColor: fillColor,
onChanged: isHandleMode ? _handleChanged : (v) => setState(() => _typedAddress = v.trim()),
prefixIcon: isHandleMode
? SizedBox(height: 50)
: Padding(
padding: const EdgeInsets.all(8.0),
child: _currencyPrefix(context),
),
prefixIconConstraints: isHandleMode
? const BoxConstraints(
minWidth: 12,
maxWidth: 12,
minHeight: 50,
maxHeight: 50,
)
: null,
suffixIconConstraints: const BoxConstraints(
minWidth: 32,
maxWidth: 40,
minHeight: 30,
maxHeight: 30,
),
suffixIcon: RoundedIconButton(
icon: Icons.paste_outlined,
iconSize: 20,
width: 38,
height: 36,
onPressed: () async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim() ?? '';
if (text.isEmpty) return;
final isHandleMode = widget.handleOnly || _mode == _InputMode.handle;
if (isHandleMode) {
_handleCtl.text = text;
_handleChanged(text);
} else {
_addressCtl.text = text;
setState(() => _typedAddress = text);
}
},
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(6))),
),
addressValidator: AddressValidator(type: _selectedCurrency),
outline: outline,
);
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final fillColor = widget.currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark
: CustomThemeColors.backgroundGradientColorLight;
final hasDropdown = _results.isNotEmpty || _isSearching;
return LayoutBuilder(builder: (context, constraints) {
return SingleChildScrollView(
reverse: true,
padding: EdgeInsets.only(bottom: bottomInset),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ImageUtil.getImageFromPath(
imagePath: contentImage,
fit: BoxFit.contain,
height: 120,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
contentText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge!,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8, top: 16),
child: SizedBox(width: double.infinity, child: _segmentedSwitcher(context)),
),
const SizedBox(height: 12),
_buildInputField(),
if (hasDropdown)
Container(
constraints: const BoxConstraints(maxHeight: 85),
decoration: BoxDecoration(
color: fillColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
)),
child: _isSearching
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: ParsedAddressListWidget(
items: _results,
fillColor: fillColor,
selected: _selected,
onItemSelected: (selected) {
setState(() {
_selected = (_selected == selected) ? null : selected;
});
_focusNode.unfocus();
},
),
),
widget.handleOnly || _mode == _InputMode.handle
? InkWell(
splashFactory: NoSplash.splashFactory,
onTap: () {
_focusNode.unfocus();
Navigator.of(context).pushNamed(Routes.supportedHandlesPage);
},
child: SizedBox(
height: 36,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
Text('View supported handles',
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant),
],
),
),
),
)
: const SizedBox(height: 36),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 24, top: 8),
child: LoadingPrimaryButton(
text: S.of(context).seed_language_next,
onPressed: () {
_focusNode.unfocus();
if (_mode == _InputMode.handle) {
Navigator.pushNamed(
context,
Routes.editNewContactGroupPage,
arguments: [_selected!, widget.existingContact],
);
_selected = null;
} else {
final parsed = ParsedAddress(
parsedAddressByCurrencyMap: {},
manualAddressByCurrencyMap: {
_selectedCurrency: _typedAddress.trim(),
},
addressSource: AddressSource.contact,
handle: '',
profileName: '',
profileImageUrl: 'assets/images/profile.png',
description: '',
);
Navigator.pushNamed(
context,
Routes.editNewContactGroupPage,
arguments: [parsed, null],
);
}
},
color: Theme.of(context).colorScheme.primary,
width: 150,
height: 40,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: false,
isDisabled: widget.handleOnly
? _selected == null || _isSearching
: (_mode == _InputMode.handle
? _selected == null || _isSearching
: _typedAddress.isEmpty),
)),
],
),
),
);
});
}
}
class ParsedAddressListWidget extends StatelessWidget {
const ParsedAddressListWidget(
{super.key,
required this.items,
required this.fillColor,
this.onItemSelected,
this.selected});
final List<ParsedAddress> items;
final Color fillColor;
final ParsedAddress? selected;
final ValueChanged<ParsedAddress>? onItemSelected;
@override
Widget build(BuildContext context) {
return ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final src = items[index];
return ListTile(
title: Text(src.addressSource.label, style: Theme.of(context).textTheme.bodyLarge),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageUtil.getImageFromPath(
imagePath: src.profileImageUrl, height: 24, width: 24, borderRadius: 12),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120, minWidth: 80),
child: Text(src.handle, style: Theme.of(context).textTheme.bodyLarge)),
const SizedBox(width: 6),
SizedBox(
width: 24,
height: 24,
child: selected == src
? Icon(Icons.check_circle,
size: 20, color: Theme.of(context).colorScheme.primary)
: Icon(Icons.circle_outlined,
size: 20, color: Theme.of(context).colorScheme.primary)),
],
),
tileColor: fillColor,
dense: true,
visualDensity: VisualDensity(horizontal: 0, vertical: -3),
contentPadding: EdgeInsets.symmetric(horizontal: 12),
leading: ImageUtil.getImageFromPath(
imagePath: src.addressSource.iconPath, height: 24, width: 24),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
onTap: () => onItemSelected?.call(src),
);
},
);
}
}

View file

@ -0,0 +1,195 @@
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/rounded_icon_button.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class EditAddressPage extends BasePage {
EditAddressPage({required this.contactViewModel})
: _formKey = GlobalKey<FormState>(),
_labelController = TextEditingController(text: contactViewModel.label),
_addressController = TextEditingController(text: contactViewModel.address) {
_labelController.addListener(() => contactViewModel.label = _labelController.text);
_addressController.addListener(() => contactViewModel.address = _addressController.text);
}
@override
String get title => contactViewModel.isAddressEdit ? 'Edit Address' : 'Add Address';
final ContactViewModel contactViewModel;
final GlobalKey<FormState> _formKey;
final TextEditingController _labelController;
final TextEditingController _addressController;
@override
Widget body(BuildContext context) {
final fillColor = currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark.withAlpha(100)
: CustomThemeColors.backgroundGradientColorLight;
return Observer(builder: (_) {
final initialCurrency = contactViewModel.currency;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
ListTile(
title: Text(initialCurrency.fullName ?? initialCurrency.name,
style: Theme.of(context).textTheme.bodyMedium),
trailing: Icon(Icons.keyboard_arrow_down_outlined,
size: 24, color: Theme.of(context).colorScheme.onSurface),
tileColor: fillColor,
dense: true,
visualDensity: VisualDensity(horizontal: 0, vertical: -3),
contentPadding: EdgeInsets.symmetric(horizontal: 12),
leading: ImageUtil.getImageFromPath(
imagePath: initialCurrency.iconPath ?? '', height: 24, width: 24),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
onTap: () => _presentCurrencyPicker(context, contactViewModel)),
const SizedBox(height: 8),
StandardTextFormFieldWidget(
controller: _labelController,
labelText: 'Address label',
fillColor: fillColor,
suffixIcon: RoundedIconButton(
icon: Icons.paste_outlined,
onPressed: () async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text ?? '';
if (text.trim().isEmpty) return;
_labelController.text = text.trim();
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(6)))),
addressValidator: (value) {
if (value == null || value.trim().isEmpty) return 'Label cannot be empty';
return null;
},
),
const SizedBox(height: 8),
StandardTextFormFieldWidget(
controller: _addressController,
labelText: S.of(context).address,
fillColor: fillColor,
suffixIcon: RoundedIconButton(
icon: Icons.paste_outlined,
onPressed: () async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text ?? '';
if (text.trim().isEmpty) return;
_addressController.text = text.trim();
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(6)))),
addressValidator: AddressValidator(type: contactViewModel.currency)),
],
),
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Row(
children: [
if (contactViewModel.isAddressEdit)
Padding(
padding: const EdgeInsets.only(right: 8),
child: RoundedIconButton(
icon: Icons.delete_outline_rounded,
onPressed: () async {
await contactViewModel.deleteCurrentAddress();
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
width: 40,
height: 40,
iconSize: 30,
fillColor: Theme.of(context).colorScheme.errorContainer),
),
Expanded(
child: ElevatedButton(
onPressed: () {
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: fillColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
S.of(context).cancel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
return;
}
if (contactViewModel.mode == ContactEditMode.manualAddress) {
await contactViewModel.saveManualAddress();
} else {
await contactViewModel.saveParsedAddress();
}
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
S.of(context).save,
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
],
),
),
],
),
),
);
});
}
}
void _presentCurrencyPicker(BuildContext context, ContactViewModel contactViewModel) {
showPopUp<void>(
builder: (_) => CurrencyPicker(
selectedAtIndex: contactViewModel.currencies.indexOf(contactViewModel.currency),
items: contactViewModel.currencies,
title: S.of(context).please_select,
hintText: S.of(context).search_currency,
onItemSelected: (Currency item) => contactViewModel.currency = item as CryptoCurrency),
context: context,
);
}

View file

@ -0,0 +1,264 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/rounded_icon_button.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/standard_text_form_field_widget.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class EditContactGroupPage extends BasePage {
EditContactGroupPage({
required this.contactViewModel,
}) : _formKey = GlobalKey<FormState>(),
_groupLabelCtl = TextEditingController(text: contactViewModel.name) {
_groupLabelCtl.addListener(() {
contactViewModel.name = _groupLabelCtl.text;
});
}
final ContactViewModel contactViewModel;
final GlobalKey<FormState> _formKey;
final TextEditingController _groupLabelCtl;
@override
String? get title => 'Edit Contact';
@override
Widget body(BuildContext context) {
final theme = Theme.of(context);
final fillColor = currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark
: CustomThemeColors.backgroundGradientColorLight;
return LayoutBuilder(builder: (context, constraints) {
return SingleChildScrollView(
reverse: true,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
//edit avatar
},
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 44,
maxWidth: 44,
minHeight: 44,
maxHeight: 44,
),
child: Container(
decoration: BoxDecoration(
color: fillColor,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 1),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Image(
width: 24,
height: 24,
image: contactViewModel.avatar,
fit: BoxFit.cover,
),
),
const SizedBox(height: 1),
Text('Icon',
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 8,
color: theme.colorScheme.onSurfaceVariant,
)),
],
),
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Form(
key: _formKey,
child: StandardTextFormFieldWidget(
controller: _groupLabelCtl,
labelText: 'Address group name',
fillColor: fillColor,
addressValidator: (value) {
// final text = value?.trim() ?? '';
// if (text.isEmpty) return 'Name cannot be empty';
//
// final clash = contactViewModel.box.values.any(
// (c) =>
// c.name.toLowerCase() == text.toLowerCase() &&
// c.key != contactViewModel.contactRecord?.original.key,
// );
// return clash ? 'Group with this name already exists' : null;
},
),
),
),
],
),
),
),
contactViewModel.userHandles.isEmpty
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
'No alias services found',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: Observer(builder: (_) {
final userHandlesList = contactViewModel.userHandles.toList();
return Column(
children: [
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Alias Services',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
ListView.separated(
shrinkWrap: true,
primary: false,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: userHandlesList.length,
separatorBuilder: (context, index) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final item = userHandlesList[index];
return ListTile(
title: Text(item.src?.label ?? '',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
)),
subtitle: Text(item.label,
style: Theme.of(context).textTheme.bodySmall),
trailing: RoundedIconButton(
icon: Icons.delete_outline_rounded,
onPressed: () {
contactViewModel.deleteParsedBlock(item.handleKey);
},
iconSize: 20,
width: 28,
height: 28),
tileColor: fillColor,
dense: true,
visualDensity: VisualDensity(horizontal: 0, vertical: -3),
contentPadding: EdgeInsets.symmetric(horizontal: 12),
leading: ImageUtil.getImageFromPath(
imagePath: item.src?.iconPath ?? '',
height: 24,
width: 24),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
);
},
),
],
);
}),
],
),
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: RoundedIconButton(
icon: Icons.delete_outline_rounded,
onPressed: () async {
await contactViewModel.deleteContact();
if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
width: 40,
height: 40,
iconSize: 30,
fillColor: Theme.of(context).colorScheme.errorContainer),
),
Expanded(
child: ElevatedButton(
onPressed: () {
if (context.mounted) Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: fillColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
S.of(context).cancel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState != null &&
!_formKey.currentState!.validate()) {
return;
}
contactViewModel.name = _groupLabelCtl.text;
await contactViewModel.saveContactInfo();
if (context.mounted) Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
S.of(context).save,
maxLines: 1,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
],
),
),
],
),
),
),
);
});
}
}

View file

@ -0,0 +1,207 @@
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:flutter/material.dart';
class EditNewContactGroupPage extends BasePage {
EditNewContactGroupPage({
required this.selectedParsedAddress,
required this.contactViewModel,
}) : _formKey = GlobalKey<FormState>(),
_groupLabelCtl = TextEditingController(
text: selectedParsedAddress.profileName ?? '',
);
final ParsedAddress selectedParsedAddress;
final ContactViewModel contactViewModel;
final GlobalKey<FormState> _formKey;
final TextEditingController _groupLabelCtl;
@override
String? get title => 'New Contact';
@override
Widget body(BuildContext context) {
final theme = Theme.of(context);
final fillColor = currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark
: CustomThemeColors.backgroundGradientColorLight;
return LayoutBuilder(builder: (context, constraints) {
return SingleChildScrollView(
reverse: true,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
selectedParsedAddress.addressSource == AddressSource.contact
? 'Choose a contact name and icon'
: 'Contact info auto-detected from ${selectedParsedAddress.addressSource.label}',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 44,
maxWidth: 44,
minHeight: 44,
maxHeight: 44,
),
child: Container(
decoration: BoxDecoration(
color: fillColor,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 1),
child: Column(
children: [
ImageUtil.getImageFromPath(
imagePath: selectedParsedAddress.profileImageUrl,
height: 24,
width: 24,
borderRadius: 30.0),
const SizedBox(height: 1),
Text('Icon',
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 8,
color: theme.colorScheme.onSurfaceVariant,
)),
],
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Form(
key: _formKey,
child: TextFormField(
controller: _groupLabelCtl,
decoration: InputDecoration(
isDense: true,
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
labelText: 'Address group name',
labelStyle: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).hintColor),
hintStyle: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).hintColor),
fillColor: fillColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: Colors.transparent)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: Colors.transparent),
),
),
style: theme.textTheme.bodyMedium,
// validator: (value) {
// final text = value?.trim() ?? '';
// if (text.isEmpty) return 'Name cannot be empty';
//
// final clash = contactViewModel.box.values.any(
// (c) => c.name.toLowerCase() == text.toLowerCase(),
// );
// return clash ? 'Group with this name already exists' : null;
// },
),
),
),
],
),
),
],
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.only(bottom: 24, top: 8),
child: LoadingPrimaryButton(
text: 'Next',
width: 150,
height: 40,
onPressed: () async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (contactViewModel.record != null) {
final record = contactViewModel.record!;
final handleKey =
'${selectedParsedAddress.addressSource.label}-${selectedParsedAddress.handle}'
.trim();
selectedParsedAddress.parsedAddressByCurrencyMap.forEach((cur, addr) {
record.setParsedAddress(
handleKey,
cur,
cur.title,
addr.trim(),
);
});
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
} else {
final localImg = await ImageUtil.saveAvatarLocally(
selectedParsedAddress.profileImageUrl);
final newContact = Contact.fromParsed(
selectedParsedAddress.copyWith(
profileName: _groupLabelCtl.text.trim(),
),
localImage: localImg,
);
contactViewModel.box.add(newContact);
final record = ContactRecord(contactViewModel.box, newContact);
if (context.mounted) {
Navigator.pushNamed(
context,
Routes.contactPage,
arguments: record,
);
}
}
},
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: false,
isDisabled: false,
),
)
],
),
),
),
);
});
}
}

View file

@ -0,0 +1,52 @@
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cw_core/crypto_currency.dart';
enum EditMode {
contactFields,
manualAddressAdd,
manualAddressEdit,
parsedAddressAdd,
parsedAddressEdit,
}
class AddressEditRequest {
factory AddressEditRequest.contact(ContactRecord? c) => AddressEditRequest._(
contact: c,
mode: EditMode.contactFields,
);
factory AddressEditRequest.address({
required ContactRecord? contact,
required CryptoCurrency currency,
String? label,
required bool kindIsManual,
final String? handle,
String? handleKey,
}) =>
AddressEditRequest._(
contact: contact,
currency: currency,
label: label,
kindIsManual: kindIsManual,
handleKey: handleKey,
mode: label == null
? (kindIsManual ? EditMode.manualAddressAdd : EditMode.parsedAddressAdd)
: (kindIsManual ? EditMode.manualAddressEdit : EditMode.parsedAddressEdit),
);
const AddressEditRequest._({
this.contact,
this.currency,
this.label,
this.kindIsManual = false,
this.handleKey,
required this.mode,
});
final ContactRecord? contact;
final CryptoCurrency? currency;
final String? label;
final bool kindIsManual;
final EditMode mode;
final String? handleKey;
}

View file

@ -0,0 +1,23 @@
import 'package:cake_wallet/entities/parsed_address.dart';
class UserHandles {
factory UserHandles({required String handleKey}) {
assert(handleKey.isNotEmpty, 'handleKey cannot be empty');
final dash = handleKey.indexOf('-');
final prefix = dash == -1 ? handleKey : handleKey.substring(0, dash);
final label =
dash == -1 || dash == handleKey.length - 1 ? handleKey : handleKey.substring(dash + 1);
final src = AddressSourceNameParser.fromLabel(prefix);
return UserHandles._(handleKey, label, src);
}
const UserHandles._(this.handleKey, this.label, this.src);
final String handleKey;
final String label;
final AddressSource? src;
}

View file

@ -0,0 +1,41 @@
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/src/screens/address_book/widgets/handles_list_widget.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/themes/utils/custom_theme_colors.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:flutter/material.dart';
class SupportedHandlesPage extends BasePage {
SupportedHandlesPage({required this.contactViewModel});
final ContactViewModel contactViewModel;
@override
String? get title => 'Supported Handles';
@override
Widget body(BuildContext context) {
final fill = currentTheme.isDark
? CustomThemeColors.backgroundGradientColorDark
: CustomThemeColors.backgroundGradientColorLight;
final selectedInit = <AddressSource>[
for (final src in supportedSources)
if (contactViewModel.lookupMap[src.label]!.$1()) src
];
return HandlesListWidget(
items: supportedSources,
fillColor: fill,
initiallySelected: selectedInit,
onSelectionChanged: (sel) {
for (final src in supportedSources) {
final pair = contactViewModel.lookupMap[src.label]!;
pair.$2(sel.contains(src));
}
},
);
}
}

View file

@ -0,0 +1,136 @@
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef EditCallback = void Function(CryptoCurrency currency, String label);
typedef CopyCallback = void Function(String address);
typedef ManualByCurrencyMap = Map<CryptoCurrency, Map<String, String>>;
class ContactAddressesExpansionTile extends StatelessWidget {
const ContactAddressesExpansionTile({
super.key,
required this.manualByCurrency,
required this.fillColor,
this.title,
this.contentPadding,
this.onEditPressed,
this.onCopyPressed,
});
final ManualByCurrencyMap manualByCurrency;
final Color fillColor;
final Widget? title;
final EdgeInsetsGeometry? contentPadding;
final EditCallback? onEditPressed;
final CopyCallback? onCopyPressed;
Widget _circleIcon({
required BuildContext context,
required IconData icon,
required VoidCallback onPressed,
ShapeBorder? shape,
}) {
final colorScheme = Theme.of(context).colorScheme;
return RawMaterialButton(
onPressed: onPressed,
fillColor: colorScheme.surfaceContainerHighest,
elevation: 0,
constraints: const BoxConstraints.tightFor(width: 24, height: 24),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: shape ?? const CircleBorder(),
child: Icon(icon, size: 14, color: colorScheme.onSurface),
);
}
Widget _addressRow(
BuildContext c, {
required CryptoCurrency currency,
required String label,
required String address,
}) {
return ListTile(
title: Text(label, style: Theme.of(c).textTheme.bodyMedium),
subtitle: AddressFormatter.buildSegmentedAddress(
address: address,
walletType: cryptoCurrencyToWalletType(currency),
evenTextStyle: Theme.of(c).textTheme.labelSmall!,
visibleChunks: 4,
shouldTruncate: true,
),
leading: ImageUtil.getImageFromPath(
imagePath: currency.iconPath ?? '',
height: 24,
width: 24,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_circleIcon(
context: c,
icon: Icons.edit,
onPressed: () => onEditPressed?.call(currency, label),
),
const SizedBox(width: 8),
_circleIcon(
context: c,
icon: Icons.copy_all_outlined,
onPressed: () => onCopyPressed?.call(address),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
],
),
dense: true,
visualDensity: const VisualDensity(horizontal: 0, vertical: -4),
contentPadding: EdgeInsets.zero,
);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: fillColor,
),
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
listTileTheme: ListTileThemeData(
contentPadding: EdgeInsets.zero,
horizontalTitleGap: 4,
),
splashFactory : NoSplash.splashFactory,
splashColor : Colors.transparent,
highlightColor: Colors.transparent,
hoverColor : Colors.transparent,),
child: Padding(
padding: contentPadding ?? const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: ExpansionTile(
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: title ?? const SizedBox(),
children: [
for (final curEntry in manualByCurrency.entries) ...[
for (final labelEntry in curEntry.value.entries)
_addressRow(
context,
currency: curEntry.key,
label: labelEntry.key,
address: labelEntry.value,
),
],
],
),
),
),
);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/utils/image_utill.dart';
class HandlesListWidget extends StatefulWidget {
const HandlesListWidget(
{super.key,
required this.items,
required this.fillColor,
this.initiallySelected = const [],
this.onSelectionChanged});
final List<AddressSource> items;
final Color fillColor;
final List<AddressSource> initiallySelected;
final ValueChanged<Set<AddressSource>>? onSelectionChanged;
@override
State<HandlesListWidget> createState() => _HandlesListWidgetState();
}
class _HandlesListWidgetState extends State<HandlesListWidget> {
late final Set<AddressSource> _selected = widget.initiallySelected.toSet();
void _toggle(AddressSource src) {
setState(() {
if (_selected.contains(src)) {
_selected.remove(src);
} else {
_selected.add(src);
}
});
widget.onSelectionChanged?.call(_selected);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = theme.colorScheme.primary;
return ListView.separated(
shrinkWrap: true,
primary: false,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
itemCount: widget.items.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (ctx, i) {
final src = widget.items[i];
final isSelected = _selected.contains(src);
return ListTile(
title: Text(src.label, style: theme.textTheme.bodyMedium),
trailing: Text(src.alias, style: theme.textTheme.bodyMedium),
tileColor: widget.fillColor,
splashColor: Colors.transparent,
dense: true,
visualDensity: const VisualDensity(horizontal: 0, vertical: -3),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: isSelected
? Icon(Icons.check_circle, size: 20, color: iconColor)
: Icon(Icons.circle_outlined, size: 20, color: iconColor),
),
const SizedBox(width: 6),
ImageUtil.getImageFromPath(
imagePath: src.iconPath,
height: 24,
width: 24,
),
],
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
onTap: () => _toggle(src),
);
},
);
}
}

View file

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class RoundedIconButton extends StatelessWidget {
const RoundedIconButton(
{required this.icon,
required this.onPressed,
this.shape,
this.width,
this.height,
this.iconSize,
this.fillColor});
final IconData icon;
final VoidCallback onPressed;
final ShapeBorder? shape;
final double? width;
final double? height;
final double? iconSize;
final Color? fillColor;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return RawMaterialButton(
onPressed: onPressed,
fillColor: fillColor ?? colorScheme.surfaceContainerHighest,
elevation: 0,
constraints: BoxConstraints.tightFor(width: width ?? 24, height: height ?? 24),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: shape ?? const CircleBorder(),
child: Icon(icon, size: iconSize ?? 14, color: colorScheme.onSurface),
);
}
}

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
class StandardTextFormFieldWidget extends StatelessWidget {
const StandardTextFormFieldWidget({
super.key,
required this.controller,
required this.labelText,
required this.fillColor,
required this.addressValidator,
this.focusNode,
this.suffixIcon,
this.prefixIcon,
this.suffix,
this.onChanged,
this.suffixIconConstraints,
this.prefixIconConstraints,
this.outline,
});
final TextEditingController controller;
final String labelText;
final Color fillColor;
final String? Function(String?)? addressValidator;
final FocusNode? focusNode;
final Widget? suffixIcon;
final Widget? prefixIcon;
final Widget? suffix;
final BoxConstraints? suffixIconConstraints;
final BoxConstraints? prefixIconConstraints;
final void Function(String)? onChanged;
final InputBorder? outline;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
isDense: true,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
labelText: labelText,
labelStyle:
Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).hintColor),
hintStyle:
Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).hintColor),
fillColor: fillColor,
border: outline ?? OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
enabledBorder: outline ?? OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15)),
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: outline?.copyWith(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary)
),
suffixIcon: Padding(padding: const EdgeInsets.only(right: 10), child: suffixIcon),
suffix: suffix,
prefixIcon: prefixIcon,
prefixIconConstraints: prefixIconConstraints,
suffixIconConstraints: suffixIconConstraints ?? const BoxConstraints(
minWidth: 34,
maxWidth: 34,
minHeight: 24,
maxHeight: 24,
)),
style: Theme.of(context).textTheme.bodyMedium,
onChanged: onChanged,
validator: addressValidator,
);
}
}

View file

@ -509,8 +509,8 @@ class BuySellPage extends BasePage {
Future<String> fetchParsedAddress(
BuildContext context, String domain, CryptoCurrency currency) async {
final parsedAddress = await getIt.get<AddressResolver>().resolve(context, domain, currency);
final address = await extractAddressFromParsed(context, parsedAddress);
return address;
final parsedAddress = await getIt.get<AddressResolverService>().resolve(query: domain,wallet: buySellViewModel.wallet, currency: currency);
return ''; //TODO: fix return parsedAddress.addressByCurrencyMap[currency] ?? '';
}
}

View file

@ -10,6 +10,7 @@ import 'package:cake_wallet/src/screens/cake_pay/widgets/text_icon_button.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/base_alert_dialog.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
@ -389,9 +390,11 @@ class CakePayBuyCardDetailPage extends BasePage {
return ConfirmSendingBottomSheet(
key: ValueKey('send_page_confirm_sending_dialog_key'),
currentTheme: currentTheme,
footerType: FooterType.slideActionButton,
walletType: cakePayPurchaseViewModel.sendViewModel.walletType,
paymentId: S.of(popupContext).payment_id,
paymentIdValue: order?.orderId,
cakePayBuyCardViewModel: cakePayPurchaseViewModel,
expirationTime: cakePayPurchaseViewModel.formattedRemainingTime,
titleText: S.of(popupContext).confirm_transaction,
titleIconPath: cakePayPurchaseViewModel.sendViewModel.selectedCryptoCurrency.iconPath,
@ -405,7 +408,7 @@ class CakePayBuyCardDetailPage extends BasePage {
feeFiatAmount:
cakePayPurchaseViewModel.sendViewModel.pendingTransactionFeeFiatAmountFormatted,
outputs: cakePayPurchaseViewModel.sendViewModel.outputs,
onSlideComplete: () async {
onSlideActionComplete: () async {
Navigator.of(popupContext).pop();
cakePayPurchaseViewModel.sendViewModel.commitTransaction(context);
},

View file

@ -1,190 +0,0 @@
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/core/contact_name_validator.dart';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/address_text_field.dart';
import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart';
import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
class ContactPage extends BasePage {
ContactPage(this.contactViewModel)
: _formKey = GlobalKey<FormState>(),
_nameController = TextEditingController(),
_addressController = TextEditingController(),
_currencyTypeController = TextEditingController() {
_nameController.text = contactViewModel.name;
_addressController.text = contactViewModel.address;
_nameController.addListener(() => contactViewModel.name = _nameController.text);
_addressController.addListener(() => contactViewModel.address = _addressController.text);
autorun((_) => _currencyTypeController.text = contactViewModel.currency?.toString() ?? '');
}
@override
String get title => S.current.contact;
final ContactViewModel contactViewModel;
final GlobalKey<FormState> _formKey;
final TextEditingController _nameController;
final TextEditingController _currencyTypeController;
final TextEditingController _addressController;
bool _isEffectsApplied = false;
@override
Widget body(BuildContext context) {
final downArrow = Image.asset(
'assets/images/arrow_bottom_purple_icon.png',
color: Theme.of(context).colorScheme.primary,
height: 8,
);
_setEffects(context);
return Observer(
builder: (_) => ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
BaseTextFormField(
controller: _nameController,
hintText: S.of(context).contact_name,
validator: ContactNameValidator(),
),
Padding(
padding: EdgeInsets.only(top: 20),
child: Container(
child: InkWell(
onTap: () => _presentCurrencyPicker(context),
child: IgnorePointer(
child: BaseTextFormField(
controller: _currencyTypeController,
hintText: S.of(context).settings_currency,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
downArrow,
SizedBox(width: 16),
],
),
),
),
),
),
),
if (contactViewModel.currency != null)
Padding(
padding: EdgeInsets.only(top: 20),
child: AddressTextField(
controller: _addressController,
options: [
AddressTextFieldOption.paste,
AddressTextFieldOption.qrCode,
],
buttonColor: Theme.of(context).colorScheme.surfaceContainerHighest,
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
validator: AddressValidator(type: contactViewModel.currency!),
),
)
],
),
),
bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24),
bottomSection: Row(
children: <Widget>[
Expanded(
child: PrimaryButton(
onPressed: () {
contactViewModel.reset();
_nameController.text = '';
_addressController.text = '';
},
text: S.of(context).reset,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
SizedBox(width: 20),
Expanded(
child: Observer(
builder: (_) => PrimaryButton(
onPressed: () async {
if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
return;
}
await contactViewModel.save();
},
text: S.of(context).save,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isDisabled: !contactViewModel.isReady,
),
),
)
],
)),
);
}
void _presentCurrencyPicker(BuildContext context) {
showPopUp<void>(
builder: (_) => CurrencyPicker(
selectedAtIndex: contactViewModel.currency != null
? contactViewModel.currencies.indexOf(contactViewModel.currency!)
: -1,
items: contactViewModel.currencies,
title: S.of(context).please_select,
hintText: S.of(context).search_currency,
onItemSelected: (Currency item) => contactViewModel.currency = item as CryptoCurrency),
context: context,
);
}
void _onContactSavingFailure(BuildContext context, String error) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.current.contact,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop(),
);
});
}
void _onContactSavedSuccessfully(BuildContext context) => Navigator.of(context).pop();
void _setEffects(BuildContext context) {
if (_isEffectsApplied) {
return;
}
_isEffectsApplied = true;
reaction((_) => contactViewModel.state, (ExecutionState state) {
if (state is FailureState) {
_onContactSavingFailure(context, state.error);
}
if (state is ExecutedSuccessfullyState) {
_onContactSavedSuccessfully(context);
}
});
}
}

View file

@ -641,9 +641,8 @@ class ExchangePage extends BasePage {
Future<String> fetchParsedAddress(
BuildContext context, String domain, CryptoCurrency currency) async {
final parsedAddress = await getIt.get<AddressResolver>().resolve(context, domain, currency);
final address = await extractAddressFromParsed(context, parsedAddress);
return address;
final parsedAddress = await getIt.get<AddressResolverService>().resolve(query: domain, currency: currency, wallet: exchangeViewModel.wallet);
return ''; //TODO: fix return parsedAddress.addressByCurrencyMap[currency] ?? '';
}
void _showFeeAlert(BuildContext context) async {

View file

@ -3,6 +3,7 @@ import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/mobile_exchange_cards_section.dart';
import 'package:cake_wallet/src/screens/exchange_trade/widgets/exchange_trade_card_item_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
@ -274,6 +275,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
return ConfirmSendingBottomSheet(
key: ValueKey('exchange_trade_page_confirm_sending_bottom_sheet_key'),
currentTheme: widget.currentTheme,
footerType: FooterType.slideActionButton,
walletType: widget.exchangeTradeViewModel.sendViewModel.walletType,
titleText: S.of(bottomSheetContext).confirm_transaction,
titleIconPath:
@ -293,10 +295,11 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
feeFiatAmount: widget.exchangeTradeViewModel.sendViewModel
.pendingTransactionFeeFiatAmountFormatted,
outputs: widget.exchangeTradeViewModel.sendViewModel.outputs,
onSlideComplete: () async {
if (bottomSheetContext.mounted) {
Navigator.of(bottomSheetContext).pop();
}
onSlideActionComplete: () async {
if (bottomSheetContext.mounted) {
Navigator.of(bottomSheetContext).pop();
}
widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context);
},
);
@ -306,9 +309,10 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
});
}
if (state is TransactionCommitted) {
WidgetsBinding.instance.addPostFrameCallback(
(_) async {
(_) async {
if (!mounted) return;
await showModalBottomSheet<void>(
@ -317,16 +321,17 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
builder: (BuildContext bottomSheetContext) {
return InfoBottomSheet(
currentTheme: widget.currentTheme,
footerType: FooterType.singleActionButton,
titleText: S.of(bottomSheetContext).transaction_sent,
contentImage: 'assets/images/birthday_cake.png',
actionButtonText: S.of(bottomSheetContext).close,
actionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'),
actionButton: () {
singleActionButtonText: S.of(bottomSheetContext).close,
singleActionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'),
onSingleActionButtonPressed: () {
Navigator.of(bottomSheetContext).pop();
if (mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(
Routes.dashboard,
(route) => false,
(route) => false,
);
}
RequestReviewHandler.requestReview();

View file

@ -4,6 +4,7 @@ import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/integrations/deuro/widgets/interest_card_widget.dart';
import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_card_widget.dart';
import 'package:cake_wallet/src/screens/integrations/deuro/widgets/savings_edit_sheet.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/gradient_background.dart';
@ -128,11 +129,11 @@ class DEuroSavingsPage extends BasePage {
feeValue: tx.feeFormatted,
feeFiatAmount: "",
outputs: [],
onSlideComplete: () async {
onSlideActionComplete: () async {
Navigator.of(bottomSheetContext).pop(true);
dEuroViewModel.commitTransaction();
},
change: tx.change,
change: tx.change, footerType: FooterType.slideActionButton,
),
);
@ -159,11 +160,11 @@ class DEuroSavingsPage extends BasePage {
feeValue: tx.feeFormatted,
feeFiatAmount: "",
outputs: [],
onSlideComplete: () {
onSlideActionComplete: () {
Navigator.of(bottomSheetContext).pop(true);
dEuroViewModel.commitApprovalTransaction();
},
change: tx.change,
change: tx.change, footerType: FooterType.slideActionButton,
),
);
@ -180,12 +181,13 @@ class DEuroSavingsPage extends BasePage {
isDismissible: false,
builder: (BuildContext bottomSheetContext) => InfoBottomSheet(
currentTheme: currentTheme,
footerType: FooterType.singleActionButton,
titleText: S.of(bottomSheetContext).transaction_sent,
contentImage: 'assets/images/birthday_cake.png',
content: S.of(bottomSheetContext).deuro_tx_commited_content,
actionButtonText: S.of(bottomSheetContext).close,
actionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'),
actionButton: () => Navigator.of(bottomSheetContext).pop(),
singleActionButtonText: S.of(bottomSheetContext).close,
singleActionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'),
onSingleActionButtonPressed: () => Navigator.of(bottomSheetContext).pop(),
),
);
});

View file

@ -1,4 +1,5 @@
import 'package:cake_wallet/entities/qr_view_data.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart';
@ -317,14 +318,13 @@ class QRWidget extends StatelessWidget {
titleText: S.of(context).payjoin_unavailable_sheet_title,
content: S.of(context).payjoin_unavailable_sheet_content,
currentTheme: currentTheme,
isTwoAction: true,
leftButtonText: S.of(context).learn_more,
actionLeftButton: () => launchUrl(
Uri.parse("https://docs.cakewallet.com/cryptos/bitcoin/#payjoin"),
mode: LaunchMode.externalApplication,
),
rightButtonText: S.of(context).ok,
actionRightButton: () => Navigator.of(context).pop(),
footerType: FooterType.doubleActionButton,
doubleActionLeftButtonText: S.of(context).learn_more,
onLeftActionButtonPressed: () => launchUrl(
Uri.parse("https://docs.cakewallet.com/cryptos/bitcoin/#payjoin"),
mode: LaunchMode.externalApplication),
doubleActionRightButtonText: S.of(context).ok,
onRightActionButtonPressed: () => Navigator.of(context).pop(),
),
);
}

View file

@ -17,12 +17,14 @@ import 'package:cake_wallet/src/widgets/adaptable_page_view.dart';
import 'package:cake_wallet/src/widgets/add_template_button.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/keyboard_done_button.dart';
import 'package:cake_wallet/src/widgets/picker.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart';
import 'package:cake_wallet/src/widgets/simple_checkbox.dart';
import 'package:cake_wallet/src/widgets/template_tile.dart';
import 'package:cake_wallet/src/widgets/trail_button.dart';
import 'package:cake_wallet/utils/payment_request.dart';
@ -569,6 +571,7 @@ class SendPage extends BasePage {
key: ValueKey('send_page_confirm_sending_dialog_key'),
titleText: S.of(bottomSheetContext).confirm_transaction,
currentTheme: currentTheme,
footerType: FooterType.slideActionButton,
walletType: sendViewModel.walletType,
titleIconPath: sendViewModel.selectedCryptoCurrency.iconPath,
currency: sendViewModel.selectedCryptoCurrency,
@ -581,7 +584,7 @@ class SendPage extends BasePage {
feeValue: sendViewModel.pendingTransaction!.feeFormatted,
feeFiatAmount: sendViewModel.pendingTransactionFeeFiatAmountFormatted,
outputs: sendViewModel.outputs,
onSlideComplete: () async {
onSlideActionComplete: () async {
Navigator.of(bottomSheetContext).pop(true);
sendViewModel.commitTransaction(context);
},
@ -602,68 +605,38 @@ class SendPage extends BasePage {
return;
}
newContactAddress = newContactAddress ?? sendViewModel.newContactAddress();
// newContactAddress = newContactAddress ?? sendViewModel.newContactAddress(); //TODO: fix this
//
// if (newContactAddress?.address != null &&
// isRegularElectrumAddress(newContactAddress!.address)) {
// newContactAddress = null;
// }
if (newContactAddress?.address != null &&
isRegularElectrumAddress(newContactAddress!.address)) {
newContactAddress = null;
}
bool showContactSheet = (newContactAddress != null && sendViewModel.showAddressBookPopup);
// bool showContactSheet = (newContactAddress != null && sendViewModel.showAddressBookPopup);
await showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (BuildContext bottomSheetContext) {
return showContactSheet && sendViewModel.ocpRequest == null
? InfoBottomSheet(
currentTheme: currentTheme,
showDontAskMeCheckbox: true,
onCheckboxChanged: (value) => sendViewModel.setShowAddressBookPopup(!value),
titleText: S.of(bottomSheetContext).transaction_sent,
contentImage: 'assets/images/contact.png',
contentImageColor: Theme.of(context).colorScheme.onSurface,
content: S.of(bottomSheetContext).add_contact_to_address_book,
isTwoAction: true,
leftButtonText: 'No',
rightButtonText: 'Yes',
actionLeftButton: () {
Navigator.of(bottomSheetContext).pop();
if (context.mounted) {
Navigator.of(context)
.pushNamedAndRemoveUntil(Routes.dashboard, (route) => false);
}
RequestReviewHandler.requestReview();
newContactAddress = null;
},
actionRightButton: () {
Navigator.of(bottomSheetContext).pop();
RequestReviewHandler.requestReview();
if (context.mounted) {
Navigator.of(context).pushNamed(Routes.addressBookAddContact,
arguments: newContactAddress);
}
newContactAddress = null;
},
)
: InfoBottomSheet(
currentTheme: currentTheme,
titleText: S.of(bottomSheetContext).transaction_sent,
contentImage: 'assets/images/birthday_cake.png',
actionButtonText: S.of(bottomSheetContext).close,
actionButtonKey: ValueKey('send_page_sent_dialog_ok_button_key'),
actionButton: () {
Navigator.of(bottomSheetContext).pop();
Future.delayed(Duration.zero, () {
if (context.mounted) {
Navigator.of(context)
.pushNamedAndRemoveUntil(Routes.dashboard, (route) => false);
}
RequestReviewHandler.requestReview();
newContactAddress = null;
});
},
);
return InfoBottomSheet(
currentTheme: currentTheme,
footerType: FooterType.singleActionButton,
titleText: S.of(bottomSheetContext).transaction_sent,
contentImage: 'assets/images/birthday_cake.png',
singleActionButtonText: S.of(bottomSheetContext).close,
singleActionButtonKey: ValueKey('send_page_transaction_sent_button_key'),
onSingleActionButtonPressed: () {
Navigator.of(bottomSheetContext).pop();
Future.delayed(Duration.zero, () {
if (context.mounted) {
Navigator.of(context)
.pushNamedAndRemoveUntil(Routes.dashboard, (route) => false);
}
RequestReviewHandler.requestReview();
newContactAddress = null;
});
},
);
},
);
@ -710,14 +683,14 @@ class SendPage extends BasePage {
dialogContext = context;
return InfoBottomSheet(
currentTheme: currentTheme,
footerType: FooterType.singleActionButton,
titleText: S.of(context).proceed_on_device,
contentImage:
'assets/images/hardware_wallet/ledger_nano_x.png',
'assets/images/hardware_wallet/ledger_nano_x.png',
contentImageColor: Theme.of(context).colorScheme.onSurface,
content: S.of(context).proceed_on_device_description,
isTwoAction: false,
actionButtonText: S.of(context).cancel,
actionButton: () {
singleActionButtonText: S.of(context).cancel,
onSingleActionButtonPressed: () {
sendViewModel.state = InitialExecutionState();
Navigator.of(context).pop();
},

View file

@ -1,135 +1,134 @@
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:flutter/material.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'choose_yat_address_alert.dart';
Future<String> extractAddressFromParsed(
BuildContext context,
ParsedAddress parsedAddress) async {
if (!context.mounted) return parsedAddress.addresses.first;
var title = '';
var content = '';
var address = '';
var profileImageUrl = '';
var profileName = '';
switch (parsedAddress.parseFrom) {
case ParseFrom.unstoppableDomains:
title = S.of(context).address_detected;
content = S.of(context).address_from_domain(parsedAddress.name);
address = parsedAddress.addresses.first;
break;
case ParseFrom.ens:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (ENS)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.openAlias:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (OpenAlias)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.wellKnown:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Well-Known)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.fio:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (FIO)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.twitter:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)');
address = parsedAddress.addresses.first;
profileImageUrl = parsedAddress.profileImageUrl;
profileName = parsedAddress.profileName;
break;
case ParseFrom.mastodon:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)');
address = parsedAddress.addresses.first;
profileImageUrl = parsedAddress.profileImageUrl;
profileName = parsedAddress.profileName;
break;
case ParseFrom.nostr:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Nostr NIP-05)');
address = parsedAddress.addresses.first;
profileImageUrl = parsedAddress.profileImageUrl;
profileName = parsedAddress.profileName;
break;
case ParseFrom.thorChain:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (ThorChain)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.zanoAlias:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (Zano Alias)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.bip353:
title = S.of(context).address_detected;
content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)');
address = parsedAddress.addresses.first;
break;
case ParseFrom.yatRecord:
if (parsedAddress.name.isEmpty) {
title = S.of(context).yat_error;
content = S.of(context).yat_error_content;
address = parsedAddress.addresses.first;
break;
}
title = S.of(context).address_detected;
content = S.of(context).address_from_yat(parsedAddress.name);
if (parsedAddress.addresses.length == 1) {
address = parsedAddress.addresses.first;
break;
}
content += S.of(context).choose_address;
address = await showPopUp<String?>(
context: context,
builder: (context) => PopScope(
child: ChooseYatAddressAlert(
alertTitle: title,
alertContent: content,
addresses: parsedAddress.addresses,
),
canPop: false,
),
) ??
'';
if (address.isEmpty) {
return parsedAddress.name;
}
return address;
case ParseFrom.contact:
case ParseFrom.notParsed:
return parsedAddress.addresses.first;
}
await showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: title,
headerTitleText: profileName.isEmpty ? null : profileName,
headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
alertContent: content,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop(),
),
);
return address;
}
// import 'package:cake_wallet/entities/parsed_address.dart';
// import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
// import 'package:cake_wallet/utils/show_pop_up.dart';
// import 'package:flutter/material.dart';
// import 'package:cake_wallet/generated/i18n.dart';
// import 'choose_yat_address_alert.dart';
//
// Future<String> extractAddressFromParsed(
// BuildContext context,
// ParsedAddress parsedAddress) async {
// var title = '';
// var content = '';
// var address = '';
// var profileImageUrl = '';
// var profileName = '';
//
// switch (parsedAddress.addressSource) {
// case AddressSource.unstoppableDomains:
// title = S.of(context).address_detected;
// content = S.of(context).address_from_domain(parsedAddress.handle);
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.ens:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (ENS)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.openAlias:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (OpenAlias)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.wellKnown:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (Well-Known)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.fio:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (FIO)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.twitter:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (Twitter)');
// address = parsedAddress.addresses.first;
// profileImageUrl = parsedAddress.profileImageUrl;
// profileName = parsedAddress.profileName;
// break;
// case AddressSource.mastodon:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (Mastodon)');
// address = parsedAddress.addresses.first;
// profileImageUrl = parsedAddress.profileImageUrl;
// profileName = parsedAddress.profileName;
// break;
// case AddressSource.nostr:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (Nostr NIP-05)');
// address = parsedAddress.addresses.first;
// profileImageUrl = parsedAddress.profileImageUrl;
// profileName = parsedAddress.profileName;
// break;
// case AddressSource.thorChain:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (ThorChain)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.zanoAlias:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (Zano Alias)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.bip353:
// title = S.of(context).address_detected;
// content = S.of(context).extracted_address_content('${parsedAddress.handle} (BIP-353)');
// address = parsedAddress.addresses.first;
// break;
// case AddressSource.yatRecord:
// if (parsedAddress.handle.isEmpty) {
// title = S.of(context).yat_error;
// content = S.of(context).yat_error_content;
// address = parsedAddress.addresses.first;
// break;
// }
//
// title = S.of(context).address_detected;
// content = S.of(context).address_from_yat(parsedAddress.handle);
//
// if (parsedAddress.addresses.length == 1) {
// address = parsedAddress.addresses.first;
// break;
// }
//
// content += S.of(context).choose_address;
//
// address = await showPopUp<String?>(
// context: context,
// builder: (BuildContext context) {
//
// return WillPopScope(
// child: ChooseYatAddressAlert(
// alertTitle: title,
// alertContent: content,
// addresses: parsedAddress.addresses),
// onWillPop: () async => false);
// }) ?? '';
//
// if (address.isEmpty) {
// return parsedAddress.handle;
// }
//
// return address;
// case AddressSource.contact:
// case AddressSource.notParsed:
// address = parsedAddress.addresses.first;
// return address;
// }
//
// await showPopUp<void>(
// context: context,
// builder: (BuildContext context) {
//
// return AlertWithOneAction(
// alertTitle: title,
// headerTitleText: profileName.isEmpty ? null : profileName,
// headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
// alertContent: content,
// buttonText: S.of(context).ok,
// buttonAction: () => Navigator.of(context).pop());
// });
//
// return address;
// }

View file

@ -53,8 +53,22 @@ class DomainLookupsPage extends BasePage {
title: 'Zano Aliases',
value: _privacySettingsViewModel.lookupsZanoAlias,
onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsZanoAlias(value)),
//if (!isHaven) it does not work correctly
SettingsSwitcherCell(
title: 'FIO',
value: _privacySettingsViewModel.lookupsFio,
onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsFio(value)),
SettingsSwitcherCell(
title: 'Nostr',
value: _privacySettingsViewModel.lookupsNostr,
onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsNostr(value)),
SettingsSwitcherCell(
title: 'ThorChain',
value: _privacySettingsViewModel.lookupsThorChain,
onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsThorChain(value)),
SettingsSwitcherCell(
title: 'BIP-353',
value: _privacySettingsViewModel.lookupsBip353,
onValueChange: (_, bool value) => _privacySettingsViewModel.setLookupsBip353(value)),
],
),
);

View file

@ -8,6 +8,7 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_expandab
import 'package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/list_row.dart';
@ -190,6 +191,7 @@ class RBFDetailsPage extends BasePage {
key: ValueKey('rbf_confirm_sending_bottom_sheet'),
titleText: S.of(bottomSheetContext).confirm_transaction,
currentTheme: currentTheme,
footerType: FooterType.slideActionButton,
walletType: transactionDetailsViewModel.sendViewModel.walletType,
titleIconPath: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency.iconPath,
currency: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency,
@ -200,7 +202,7 @@ class RBFDetailsPage extends BasePage {
feeValue: transactionDetailsViewModel.sendViewModel.pendingTransaction!.feeFormatted,
feeFiatAmount: transactionDetailsViewModel.sendViewModel.pendingTransactionFeeFiatAmountFormatted,
outputs: transactionDetailsViewModel.sendViewModel.outputs,
onSlideComplete: () async {
onSlideActionComplete: () async {
Navigator.of(bottomSheetContext).pop();
await transactionDetailsViewModel.sendViewModel.commitTransaction(context);
try {

View file

@ -10,6 +10,7 @@ class FilteredList extends StatefulWidget {
this.canReorder = true,
this.shrinkWrap = false,
this.physics,
this.itemPadding = const EdgeInsets.symmetric(vertical: 4),
});
final ObservableList<dynamic> list;
@ -18,39 +19,51 @@ class FilteredList extends StatefulWidget {
final bool canReorder;
final bool shrinkWrap;
final ScrollPhysics? physics;
final EdgeInsets itemPadding;
@override
FilteredListState createState() => FilteredListState();
}
class FilteredListState extends State<FilteredList> {
Widget _buildPaddedItem(BuildContext ctx, int index) {
return Padding(
key: ValueKey(widget.list[index]),
padding: widget.itemPadding,
child: widget.itemBuilder(ctx, index),
);
}
@override
Widget build(BuildContext context) {
if (widget.canReorder) {
return Observer(
builder: (_) => ReorderableListView.builder(
shrinkWrap: widget.shrinkWrap,
physics: widget.physics ?? const BouncingScrollPhysics(),
itemBuilder: widget.itemBuilder,
itemCount: widget.list.length,
onReorder: (int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final dynamic item = widget.list.removeAt(oldIndex);
widget.list.insert(newIndex, item);
widget.updateFunction();
},
),
);
} else {
if (!widget.canReorder) {
return Observer(
builder: (_) => ListView.builder(
shrinkWrap: widget.shrinkWrap,
physics: widget.physics ?? const BouncingScrollPhysics(),
itemBuilder: widget.itemBuilder,
itemCount: widget.list.length,
itemBuilder: _buildPaddedItem,
),
);
}
return Observer(
builder: (_) => ReorderableListView.builder(
shrinkWrap: widget.shrinkWrap,
physics: widget.physics ?? const BouncingScrollPhysics(),
itemCount: widget.list.length,
itemBuilder: _buildPaddedItem,
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) newIndex -= 1;
final item = widget.list.removeAt(oldIndex);
widget.list.insert(newIndex, item);
widget.updateFunction();
},
proxyDecorator: (child, _, __) => Material(
color: Colors.transparent,
child: child,
),
),
);
}
}

View file

@ -0,0 +1,147 @@
import 'dart:async';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/address_book/contact_welcome_page.dart';
import 'package:cake_wallet/src/screens/address_book/edit_address_page.dart';
import 'package:cake_wallet/src/screens/address_book/edit_contact_group_page.dart';
import 'package:cake_wallet/src/screens/address_book/contact_page.dart';
import 'package:cake_wallet/src/screens/address_book/edit_new_contact_group_page.dart';
import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart';
import 'package:cake_wallet/src/screens/address_book/supported_handles_page.dart';
import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:flutter/material.dart';
class AddressBookBottomSheet extends StatelessWidget {
const AddressBookBottomSheet({
super.key,
required this.onHandlerSearch,
this.initialRoute,
this.initialArgs,
});
final Future<List<ParsedAddress>> Function(String q) onHandlerSearch;
final String? initialRoute;
final Object? initialArgs;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDragHandle(context),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * .7,
),
child: _AddContactNavigator(
onHandlerSearch: onHandlerSearch,
initialRoute: initialRoute ?? Navigator.defaultRouteName,
initialArgs: initialArgs,
),
),
],
),
),
);
}
}
Widget _buildDragHandle(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
children: [
const Spacer(flex: 4),
Expanded(
flex: 2,
child: Container(
height: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.onSurface,
),
),
),
const Spacer(flex: 4),
],
),
);
}
class _AddContactNavigator extends StatelessWidget {
const _AddContactNavigator({
required this.onHandlerSearch,
required this.initialRoute,
this.initialArgs,
});
final Future<List<ParsedAddress>> Function(String) onHandlerSearch;
final String initialRoute;
final Object? initialArgs;
@override
Widget build(BuildContext context) {
return Navigator(
onGenerateInitialRoutes: (_, __) => [
_routeFor(initialRoute, initialArgs),
],
onGenerateRoute: (settings) => _routeFor(
settings.name ?? Navigator.defaultRouteName,
settings.arguments,
),
);
}
Route<dynamic> _routeFor(String name, Object? args) {
late final Widget page;
switch (name) {
case Routes.supportedHandlesPage:
page = getIt<SupportedHandlesPage>();
break;
case Routes.newContactWelcomePage:
final list = args as List<dynamic>;
final onSearch = list[0] as Future<List<ParsedAddress>> Function(String);
final handleOnly = list.length > 1 && list[1] == true;
final contact = list.length > 2 ? list[2] as ContactRecord? : null;
page = NewContactWelcomePage(
onSearch: onSearch,
handleOnly: handleOnly,
existingContact: contact,
);
break;
case Routes.editNewContactGroupPage:
final list = args as List<dynamic>;
page = getIt<EditNewContactGroupPage>(
param1: list[0] as ParsedAddress,
param2: list.length > 1 ? list[1] as ContactRecord? : null,
);
break;
case Routes.editContactGroupPage:
final vm = args as ContactViewModel;
page = getIt<EditContactGroupPage>(param1: vm);
break;
case Routes.contactPage:
page = getIt<ContactPage>(param1: args as ContactRecord);
break;
case Routes.editAddressPage:
page = getIt<EditAddressPage>(param1: args as AddressEditRequest);
break;
default:
page = NewContactWelcomePage(onSearch: onHandlerSearch, handleOnly: false);
}
return MaterialPageRoute(
builder: (_) => page,
settings: RouteSettings(name: name, arguments: args),
);
}
}

View file

@ -1,65 +1,78 @@
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:flutter/material.dart';
enum FooterType { none, slideActionButton, singleActionButton, doubleActionButton }
abstract class BaseBottomSheet extends StatelessWidget {
const BaseBottomSheet({
super.key,
required this.titleText,
this.titleIconPath,
required this.footerType,
this.currentTheme,
this.slideActionButtonText,
this.onSlideActionComplete,
this.singleActionButtonText,
this.accessibleNavigationModeSlideActionButtonText,
this.onSingleActionButtonPressed,
this.singleActionButtonKey,
this.doubleActionLeftButtonText,
this.doubleActionRightButtonText,
this.onLeftActionButtonPressed,
this.onRightActionButtonPressed,
this.leftActionButtonKey,
this.rightActionButtonKey,
}) : assert(footerType == FooterType.none || currentTheme != null,
'currentTheme is required unless footerType is none');
final String titleText;
final String? titleIconPath;
final MaterialThemeBase? currentTheme;
final FooterType footerType;
final String? slideActionButtonText;
final VoidCallback? onSlideActionComplete;
final String? singleActionButtonText;
final String? accessibleNavigationModeSlideActionButtonText;
final VoidCallback? onSingleActionButtonPressed;
final Key? singleActionButtonKey;
final String? doubleActionLeftButtonText;
final String? doubleActionRightButtonText;
final VoidCallback? onLeftActionButtonPressed;
final VoidCallback? onRightActionButtonPressed;
final Key? leftActionButtonKey;
final Key? rightActionButtonKey;
const BaseBottomSheet({required this.titleText, this.titleIconPath});
Widget headerWidget(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
const Spacer(flex: 4),
Expanded(
flex: 2,
child: Container(
height: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.onSurface,
),
),
Widget _buildDragHandle(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
const Spacer(flex: 4),
Expanded(
flex: 2,
child: Container(
height: 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.onSurface,
),
const Spacer(flex: 4),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (titleIconPath != null)
Image.asset(titleIconPath!, height: 24, width: 24, excludeFromSemantics: true)
else
Container(),
const SizedBox(width: 6),
Text(
titleText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontSize: 20,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
],
),
const SizedBox(height: 24),
],
),
const Spacer(flex: 4),
],
),
);
}
Widget contentWidget(BuildContext context);
Widget footerWidget(BuildContext context);
Widget contentWidget(BuildContext context);
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: 600),
constraints: BoxConstraints(maxHeight: 900),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(30.0), topRight: Radius.circular(30.0)),
@ -68,13 +81,97 @@ abstract class BaseBottomSheet extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
headerWidget(context),
buildHeader(context) ?? _buildDragHandle(context),
contentWidget(context),
footerWidget(context),
_buildFooter(context),
],
),
),
),
);
}
}
Widget? buildHeader(BuildContext context) => Column(
children: [
_buildDragHandle(context),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (titleIconPath != null) ...[
Image.asset(titleIconPath!, height: 24, width: 24),
const SizedBox(width: 6),
],
Text(
titleText,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontSize: 20,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
],
),
const SizedBox(height: 13),
],
);
Widget _buildFooter(BuildContext context) {
switch (footerType) {
case FooterType.none:
return const SizedBox.shrink();
case FooterType.slideActionButton:
return Padding(
padding: const EdgeInsets.fromLTRB(40, 12, 40, 34),
child: StandardSlideButton(
buttonText: slideActionButtonText ?? '',
onSlideComplete: onSlideActionComplete ?? () {},
currentTheme: currentTheme!,
accessibleNavigationModeButtonText: accessibleNavigationModeSlideActionButtonText ?? '',
),
);
case FooterType.singleActionButton:
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 34),
child: LoadingPrimaryButton(
key: singleActionButtonKey,
text: singleActionButtonText ?? '',
onPressed: onSingleActionButtonPressed ?? () {},
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: false,
isDisabled: false,
),
);
case FooterType.doubleActionButton:
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 34),
child: Row(
children: [
Expanded(
child: PrimaryButton(
key: leftActionButtonKey,
text: doubleActionLeftButtonText ?? '',
onPressed: onLeftActionButtonPressed,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
key: rightActionButtonKey,
text: doubleActionRightButtonText ?? '',
onPressed: onRightActionButtonPressed,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
);
}
}
}

View file

@ -2,37 +2,28 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../../../view_model/cake_pay/cake_pay_purchase_view_model.dart';
import 'base_bottom_sheet_widget.dart';
class ConfirmSendingBottomSheet extends BaseBottomSheet {
final CryptoCurrency currency;
final MaterialThemeBase currentTheme;
final String? paymentId;
final String? paymentIdValue;
final String? expirationTime;
final String amount;
final String amountValue;
final String fiatAmountValue;
final String fee;
final String feeValue;
final String feeFiatAmount;
final List<Output> outputs;
final VoidCallback onSlideComplete;
final WalletType walletType;
final PendingChange? change;
final bool isOpenCryptoPay;
ConfirmSendingBottomSheet({
required String titleText,
required MaterialThemeBase currentTheme,
required FooterType footerType,
String? titleIconPath,
String? slideActionButtonText,
VoidCallback? onSlideActionComplete,
String? accessibleNavigationModeSlideActionButtonText,
required this.currency,
required this.currentTheme,
this.paymentId,
this.paymentIdValue,
this.expirationTime,
@ -43,13 +34,42 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
required this.feeValue,
required this.feeFiatAmount,
required this.outputs,
required this.onSlideComplete,
required this.walletType,
this.change,
this.isOpenCryptoPay = false,
this.cakePayBuyCardViewModel,
this.quantity,
Key? key,
}) : showScrollbar = outputs.length > 3,
super(titleText: titleText, titleIconPath: titleIconPath);
_currentTheme = currentTheme,
super(
titleText: titleText,
titleIconPath: titleIconPath,
currentTheme: currentTheme,
footerType: footerType,
slideActionButtonText: slideActionButtonText ?? 'Swipe to send',
onSlideActionComplete: onSlideActionComplete,
accessibleNavigationModeSlideActionButtonText:
accessibleNavigationModeSlideActionButtonText,
key: key);
final CryptoCurrency currency;
final MaterialThemeBase _currentTheme;
final String? paymentId;
final String? paymentIdValue;
final String? expirationTime;
final String amount;
final String amountValue;
final String fiatAmountValue;
final String fee;
final String feeValue;
final String feeFiatAmount;
final List<Output> outputs;
final WalletType walletType;
final PendingChange? change;
final bool isOpenCryptoPay;
final CakePayPurchaseViewModel? cakePayBuyCardViewModel;
final String? quantity;
final bool showScrollbar;
final ScrollController scrollController = ScrollController();
@ -57,15 +77,15 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
@override
Widget contentWidget(BuildContext context) {
final itemTitleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
);
fontSize: 16,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
);
final itemSubTitleTextStyle = Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
);
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
);
final tileBackgroundColor = Theme.of(context).colorScheme.surfaceContainer;
@ -73,19 +93,24 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
padding: EdgeInsets.fromLTRB(8, 0, showScrollbar ? 16 : 8, 8),
child: Column(
children: [
if (paymentId != null && paymentIdValue != null)
if (paymentId != null && paymentIdValue != null && cakePayBuyCardViewModel != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AddressTile(
itemTitle: paymentId!,
itemTitleTextStyle: itemTitleTextStyle,
walletType: walletType,
isBatchSending: false,
amount: '',
address: paymentIdValue!,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
),
child: Observer(
builder: (_) => AddressTile(
itemTitle: paymentId!,
itemTitleTextStyle: itemTitleTextStyle,
amountTextStyle: itemSubTitleTextStyle,
walletType: walletType,
amount: expirationTime != null
? S.current.offer_expires_in +
' ${cakePayBuyCardViewModel!.formattedRemainingTime}'
: null,
address: paymentIdValue!,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
applyAddressFormatting: false,
)),
),
StandardTile(
itemTitle: amount,
@ -116,39 +141,43 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
itemBuilder: (context, index) {
final bool isBatchSending = outputs.length > 1;
final item = outputs[index];
final contactName = item.parsedAddress.name;
final contactName = item.parsedAddress.handle;
final isCakePayName = contactName == 'Cake Pay';
final batchContactTitle =
'${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}';
final _address = item.isParsedAddress ? item.extractedAddress : item.address;
final _amount = item.cryptoAmount.replaceAll(',', '.') + ' ${currency.title}';
return isBatchSending || contactName.isNotEmpty
? AddressExpansionTile(
contactType: isOpenCryptoPay ? 'Open CryptoPay' : S.of(context).contact,
name: isBatchSending ? batchContactTitle : contactName,
address: _address,
amount: _amount,
walletType: walletType,
isBatchSending: isBatchSending,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
)
return isBatchSending || (contactName.isNotEmpty && !isCakePayName)
? ExpansionAddressTile(
contactType: isOpenCryptoPay ? 'Open CryptoPay' : S.of(context).contact,
name: isBatchSending ? batchContactTitle : contactName,
address: _address,
amount: _amount,
walletType: walletType,
isBatchSending: isBatchSending,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
)
: AddressTile(
itemTitle: S.of(context).address,
itemTitleTextStyle: itemTitleTextStyle,
isBatchSending: isBatchSending,
walletType: walletType,
amount: _amount,
address: _address,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
);
itemTitle: isCakePayName
? item.parsedAddress.profileName
: S.of(context).address,
imagePath: isCakePayName ? item.parsedAddress.profileImageUrl : null,
itemTitleTextStyle: itemTitleTextStyle,
walletType: walletType,
amount: isCakePayName ? item.fiatAmount : _amount,
address: _address,
itemSubTitle: isCakePayName ? quantity : null,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
);
},
),
if (change != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: AddressExpansionTile(
child: ExpansionAddressTile(
contactType: 'Change',
name: S.of(context).send_change_to_you,
address: change!.address,
@ -184,31 +213,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
return content;
}
}
@override
Widget footerWidget(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(40, 12, 40, 34),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
if (showScrollbar)
BoxShadow(
color: Theme.of(context).colorScheme.outlineVariant,
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 0),
),
],
),
child: StandardSlideButton(
onSlideComplete: onSlideComplete,
buttonText: 'Swipe to send',
currentTheme: currentTheme,
accessibleNavigationModeButtonText: S.of(context).send,
),
);
}
}
class StandardTile extends StatelessWidget {
@ -265,22 +269,28 @@ class AddressTile extends StatelessWidget {
super.key,
required this.itemTitle,
required this.itemTitleTextStyle,
required this.isBatchSending,
required this.amount,
required this.address,
required this.itemSubTitleTextStyle,
required this.tileBackgroundColor,
required this.walletType,
this.amountTextStyle,
this.applyAddressFormatting = true,
this.imagePath,
this.amount,
this.itemSubTitle,
});
final String itemTitle;
final TextStyle itemTitleTextStyle;
final bool isBatchSending;
final String amount;
final String? amount;
final String address;
final TextStyle itemSubTitleTextStyle;
final TextStyle? amountTextStyle;
final Color tileBackgroundColor;
final WalletType walletType;
final bool applyAddressFormatting;
final String? imagePath;
final String? itemSubTitle;
@override
Widget build(BuildContext context) {
@ -296,17 +306,56 @@ class AddressTile extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(itemTitle, style: itemTitleTextStyle),
if (isBatchSending) Text(amount, style: itemTitleTextStyle),
Expanded(
child: Row(
children: [
if (imagePath != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ImageUtil.getImageFromPath(
imagePath: imagePath!, height: 40, width: 40),
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
itemTitle,
style: itemTitleTextStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
)),
),
],
),
),
if (amount != null) Text(amount!, style: amountTextStyle ?? itemTitleTextStyle),
],
),
AddressFormatter.buildSegmentedAddress(
address: address,
walletType: walletType,
evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
address.isEmpty
? Container()
: applyAddressFormatting
? AddressFormatter.buildSegmentedAddress(
address: address,
walletType: walletType,
evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
))
: Text(
address,
style: itemTitleTextStyle,
),
itemSubTitle == null
? Container()
: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(itemSubTitle!, style: itemSubTitleTextStyle),
],
),
],
),
@ -314,8 +363,8 @@ class AddressTile extends StatelessWidget {
}
}
class AddressExpansionTile extends StatelessWidget {
const AddressExpansionTile({
class ExpansionAddressTile extends StatelessWidget {
const ExpansionAddressTile({
super.key,
required this.contactType,
required this.name,
@ -371,9 +420,9 @@ class AddressExpansionTile extends StatelessWidget {
Text(
isBatchSending ? amount : name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
],
),
@ -385,9 +434,8 @@ class AddressExpansionTile extends StatelessWidget {
address: address,
walletType: walletType,
evenTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
fontWeight: FontWeight.w600,
),
),
),
],
@ -399,4 +447,4 @@ class AddressExpansionTile extends StatelessWidget {
),
);
}
}
}

View file

@ -1,65 +1,84 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'base_bottom_sheet_widget.dart';
class LoadingBottomSheet extends BaseBottomSheet {
LoadingBottomSheet({required String titleText, String? titleIconPath})
: super(titleText: titleText, titleIconPath: titleIconPath);
LoadingBottomSheet(
{required String titleText, String? titleIconPath})
: super(titleText: titleText, titleIconPath: titleIconPath, footerType: FooterType.none);
@override
Widget contentWidget(BuildContext context) {
return SizedBox(
height: 200,
height: 300,
child: Center(child: CircularProgressIndicator()),
);
}
@override
Widget footerWidget(BuildContext context) => const SizedBox(height: 94);
}
class InfoBottomSheet extends BaseBottomSheet {
final MaterialThemeBase currentTheme;
final String? contentImage;
final Color? contentImageColor;
final String? content;
final bool isTwoAction;
final bool showDontAskMeCheckbox;
final Function(bool)? onCheckboxChanged;
final String? actionButtonText;
final VoidCallback? actionButton;
final Key? actionButtonKey;
final String? leftButtonText;
final String? rightButtonText;
final VoidCallback? actionLeftButton;
final VoidCallback? actionRightButton;
final Key? rightActionButtonKey;
final Key? leftActionButtonKey;
InfoBottomSheet({
required String titleText,
String? titleIconPath,
required this.currentTheme,
required this.footerType,
this.contentImage,
this.contentImageColor,
this.content,
this.isTwoAction = false,
this.showDontAskMeCheckbox = false,
this.onCheckboxChanged,
this.actionButtonText,
this.actionButton,
this.actionButtonKey,
this.leftButtonText,
this.rightButtonText,
this.actionLeftButton,
this.actionRightButton,
this.rightActionButtonKey,
this.singleActionButtonText,
this.onSingleActionButtonPressed,
this.singleActionButtonKey,
this.doubleActionLeftButtonText,
this.doubleActionRightButtonText,
this.onLeftActionButtonPressed,
this.onRightActionButtonPressed,
this.leftActionButtonKey,
}) : super(titleText: titleText, titleIconPath: titleIconPath);
this.rightActionButtonKey,
Widget? actionPanel,
Key? key,
}) : _actionPanel = actionPanel,
super(
titleText: titleText,
titleIconPath: titleIconPath,
currentTheme: currentTheme,
footerType: footerType,
singleActionButtonText: singleActionButtonText,
onSingleActionButtonPressed: onSingleActionButtonPressed,
singleActionButtonKey: singleActionButtonKey,
doubleActionLeftButtonText: doubleActionLeftButtonText,
doubleActionRightButtonText: doubleActionRightButtonText,
onLeftActionButtonPressed: onLeftActionButtonPressed,
onRightActionButtonPressed: onRightActionButtonPressed,
leftActionButtonKey: leftActionButtonKey,
rightActionButtonKey: rightActionButtonKey,
key: key);
final MaterialThemeBase currentTheme;
final FooterType footerType;
final String? contentImage;
final Color? contentImageColor;
final String? content;
final Widget? _actionPanel;
final String? singleActionButtonText;
final VoidCallback? onSingleActionButtonPressed;
final Key? singleActionButtonKey;
final String? doubleActionLeftButtonText;
final String? doubleActionRightButtonText;
final VoidCallback? onLeftActionButtonPressed;
final VoidCallback? onRightActionButtonPressed;
final Key? rightActionButtonKey;
final Key? leftActionButtonKey;
Widget get defaultActionPanel => const SizedBox();
Widget get actionPanel => _actionPanel ?? defaultActionPanel;
@override
Widget contentWidget(BuildContext context) {
@ -70,7 +89,12 @@ class InfoBottomSheet extends BaseBottomSheet {
if (contentImage != null)
Expanded(
flex: 4,
child: getImage(contentImage!, imageColor: contentImageColor),
child: ImageUtil.getImageFromPath(
imagePath: contentImage!,
svgImageColor: contentImageColor,
fit: BoxFit.contain,
borderRadius: 10,
),
)
else
Container(),
@ -87,134 +111,20 @@ class InfoBottomSheet extends BaseBottomSheet {
content!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
),
const Spacer(flex: 2),
],
),
),
if (showDontAskMeCheckbox)
Padding(
padding: const EdgeInsets.only(left: 34.0),
child: Row(
children: [
SimpleCheckbox(onChanged: onCheckboxChanged),
const SizedBox(width: 8),
Text(
'Dont ask me next time',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
),
),
],
),
),
actionPanel ?? const SizedBox(),
],
),
);
}
@override
Widget footerWidget(BuildContext context) {
if (isTwoAction) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 34),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.only(right: 8.0, top: 8.0),
child: PrimaryButton(
key: leftActionButtonKey,
onPressed: actionLeftButton,
text: leftButtonText ?? '',
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
),
Flexible(
child: Container(
padding: const EdgeInsets.only(left: 8.0, top: 8.0),
child: PrimaryButton(
key: rightActionButtonKey,
onPressed: actionRightButton,
text: rightButtonText ?? '',
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
);
} else {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 34),
child: LoadingPrimaryButton(
key: actionButtonKey,
onPressed: actionButton ?? () {},
text: actionButtonText ?? '',
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: false,
isDisabled: false,
),
);
}
}
Widget getImage(String imagePath, {Color? imageColor}) {
final bool isSvg = imagePath.endsWith('.svg');
if (isSvg) {
return SvgPicture.asset(
imagePath,
colorFilter: imageColor != null ? ColorFilter.mode(imageColor, BlendMode.srcIn) : null,
);
} else {
return Image.asset(imagePath);
}
}
}
class SimpleCheckbox extends StatefulWidget {
SimpleCheckbox({this.onChanged});
final Function(bool)? onChanged;
@override
State<SimpleCheckbox> createState() => _SimpleCheckboxState();
}
class _SimpleCheckboxState extends State<SimpleCheckbox> {
bool initialValue = false;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 24.0,
width: 24.0,
child: Checkbox(
value: initialValue,
onChanged: (value) => setState(() {
initialValue = value!;
widget.onChanged?.call(value);
}),
checkColor: Theme.of(context).colorScheme.onSurfaceVariant,
activeColor: Colors.transparent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: WidgetStateBorderSide.resolveWith((states) =>
BorderSide(color: Theme.of(context).colorScheme.onSurfaceVariant, width: 1.0)),
),
);
}
}
}

View file

@ -21,7 +21,7 @@ class InfoStepsBottomSheet extends BaseBottomSheet {
required this.steps,
String? titleIconPath,
required this.currentTheme,
}) : super(titleText: titleText, titleIconPath: titleIconPath);
}) : super(titleText: titleText, titleIconPath: titleIconPath, footerType: FooterType.none);
@override
Widget contentWidget(BuildContext context) => SizedBox(
@ -108,7 +108,4 @@ class InfoStepsBottomSheet extends BaseBottomSheet {
],
),
);
@override
Widget footerWidget(BuildContext context) => SizedBox.shrink();
}

View file

@ -79,6 +79,8 @@ class LoadingPrimaryButton extends StatelessWidget {
required this.text,
required this.color,
required this.textColor,
this.width,
this.height,
this.isDisabled = false,
this.isLoading = false,
super.key,
@ -87,6 +89,8 @@ class LoadingPrimaryButton extends StatelessWidget {
final VoidCallback onPressed;
final Color color;
final Color textColor;
final double? width;
final double? height;
final bool isLoading;
final bool isDisabled;
final String text;
@ -96,8 +100,8 @@ class LoadingPrimaryButton extends StatelessWidget {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint),
child: SizedBox(
width: double.infinity,
height: 52.0,
width: width ?? double.infinity,
height: height ?? 52.0,
child: TextButton(
onPressed: (isLoading || isDisabled)
? null

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class SimpleCheckbox extends StatefulWidget {
SimpleCheckbox({this.onChanged});
final Function(bool)? onChanged;
@override
State<SimpleCheckbox> createState() => _SimpleCheckboxState();
}
class _SimpleCheckboxState extends State<SimpleCheckbox> {
bool initialValue = false;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 24.0,
width: 24.0,
child: Checkbox(
value: initialValue,
onChanged: (value) => setState(() {
initialValue = value!;
widget.onChanged?.call(value);
}),
checkColor: Theme.of(context).textTheme.titleLarge!.color,
activeColor: Colors.transparent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: WidgetStateBorderSide.resolveWith((states) => BorderSide(
color: Theme.of(context).textTheme.titleLarge!.color!, width: 1.0)),
),
);
}
}

View file

@ -118,6 +118,10 @@ abstract class SettingsStoreBase with Store {
required this.lookupsOpenAlias,
required this.lookupsENS,
required this.lookupsWellKnown,
required this.lookupsFio,
required this.lookupsNostr,
required this.lookupsThorChain,
required this.lookupsBip353,
required this.usePayjoin,
required this.showPayjoinCard,
required this.customBitcoinFeeRate,
@ -488,6 +492,26 @@ abstract class SettingsStoreBase with Store {
(bool looksUpWellKnown) =>
_sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown));
reaction(
(_) => lookupsFio,
(bool looksUpWellKnown) =>
_sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown));
reaction(
(_) => lookupsNostr,
(bool looksUpWellKnown) =>
_sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown));
reaction(
(_) => lookupsThorChain,
(bool looksUpWellKnown) =>
_sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown));
reaction(
(_) => lookupsBip353,
(bool looksUpWellKnown) =>
_sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown));
reaction(
(_) => usePayjoin,
(bool usePayjoin) =>
@ -816,6 +840,18 @@ abstract class SettingsStoreBase with Store {
@observable
bool lookupsWellKnown;
@observable
bool lookupsFio;
@observable
bool lookupsNostr;
@observable
bool lookupsThorChain;
@observable
bool lookupsBip353;
@observable
bool usePayjoin;
@ -1022,6 +1058,10 @@ abstract class SettingsStoreBase with Store {
final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true;
final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true;
final lookupsWellKnown = sharedPreferences.getBool(PreferencesKey.lookupsWellKnown) ?? true;
final lookupsFio = sharedPreferences.getBool(PreferencesKey.lookupsFio) ?? true;
final lookupsNostr = sharedPreferences.getBool(PreferencesKey.lookupsNostr) ?? true;
final lookupsThorChain = sharedPreferences.getBool(PreferencesKey.lookupsThorChain) ?? true;
final lookupsBip353 = sharedPreferences.getBool(PreferencesKey.lookupsBip353) ?? true;
final usePayjoin = sharedPreferences.getBool(PreferencesKey.usePayjoin) ?? false;
final showPayjoinCard = sharedPreferences.getBool(PreferencesKey.showPayjoinCard) ?? true;
final customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1;
@ -1327,6 +1367,10 @@ abstract class SettingsStoreBase with Store {
lookupsOpenAlias: lookupsOpenAlias,
lookupsENS: lookupsENS,
lookupsWellKnown: lookupsWellKnown,
lookupsFio: lookupsFio,
lookupsNostr: lookupsNostr,
lookupsThorChain: lookupsThorChain,
lookupsBip353: lookupsBip353,
usePayjoin: usePayjoin,
showPayjoinCard: showPayjoinCard,
customBitcoinFeeRate: customBitcoinFeeRate,

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:cake_wallet/.secrets.g.dart' as secrets;
import 'package:cake_wallet/twitter/twitter_user.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class TwitterApi {
@ -10,7 +11,9 @@ class TwitterApi {
static const apiHost = 'api.twitter.com';
static const userPath = '/2/users/by/username/';
static Future<TwitterUser> lookupUserByName({required String userName}) async {
static Future<TwitterUser?> lookupUserByName({required String userName}) async {
try{
final queryParams = {
'user.fields': 'description,profile_image_url',
'expansions': 'pinned_tweet_id',
@ -41,8 +44,14 @@ class TwitterApi {
}
return TwitterUser.fromJson(responseJSON, _getPinnedTweet(responseJSON));
} catch (e) {
printV('Error in lookupUserByName: $e');
return null;
}
}
static Tweet? _getPinnedTweet(Map<String, dynamic> responseJSON) {
try {
final tweetId = responseJSON['data']['pinned_tweet_id'] as String?;
@ -61,6 +70,7 @@ class TwitterApi {
return Tweet(id: tweetId, text: pinnedTweetText);
} catch (e) {
printV('Error in _getPinnedTweet: $e');
return null;
}
}

View file

@ -10,8 +10,8 @@ class AddressFormatter {
TextStyle? oddTextStyle,
TextAlign? textAlign,
bool shouldTruncate = false,
int visibleChunks = 2,
}) {
final cleanAddress = address.replaceAll('bitcoincash:', '');
final isMWEB = address.startsWith('ltcmweb');
final chunkSize = walletType != null ? _getChunkSize(walletType) : 4;
@ -21,8 +21,10 @@ class AddressFormatter {
address: cleanAddress,
isMWEB: isMWEB,
chunkSize: chunkSize,
visibleChunks: visibleChunks,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(150)),
oddTextStyle:
oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(150)),
textAlign: textAlign,
);
} else {
@ -31,7 +33,8 @@ class AddressFormatter {
isMWEB: isMWEB,
chunkSize: chunkSize,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(128)),
oddTextStyle:
oddTextStyle ?? evenTextStyle.copyWith(color: evenTextStyle.color!.withAlpha(128)),
textAlign: textAlign,
);
}
@ -45,7 +48,6 @@ class AddressFormatter {
required TextStyle oddTextStyle,
TextAlign? textAlign,
}) {
final chunks = <String>[];
if (isMWEB) {
@ -86,40 +88,24 @@ class AddressFormatter {
required String address,
required bool isMWEB,
required int chunkSize,
required int visibleChunks,
required TextStyle evenTextStyle,
required TextStyle oddTextStyle,
TextAlign? textAlign,
}) {
if (isMWEB) {
const fixedPrefix = 'ltcmweb';
final secondChunkStart = fixedPrefix.length;
const chunkSize = 4;
final secondChunk = address.substring(
secondChunkStart,
math.min(secondChunkStart + chunkSize, address.length),
);
final lastChunk = address.substring(address.length - chunkSize);
const prefix = 'ltcmweb';
final rest = address.substring(prefix.length);
final spans = <TextSpan>[
TextSpan(text: '$fixedPrefix ', style: evenTextStyle),
TextSpan(text: '$secondChunk ', style: oddTextStyle),
TextSpan(text: '... ', style: oddTextStyle),
TextSpan(text: lastChunk, style: evenTextStyle),
];
final chunks = <String>[];
for (int i = 0; i < rest.length; i += chunkSize) {
chunks.add(rest.substring(i, math.min(i + chunkSize, rest.length)));
}
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
);
} else {
final int digitCount = chunkSize;
if (address.length <= 2 * digitCount) {
if (chunks.length <= visibleChunks + 1) {
return _buildFullSegmentedAddress(
address: address,
isMWEB: isMWEB,
isMWEB: true,
chunkSize: chunkSize,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle,
@ -127,25 +113,60 @@ class AddressFormatter {
);
}
final String firstPart = address.substring(0, digitCount);
final String secondPart =
address.substring(digitCount, digitCount * 2);
final String lastPart =
address.substring(address.length - digitCount);
final spans = <TextSpan>[
TextSpan(text: '$firstPart ', style: evenTextStyle),
TextSpan(text: '$secondPart ', style: oddTextStyle),
TextSpan(text: '... ', style: oddTextStyle),
TextSpan(text: lastPart, style: evenTextStyle),
TextSpan(text: '$prefix ', style: evenTextStyle),
];
for (var i = 0; i < visibleChunks; i++) {
final style = (i.isEven) ? oddTextStyle : evenTextStyle;
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
}
spans.add(TextSpan(text: '... ', style: oddTextStyle));
final lastStyle = (visibleChunks.isEven) ? evenTextStyle : oddTextStyle;
spans.add(TextSpan(text: chunks.last, style: lastStyle));
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
);
}
final chunks = <String>[];
for (int i = 0; i < address.length; i += chunkSize) {
chunks.add(address.substring(i, math.min(i + chunkSize, address.length)));
}
if (chunks.length <= visibleChunks + 1) {
return _buildFullSegmentedAddress(
address: address,
isMWEB: false,
chunkSize: chunkSize,
evenTextStyle: evenTextStyle,
oddTextStyle: oddTextStyle,
textAlign: textAlign,
);
}
final spans = <TextSpan>[];
for (var i = 0; i < visibleChunks; i++) {
final style = (i.isEven) ? evenTextStyle : oddTextStyle;
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
}
spans.add(TextSpan(text: '... ', style: oddTextStyle));
final lastStyle = (visibleChunks.isEven) ? oddTextStyle : evenTextStyle;
spans.add(TextSpan(text: chunks.last, style: lastStyle));
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
);
}
static int _getChunkSize(WalletType walletType) {
@ -158,4 +179,4 @@ class AddressFormatter {
return 4;
}
}
}
}

View file

@ -1,78 +1,126 @@
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
class ImageUtil {
static Widget getImageFromPath({required String imagePath, double? height, double? width}) {
static Widget getImageFromPath({
required String imagePath,
double? height,
double? width,
Color? svgImageColor,
BoxFit? fit,
double? borderRadius,
}) {
bool isNetworkImage = imagePath.startsWith('http') || imagePath.startsWith('https');
if (CakeTor.instance.enabled && isNetworkImage) {
imagePath = "assets/images/tor_logo.svg";
isNetworkImage = false;
}
final bool isSvg = imagePath.endsWith('.svg');
final double _height = height ?? 35;
final double _width = width ?? 35;
final bool ignoreSize = fit != null;
final double? _height = ignoreSize ? null : (height ?? 35);
final double? _width = ignoreSize ? null : (width ?? 35);
Widget img;
if (isNetworkImage) {
return isSvg
img = isSvg
? SvgPicture.network(
key: ValueKey(imagePath),
imagePath,
key: ValueKey(imagePath),
height: _height,
width: _width,
placeholderBuilder: (BuildContext context) => Container(
height: _height,
width: _width,
child: Center(
child: CircularProgressIndicator(),
),
),
fit: fit ?? BoxFit.contain,
placeholderBuilder: (_) => _placeholder(_height, _width),
)
: Image.network(
key: ValueKey(imagePath),
imagePath,
key: ValueKey(imagePath),
height: _height,
width: _width,
loadingBuilder:
(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Container(
height: _height,
width: _width,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
return Container(
height: _height,
width: _width,
);
},
fit: fit,
loadingBuilder: (_, child, progress) =>
progress == null ? child : _placeholder(_height, _width),
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
} else {
return isSvg
img = isSvg
? SvgPicture.asset(
imagePath,
key: ValueKey(imagePath),
height: _height,
width: _width,
placeholderBuilder: (_) => Icon(Icons.error),
key: ValueKey(imagePath),
fit: fit ?? BoxFit.contain,
colorFilter:
svgImageColor != null ? ColorFilter.mode(svgImageColor, BlendMode.srcIn) : null,
placeholderBuilder: (_) => const Icon(Icons.error),
)
: Image.asset(
imagePath,
key: ValueKey(imagePath),
height: _height,
width: _width,
errorBuilder: (_, __, ___) => Icon(Icons.error),
key: ValueKey(imagePath),
fit: fit,
errorBuilder: (_, __, ___) => const Icon(Icons.error),
);
}
if (borderRadius != null && borderRadius > 0) {
img = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: img,
);
}
return img;
}
static Widget _placeholder(double? height, double? width) {
return (height != null || width != null)
? SizedBox(
height: height,
width: width,
child: const Center(child: CircularProgressIndicator()),
)
: const Center(child: CircularProgressIndicator());
}
static Future<String?> saveAvatarLocally(String imageUriOrPath) async {
if (imageUriOrPath.isEmpty) return null;
try {
final dir = await getApplicationDocumentsDirectory();
String ext = p.extension(imageUriOrPath);
if (ext.isEmpty) ext = '.png';
final file = File('${dir.path}/${DateTime.now().millisecondsSinceEpoch}$ext');
if (imageUriOrPath.startsWith('http')) {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.parse(imageUriOrPath),
)
.catchError((error) {
throw Exception('HTTP request failed: $error');
});
if (response.statusCode == 200) {
await file.writeAsBytes(response.bodyBytes);
} else {
return null;
}
} else {
await File(imageUriOrPath).copy(file.path);
}
return file.existsSync() ? file.path : null;
} catch (_) {
return null;
}
}
}

View file

@ -10,6 +10,7 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/utils/mobx.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hive/hive.dart';
@ -17,11 +18,11 @@ import 'package:mobx/mobx.dart';
part 'contact_list_view_model.g.dart';
class ContactListViewModel = ContactListViewModelBase with _$ContactListViewModel;
class ContactListViewModel = ContactListViewModelBase with _$ContactListViewModel;
abstract class ContactListViewModelBase with Store {
ContactListViewModelBase(
this.contactSource, this.walletInfoSource, this._currency, this.settingsStore)
this.contactSource, this.walletInfoSource,this.wallet, this._currency, this.settingsStore)
: contacts = ObservableList<ContactRecord>(),
walletContacts = [],
isAutoGenerateEnabled =
@ -100,6 +101,7 @@ abstract class ContactListViewModelBase with Store {
final ObservableList<ContactRecord> contacts;
final List<WalletContact> walletContacts;
final CryptoCurrency? _currency;
final WalletBase wallet;
StreamSubscription<BoxEvent>? _subscription;
final SettingsStore settingsStore;
@ -152,9 +154,9 @@ abstract class ContactListViewModelBase with Store {
Future<void> sortGroupByType() async {
List<Contact> contactsSourceCopy = contactSource.values.toList();
contactsSourceCopy.sort((a, b) => ascending
? a.type.toString().compareTo(b.type.toString())
: b.type.toString().compareTo(a.type.toString()));
// contactsSourceCopy.sort((a, b) => ascending //TODO fix sort by type
// ? a.type.toString().compareTo(b.type.toString())
// : b.type.toString().compareTo(a.type.toString()));
await reorderContacts(contactsSourceCopy);
}

View file

@ -1,90 +1,328 @@
import 'dart:io';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/src/screens/address_book/entities/address_edit_request.dart';
import 'package:cake_wallet/src/screens/address_book/entities/user_handles.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cw_core/crypto_currency.dart';
part 'contact_view_model.g.dart';
class ContactViewModel = ContactViewModelBase with _$ContactViewModel;
enum ContactEditMode {
contactInfo,
manualAddress,
parsedAddress,
}
abstract class ContactViewModelBase with Store {
ContactViewModelBase(this._contacts, {ContactRecord? contact})
: state = InitialExecutionState(),
class ContactViewModel = _ContactViewModel with _$ContactViewModel;
abstract class _ContactViewModel with Store {
_ContactViewModel(
this.box,
this.wallet,
this.settingsStore, {
AddressEditRequest? request,
}) : mode = request?.mode == EditMode.manualAddressAdd ||
request?.mode == EditMode.manualAddressEdit
? ContactEditMode.manualAddress
: request?.mode == EditMode.parsedAddressAdd ||
request?.mode == EditMode.parsedAddressEdit
? ContactEditMode.parsedAddress
: ContactEditMode.contactInfo,
record = request?.contact,
currencies = CryptoCurrency.all,
_contact = contact,
name = contact?.name ?? '',
address = contact?.address ?? '',
currency = contact?.type,
lastChange = contact?.lastChange;
state = InitialExecutionState(),
name = request?.contact?.name ?? '',
handle = request?.contact?.handle ?? '',
profileName = request?.contact?.profileName ?? '',
description = request?.contact?.description ?? '',
imagePath = request?.contact?.imagePath ?? '',
sourceType = request?.contact?.sourceType ?? AddressSource.notParsed,
currency = request?.currency ?? CryptoCurrency.xmr,
label = request?.label ?? '',
address = '',
handleKey = request?.handleKey ?? '' {
_initMapsFromRecord();
if (request?.label != null && record != null) {
currency = request!.currency!;
label = request.label!;
address = _targetMap[currency]?[label] ?? '';
_rememberOriginal(
blockKey: mode == ContactEditMode.parsedAddress
? (request.handleKey ?? _defaultHandleKey())
: null,
);
}
}
final Box<Contact> box;
final WalletBase wallet;
final SettingsStore? settingsStore;
ContactRecord? record;
@observable
ExecutionState state;
@observable
String name;
String name, handle, profileName, description, imagePath;
@observable
AddressSource sourceType;
@observable
String address;
CryptoCurrency currency;
@observable
String label, address, handleKey;
@observable
CryptoCurrency? currency;
ObservableMap<CryptoCurrency, Map<String, String>> manual = ObservableMap();
@observable
ObservableMap<CryptoCurrency, Map<String, String>> parsed = ObservableMap();
@observable
ObservableMap<String, Map<CryptoCurrency, Map<String, String>>> parsedBlocks = ObservableMap();
DateTime? lastChange;
final ContactEditMode mode;
final List<CryptoCurrency> currencies;
CryptoCurrency? _originalCur;
String? _originalLabel, _originalAddress, _originalHandleKey;
@computed
bool get isReady =>
name.isNotEmpty &&
(currency?.toString().isNotEmpty ?? false) &&
address.isNotEmpty;
bool get isReady => name.trim().isNotEmpty || manual.isNotEmpty || parsed.isNotEmpty;
final List<CryptoCurrency> currencies;
final Box<Contact> _contacts;
final ContactRecord? _contact;
@computed
List<UserHandles> get userHandles =>
parsedBlocks.keys.map((k) => UserHandles(handleKey: k)).toList();
@computed
ImageProvider get avatar => imagePath.isEmpty
? const AssetImage('assets/images/profile.png')
: FileImage(File(imagePath));
bool get isAddressEdit =>
mode != ContactEditMode.contactInfo && record != null && (_originalLabel ?? '').isNotEmpty;
ObservableMap<CryptoCurrency, Map<String, String>> get _targetMap =>
mode == ContactEditMode.manualAddress
? manual
: parsed[currency] != null
? parsed
: manual;
@action
Future<void> saveContactInfo() async {
if (record != null) {
record!
..name = name.trim()
..handle = handle.trim()
..profileName = profileName.trim()
..description = description.trim()
..imagePath = imagePath
..sourceType = sourceType;
record!.original..lastChange = DateTime.now();
await record!.original.save();
state = ExecutedSuccessfullyState();
return;
}
final newContact = Contact(
name: name.trim(),
address: '',
)
..handle = handle.trim()
..profileName = profileName.trim()
..description = description.trim()
..imagePath = imagePath
..source = sourceType
..lastChange = DateTime.now();
await box.put(newContact.key, newContact);
record = ContactRecord(box, newContact);
state = ExecutedSuccessfullyState();
}
@action
Future<void> saveManualAddress() async {
_ensureRecord();
final map = manual.putIfAbsent(currency, () => {});
final oldLabel = isAddressEdit ? _originalLabel : null;
final newLabel = label.trim().isEmpty ? currency.title : label.trim();
final newAddress = address.trim();
if (oldLabel != null && oldLabel != newLabel) map.remove(oldLabel);
map[newLabel] = newAddress;
manual[currency] = Map.of(map);
record!.setManualAddress(currency, newLabel, newAddress);
_rememberOriginal();
state = ExecutedSuccessfullyState();
}
@action
Future<void> saveParsedAddress() async {
_ensureRecord();
final blockKey = handleKey.trim().isEmpty ? _defaultHandleKey() : handleKey.trim();
final block = parsedBlocks.putIfAbsent(blockKey, () => {});
final map = block.putIfAbsent(currency, () => {});
final oldLabel = isAddressEdit ? _originalLabel : null;
final newLabel = label.trim().isEmpty ? currency.title : label.trim();
final newAddress = address.trim();
if (oldLabel != null && oldLabel != newLabel) map.remove(oldLabel);
map[newLabel] = newAddress;
parsedBlocks[blockKey] = {for (final e in block.entries) e.key: Map.of(e.value)};
record!.setParsedAddress(blockKey, currency, newLabel, newAddress);
_rememberOriginal(blockKey: blockKey);
state = ExecutedSuccessfullyState();
}
@action
Future<void> deleteCurrentAddress() async {
if (!isAddressEdit) return;
_ensureRecord();
if (mode == ContactEditMode.manualAddress) {
final map = manual[_originalCur]!;
map.remove(_originalLabel);
if (map.isEmpty) manual.remove(_originalCur);
manual[_originalCur!] = Map.of(map);
record!.removeManualAddress(_originalCur!, _originalLabel!);
} else {
final block = parsedBlocks[_originalHandleKey]!;
final curMap = block[_originalCur]!;
curMap.remove(_originalLabel);
if (curMap.isEmpty) block.remove(_originalCur);
if (block.isEmpty)
parsedBlocks.remove(_originalHandleKey);
else
parsedBlocks[_originalHandleKey!] = {for (final e in block.entries) e.key: Map.of(e.value)};
record!.removeParsedAddress(_originalHandleKey!, _originalCur!, _originalLabel!);
}
state = ExecutedSuccessfullyState();
}
@action
Future<void> deleteParsedBlock(String handleKey) async {
if (!parsedBlocks.containsKey(handleKey)) return;
parsedBlocks.remove(handleKey);
record!.removeParsedAddress(handleKey, null, null);
state = ExecutedSuccessfullyState();
}
@action
Future<void> deleteContact() async {
if (record == null) return;
await record!.original.delete();
record = null;
reset();
state = ExecutedSuccessfullyState();
}
@action
void reset() {
address = '';
name = '';
currency = null;
name = handle = profileName = description = imagePath = '';
label = address = handleKey = '';
currency = CryptoCurrency.xmr;
manual.clear();
parsed.clear();
parsedBlocks.clear();
_originalCur = null;
_originalLabel = null;
_originalAddress = null;
_originalHandleKey = null;
state = InitialExecutionState();
}
Future<void> save() async {
try {
state = IsExecutingState();
final now = DateTime.now();
void _initMapsFromRecord() {
if (record == null) return;
final nameExists = _contact == null
? doesContactNameExist(name)
: doesContactNameExist(name) && _contact.original.name != name;
if (nameExists) {
state = FailureState(S.current.contact_name_exists);
return;
}
if (_contact != null && _contact.original.isInBox) {
_contact.name = name;
_contact.address = address;
_contact.type = currency!;
_contact.lastChange = now;
await _contact.save();
} else {
await _contacts
.add(Contact(name: name, address: address, type: currency!, lastChange: now));
}
lastChange = now;
state = ExecutedSuccessfullyState();
} catch (e) {
state = FailureState(e.toString());
}
manual = ObservableMap.of(record!.manual);
parsed = ObservableMap.of(record!.parsedByCurrency);
parsedBlocks = ObservableMap.of(record!.parsedBlocks);
}
bool doesContactNameExist(String name) {
return _contacts.values.any((contact) => contact.name == name);
void _ensureRecord() {
if (record != null) return;
final newContact = Contact(name: name.trim().isEmpty ? 'No name' : name, address: '');
box.put(newContact.key, newContact);
record = ContactRecord(box, newContact);
}
}
String _defaultHandleKey() => '${sourceType.label}-${handle}'.trim();
void _rememberOriginal({String? blockKey}) {
_originalCur = currency;
_originalLabel = label.trim().isEmpty ? currency.title : label.trim();
_originalAddress = address.trim();
_originalHandleKey = blockKey ?? _defaultHandleKey();
}
late final Map<String, (bool Function(), void Function(bool))> lookupMap = settingsStore != null
? {
AddressSource.twitter.label: (
() => settingsStore!.lookupsTwitter,
(v) => settingsStore!.lookupsTwitter = v
),
AddressSource.zanoAlias.label: (
() => settingsStore!.lookupsZanoAlias,
(v) => settingsStore!.lookupsZanoAlias = v
),
AddressSource.mastodon.label: (
() => settingsStore!.lookupsMastodon,
(v) => settingsStore!.lookupsMastodon = v
),
AddressSource.yatRecord.label: (
() => settingsStore!.lookupsYatService,
(v) => settingsStore!.lookupsYatService = v
),
AddressSource.unstoppableDomains.label: (
() => settingsStore!.lookupsUnstoppableDomains,
(v) => settingsStore!.lookupsUnstoppableDomains = v
),
AddressSource.openAlias.label: (
() => settingsStore!.lookupsOpenAlias,
(v) => settingsStore!.lookupsOpenAlias = v
),
AddressSource.ens.label: (
() => settingsStore!.lookupsENS,
(v) => settingsStore!.lookupsENS = v
),
AddressSource.wellKnown.label: (
() => settingsStore!.lookupsWellKnown,
(v) => settingsStore!.lookupsWellKnown = v
),
AddressSource.fio.label: (
() => settingsStore!.lookupsFio,
(v) => settingsStore!.lookupsFio = v
),
AddressSource.nostr.label: (
() => settingsStore!.lookupsNostr,
(v) => settingsStore!.lookupsNostr = v
),
AddressSource.thorChain.label: (
() => settingsStore!.lookupsThorChain,
(v) => settingsStore!.lookupsThorChain = v
),
AddressSource.bip353.label: (
() => settingsStore!.lookupsBip353,
(v) => settingsStore!.lookupsBip353 = v
),
}
: {};
}

View file

@ -73,10 +73,9 @@ class WalletRestoreFromQRCode {
static String? _extractAddressFromUrl(String rawString, WalletType type) {
try {
return AddressResolver.extractAddressByType(
return AddressResolverService.extractAddressByType(
raw: rawString,
type: walletTypeToCryptoCurrency(type),
requireSurroundingWhitespaces: false,
);
} catch (_) {
return null;

View file

@ -45,7 +45,7 @@ abstract class OutputBase with Store {
address = '',
note = '',
extractedAddress = '',
parsedAddress = ParsedAddress(addresses: []) {
parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}) {
_setCryptoNumMaximumFractionDigits();
}
@ -79,7 +79,7 @@ abstract class OutputBase with Store {
@computed
bool get isParsedAddress =>
parsedAddress.parseFrom != ParseFrom.notParsed && parsedAddress.name.isNotEmpty;
parsedAddress.addressSource != AddressSource.notParsed && parsedAddress.handle != null;
@observable
String? stealthAddress;
@ -241,7 +241,7 @@ abstract class OutputBase with Store {
void resetParsedAddress() {
extractedAddress = '';
parsedAddress = ParsedAddress(addresses: []);
parsedAddress = ParsedAddress( parsedAddressByCurrencyMap: {});
}
@action
@ -323,15 +323,16 @@ abstract class OutputBase with Store {
Future<void> fetchParsedAddress(BuildContext context) async {
final domain = address;
final currency = cryptoCurrencyHandler();
parsedAddress = await getIt.get<AddressResolver>().resolve(context, domain, currency);
extractedAddress = await extractAddressFromParsed(context, parsedAddress);
note = parsedAddress.description;
final parsedAddresses = await getIt.get<AddressResolverService>().resolve(query: domain, currency: currency, wallet: _wallet);
parsedAddress = parsedAddresses.first;
extractedAddress = ''; //TODO: fix return parsedAddress.addressByCurrencyMap[currency] ?? '';
note = parsedAddress.description ?? '';
}
void loadContact(ContactBase contact) {
address = contact.name;
parsedAddress = ParsedAddress.fetchContactAddress(address: contact.address, name: contact.name);
extractedAddress = parsedAddress.addresses.first;
note = parsedAddress.description;
parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {contact.type : contact.address}, handle: contact.name);
extractedAddress = parsedAddress.parsedAddressByCurrencyMap[contact.type] ?? '';
note = parsedAddress.description ?? '';
}
}

View file

@ -312,7 +312,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
List<CryptoCurrency> currencies;
bool get hasYat => outputs
.any((out) => out.isParsedAddress && out.parsedAddress.parseFrom == ParseFrom.yatRecord);
.any((out) => out.isParsedAddress && out.parsedAddress.addressSource == AddressSource.yatRecord);
WalletType get walletType => wallet.type;
@ -431,7 +431,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
outputs.first.address = paymentRequest.address;
outputs.first.parsedAddress =
ParsedAddress(addresses: [paymentRequest.address], name: ocpRequest!.receiverName);
ParsedAddress(parsedAddressByCurrencyMap: {currency:paymentRequest.address}, handle: ocpRequest!.receiverName);
outputs.first.setCryptoAmount(paymentRequest.amount);
outputs.first.note = ocpRequest!.receiverName;
@ -708,7 +708,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
for (var output in outputs) {
String address;
if (output.isParsedAddress) {
address = output.parsedAddress.addresses.first;
address = output.parsedAddress.parsedAddressByCurrencyMap[selectedCryptoCurrency] ?? '';
} else {
address = output.address;
}
@ -720,8 +720,15 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
contactListViewModel.contactSource,
Contact(
name: '',
address: address,
type: selectedCryptoCurrency,
parsedByHandle: {
'handle':
{
3: {'label4': address}
}
},
manualAddresses: {3: {'label5':address} //TODO : Fix this hardcoded value
},
));
}
}

View file

@ -103,6 +103,18 @@ abstract class PrivacySettingsViewModelBase with Store {
@computed
bool get looksUpWellKnown => _settingsStore.lookupsWellKnown;
@computed
bool get lookupsFio => _settingsStore.lookupsFio;
@computed
bool get lookupsNostr => _settingsStore.lookupsNostr;
@computed
bool get lookupsThorChain => _settingsStore.lookupsThorChain;
@computed
bool get lookupsBip353 => _settingsStore.lookupsBip353;
@computed
bool get usePayjoin => _settingsStore.usePayjoin;
@ -169,6 +181,18 @@ abstract class PrivacySettingsViewModelBase with Store {
ethereum!.updateEtherscanUsageState(_wallet, value);
}
@action
void setLookupsFio(bool value) => _settingsStore.lookupsFio = value;
@action
void setLookupsNostr(bool value) => _settingsStore.lookupsNostr = value;
@action
void setLookupsThorChain(bool value) => _settingsStore.lookupsThorChain = value;
@action
void setLookupsBip353(bool value) => _settingsStore.lookupsBip353 = value;
@action
void setUsePolygonScan(bool value) {
_settingsStore.usePolygonScan = value;