From 6a21d3d1e807828ffb0be1b5cbb9571d1c6cf3ad Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 15 Dec 2024 19:15:08 +0100 Subject: [PATCH 01/51] ndk --- .vscode/settings.json | 2 +- android/app/build.gradle | 12 ++- android/build.gradle | 14 +++ android/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 2 +- pubspec.lock | 91 ++++++++++++------- pubspec.yaml | 10 +- 8 files changed, 92 insertions(+), 42 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b734807..644cd98 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": "~/fvm/default", + "dart.flutterSdkPath": "/usr/bin/flutter", // Remove .fvm files from search "search.exclude": { "**/.fvm": true diff --git a/android/app/build.gradle b/android/app/build.gradle index 4447442..42a8c5e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,15 +31,19 @@ if (keystorePropertiesFile.exists()) { android { namespace "dev.zapstore.app" compileSdk flutter.compileSdkVersion - ndkVersion flutter.ndkVersion + ndkVersion "25.1.8937393" compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + + kotlin { + jvmToolchain(21) } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '21' } sourceSets { diff --git a/android/build.gradle b/android/build.gradle index 3af9b05..32688b0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,6 +18,20 @@ subprojects { } } +subprojects { + afterEvaluate { project -> + if (project.hasProperty('android')) { + project.android { +// compileSdkVersion 34 + if (namespace == null) { + namespace project.group + } + } + } + } +} + + rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" diff --git a/android/gradle.properties b/android/gradle.properties index 598d13f..f33d08c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +kotlin.jvm.target.validation.mode = IGNORE \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..afa1e8e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index c9926a2..df8b135 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version "8.3.2" apply false id "org.jetbrains.kotlin.android" version "2.0.21" apply false } diff --git a/pubspec.lock b/pubspec.lock index cd434c1..04de54f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" android_package_manager: dependency: "direct main" description: @@ -130,10 +130,10 @@ packages: dependency: transitive description: name: bip340 - sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" + sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.0" boolean_selector: dependency: transitive description: @@ -266,10 +266,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: "direct main" description: @@ -526,6 +526,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" hooks_riverpod: dependency: "direct main" description: @@ -622,14 +630,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + isar: + dependency: transitive + description: + name: isar + sha256: ebf74d87c400bd9f7da14acb31932b50c2407edbbd40930da3a6c2a8143f85a8 + url: "https://pub.dev" + source: hosted + version: "4.0.0-dev.14" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -650,18 +666,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -678,6 +694,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: @@ -690,10 +714,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" markdown: dependency: transitive description: @@ -742,6 +766,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + ndk: + dependency: "direct overridden" + description: + path: "../ndk/packages/ndk" + relative: true + source: path + version: "0.2.0-dev005" octo_image: dependency: transitive description: @@ -937,11 +968,9 @@ packages: purplebase: dependency: "direct main" description: - path: "." - ref: cbe8342031adf908c8243125e176f52739d400d8 - resolved-ref: cbe8342031adf908c8243125e176f52739d400d8 - url: "https://github.com/purplebase/purplebase" - source: git + path: "../purplebase" + relative: true + source: path version: "0.2.0" quiver: dependency: transitive @@ -1067,7 +1096,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -1160,10 +1189,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" state_notifier: dependency: transitive description: @@ -1192,10 +1221,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" synchronized: dependency: transitive description: @@ -1224,10 +1253,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" timing: dependency: transitive description: @@ -1344,10 +1373,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b5681cd..9622e54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,11 +53,13 @@ dependencies: easy_image_viewer: ^1.5.1 dependency_overrides: + ndk: + path: ../ndk/packages/ndk purplebase: - # path: ../purplebase - git: - url: https://github.com/purplebase/purplebase - ref: cbe8342031adf908c8243125e176f52739d400d8 + path: ../purplebase +# git: +# url: https://github.com/purplebase/purplebase +# ref: cbe8342031adf908c8243125e176f52739d400d8 flutter_data: git: url: https://github.com/zapstore/flutter_data From 2f9af27a6ad206ab52c6c88de8c9afdc76b8fbcf Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 16 Dec 2024 16:52:11 +0100 Subject: [PATCH 02/51] deps --- pubspec.lock | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 94c4e75..f1538db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,10 +130,10 @@ packages: dependency: transitive description: name: bip340 - sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" + sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.0" boolean_selector: dependency: transitive description: @@ -526,6 +526,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" hooks_riverpod: dependency: "direct main" description: @@ -622,14 +630,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + isar: + dependency: transitive + description: + name: isar + sha256: ebf74d87c400bd9f7da14acb31932b50c2407edbbd40930da3a6c2a8143f85a8 + url: "https://pub.dev" + source: hosted + version: "4.0.0-dev.14" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -678,6 +694,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: @@ -742,6 +766,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + ndk: + dependency: "direct overridden" + description: + path: "../ndk/packages/ndk" + relative: true + source: path + version: "0.2.0-dev005" octo_image: dependency: transitive description: @@ -937,11 +968,9 @@ packages: purplebase: dependency: "direct main" description: - path: "." - ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a" - resolved-ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a" - url: "https://github.com/purplebase/purplebase" - source: git + path: "../purplebase" + relative: true + source: path version: "0.2.0" quiver: dependency: transitive From f21fab9c940de5738acec60477fb004d0755b858 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 17 Dec 2024 12:33:01 +0100 Subject: [PATCH 03/51] change deps to use git commit ref --- pubspec.lock | 16 ++++++++++------ pubspec.yaml | 12 +++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index f1538db..88c81d5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -769,9 +769,11 @@ packages: ndk: dependency: "direct overridden" description: - path: "../ndk/packages/ndk" - relative: true - source: path + path: "packages/ndk" + ref: c279076973ff7b017697f4fbd172738535756c50 + resolved-ref: c279076973ff7b017697f4fbd172738535756c50 + url: "https://github.com/relaystr/ndk" + source: git version: "0.2.0-dev005" octo_image: dependency: transitive @@ -968,9 +970,11 @@ packages: purplebase: dependency: "direct main" description: - path: "../purplebase" - relative: true - source: path + path: "." + ref: "44f6388ab42fe706df61e30531d9361b19c1a19a" + resolved-ref: "44f6388ab42fe706df61e30531d9361b19c1a19a" + url: "https://github.com/purplebase/purplebase" + source: git version: "0.2.0" quiver: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 6876a61..03d640a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,12 +54,14 @@ dependencies: dependency_overrides: ndk: - path: ../ndk/packages/ndk + git: + url: https://github.com/relaystr/ndk + path: packages/ndk + ref: c279076973ff7b017697f4fbd172738535756c50 purplebase: - path: ../purplebase -# git: -# url: https://github.com/purplebase/purplebase -# ref: 7f0eefd1fd1bcbc947123dc377183dbae58ea97a + git: + url: https://github.com/purplebase/purplebase + ref: 44f6388ab42fe706df61e30531d9361b19c1a19a flutter_data: git: url: https://github.com/zapstore/flutter_data From e6b195c6a9c730590bcbd2cffcf2671f314280e1 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 17 Dec 2024 15:53:33 +0100 Subject: [PATCH 04/51] inital code WiP --- lib/main.data.dart | 11 +++ lib/screens/settings_screen.dart | 91 ++++++++++++++++++- lib/utils/nwc_notifier.dart | 24 +++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 50 +++++++++- pubspec.yaml | 2 + 8 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 lib/utils/nwc_notifier.dart diff --git a/lib/main.data.dart b/lib/main.data.dart index 403cff0..7fac59b 100644 --- a/lib/main.data.dart +++ b/lib/main.data.dart @@ -3,6 +3,7 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ndk/ndk.dart'; import 'package:zapstore/models/app_curation_set.dart'; import 'package:zapstore/models/app.dart'; @@ -11,6 +12,7 @@ import 'package:zapstore/models/local_app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/utils/nwc_notifier.dart'; final adapterProvidersMap = >>{ 'appCurationSets': appCurationSetsAdapterProvider, @@ -50,4 +52,13 @@ extension AdapterRefX on Ref { Adapter get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; + } + +final nwcSecretProvider = + StateNotifierProvider((ref) { + return NwcSecretNotifier(); +}); + +final ndk = Ndk.emptyBootstrapRelaysConfig(); + diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 952cdaa..63415fa 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,6 +10,8 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; +import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; import 'package:path_provider/path_provider.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/feedback.dart'; @@ -26,12 +28,16 @@ class SettingsScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final systemInfoState = ref.watch(systemInfoNotifierProvider); - final controller = useTextEditingController(); + final feedbackController = useTextEditingController(); + final nwcController = useTextEditingController(); final user = ref.settings .watchOne('_', alsoWatch: (_) => {_.user}) .model! .user .value; + final isTextFieldEmpty = useState(true); + + final nwcSecret = ref.watch(nwcSecretProvider); return SingleChildScrollView( child: Column( @@ -51,7 +57,7 @@ class SettingsScreen extends HookConsumerWidget { Text('Comments, suggestions and error reports welcome here.'), Gap(20), TextField( - controller: controller, + controller: feedbackController, maxLines: 10, ), Gap(20), @@ -61,9 +67,9 @@ class SettingsScreen extends HookConsumerWidget { loadingWidget: SizedBox( width: 14, height: 14, child: CircularProgressIndicator()), onPressed: () async { - if (controller.text.trim().isNotEmpty) { + if (feedbackController.text.trim().isNotEmpty) { final text = - '${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; + '${feedbackController.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; final event = AppFeedback(content: text).sign(kI); try { await http.post(Uri.parse('https://relay.zapstore.dev/'), @@ -73,7 +79,7 @@ class SettingsScreen extends HookConsumerWidget { context.showInfo('Thank you', description: 'Message sent successfully'); } - controller.clear(); + feedbackController.clear(); } catch (e, stack) { if (context.mounted) { context.showError( @@ -96,6 +102,81 @@ class SettingsScreen extends HookConsumerWidget { Gap(40), Divider(), Gap(40), + if (user != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Gap(20), + Text( + "Nostr Wallet Connect ${nwcSecret != null ? 'connected' : 'URI'}"), + if (nwcSecret != null && nwcSecret != '') + ElevatedButton( + onPressed: () async { + await ref + .read(nwcSecretProvider.notifier) + .updateNwcSecret(null); + }, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: Text('Disconnect'), + ), + if (nwcSecret == null || nwcSecret == '') + TextField( + autocorrect: false, + controller: nwcController, + onChanged: (value) { + isTextFieldEmpty.value = value.isEmpty; + }, + decoration: InputDecoration( + hintText: 'nostr+walletconnect://...', + suffixIcon: AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + onPressed: () async { + final nwcUri = nwcController.text.trim(); + NwcConnection connection = await ndk.nwc.connect(nwcUri, doGetInfoMethod: true, onError: (error) { + context.showError( + title: 'Unable to connect', + description: error); + }); + if (connection.info!=null && connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + await ref + .read(nwcSecretProvider.notifier) + .updateNwcSecret(nwcUri); + } else { + context.showError( + title: 'Wallet missing pay_invoice permission', + description: ''); + } + }, + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), + ), + ); + }, + child: Text('Connect'), + ), + ), + ), + ], + ), + Gap(40), + Divider(), + Gap(40), Text( 'Tools', style: context.theme.textTheme.headlineLarge! diff --git a/lib/utils/nwc_notifier.dart b/lib/utils/nwc_notifier.dart new file mode 100644 index 0000000..ecdfe84 --- /dev/null +++ b/lib/utils/nwc_notifier.dart @@ -0,0 +1,24 @@ +import 'package:flutter_data/flutter_data.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class NwcSecretNotifier extends StateNotifier { + final _storage = const FlutterSecureStorage(); + + NwcSecretNotifier() : super(null) { + _loadNwcSecret(); + } + + Future _loadNwcSecret() async { + final secret = await _storage.read(key: 'nwc_secret'); + state = secret; + } + + Future updateNwcSecret(String? newSecret) async { + if (newSecret != null) { + await _storage.write(key: 'nwc_secret', value: newSecret); + } else { + await _storage.delete(key: 'nwc_secret'); + } + await _loadNwcSecret(); + } +} \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fc949e0..57efdf4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c34d078..9137173 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux gtk sqlite3_flutter_libs url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2060261..dc32300 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import app_links import device_info_plus +import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation import sqflite_darwin @@ -16,6 +17,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 88c81d5..f87169f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -452,6 +452,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + url: "https://pub.dev" + source: hosted + version: "9.2.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -767,7 +815,7 @@ packages: source: hosted version: "3.1.0" ndk: - dependency: "direct overridden" + dependency: "direct main" description: path: "packages/ndk" ref: c279076973ff7b017697f4fbd172738535756c50 diff --git a/pubspec.yaml b/pubspec.yaml index 03d640a..62a371e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,8 @@ dependencies: background_downloader: ^8.8.0 mutex: ^3.1.0 easy_image_viewer: ^1.5.1 + flutter_secure_storage: ^9.2.2 + ndk: ^0.2.0-dev005 dependency_overrides: ndk: From 8a91952826cfa68d707dbb184ee1c23470614e19 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 17 Dec 2024 16:31:14 +0100 Subject: [PATCH 05/51] zap button --- lib/screens/app_detail_screen.dart | 9 ++++ lib/screens/settings_screen.dart | 6 +++ lib/widgets/zap_button.dart | 69 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 lib/widgets/zap_button.dart diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index b049fec..d12ebad 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -18,6 +18,7 @@ import 'package:zapstore/widgets/signer_and_developer_row.dart'; import 'package:zapstore/widgets/spinning_logo.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; import 'package:zapstore/widgets/wot_container.dart'; +import 'package:zapstore/widgets/zap_button.dart'; class AppDetailScreen extends HookConsumerWidget { final App model; @@ -39,6 +40,8 @@ class AppDetailScreen extends HookConsumerWidget { } return ref.apps.findOne(model.identifier!, remote: true); })); + final nwcSecret = ref.watch(nwcSecretProvider); + final state = ref.apps.watchOne(model.id!, alsoWatch: (_) => { _.localApp, @@ -317,6 +320,12 @@ class AppDetailScreen extends HookConsumerWidget { ], ), ), + SizedBox( + height: 50, + child: Center( + child: ZapButton(app: app), + ), + ), SizedBox( height: 50, child: Center( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 63415fa..7dc55fd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -12,6 +12,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/get_balance_response.dart'; import 'package:path_provider/path_provider.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/feedback.dart'; @@ -148,6 +149,11 @@ class SettingsScreen extends HookConsumerWidget { await ref .read(nwcSecretProvider.notifier) .updateNwcSecret(nwcUri); + context.showInfo("Wallet connected!"); + if (connection.permissions.contains(NwcMethod.GET_BALANCE.name)) { + GetBalanceResponse balance = await ndk.nwc.getBalance(connection); + context.showInfo("${balance.balanceSats} sats in wallet"); + } } else { context.showError( title: 'Wallet missing pay_invoice permission', diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart new file mode 100644 index 0000000..a86bd6b --- /dev/null +++ b/lib/widgets/zap_button.dart @@ -0,0 +1,69 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:percent_indicator/linear_percent_indicator.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/app.dart'; +import 'package:zapstore/models/local_app.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/system_info.dart'; +import 'package:zapstore/widgets/install_alert_dialog.dart'; +import 'package:zapstore/widgets/spinning_logo.dart'; + +class ZapButton extends HookConsumerWidget { + ZapButton({ + super.key, + required this.app, + this.compact = false, + }); + + final bool compact; + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final progress = ref.watch(installationProgressProvider(app.id!)); + final status = app.localApp.value?.status; + final nwcSecret = ref.watch(nwcSecretProvider); + + useMemoized(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (progress is IdleInstallProgress && progress.success == true) { + context.showInfo('Success', + description: + '${app.name ?? app.identifier} was successfully installed'); + } + }); + }, [app.id, progress]); + + return GestureDetector( + onTap: () { + User? developer = app.developer.value; + if (developer!=null) { + // TODO get dev lud16 and get invoice from it, then pay invoice. + } + }, + child: LinearPercentIndicator( + lineHeight: compact ? 30 : 42, + percent: switch (progress) { + VerifyingHashProgress() => 1, + DownloadingInstallProgress(:final progress) => progress, + _ => switch (status) { + AppInstallStatus.updated => 1, + _ => 0, + }, + }, + backgroundColor: nwcSecret!=null && nwcSecret!=''? kUpdateColor : Colors.grey, + progressColor: Colors.blue[800], + barRadius: Radius.circular(20), + padding: EdgeInsets.all(0), + animateFromLastPercent: true, + center: Text('Zap ⚡'), + ), + ); + } +} + +final kUpdateColor = Colors.blue[700]!; From ce6976a2ee6303ace662bd42ce70a33a751444fc Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 17 Dec 2024 16:33:48 +0100 Subject: [PATCH 06/51] zap button --- lib/widgets/zap_button.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index a86bd6b..f02e273 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -55,7 +55,7 @@ class ZapButton extends HookConsumerWidget { _ => 0, }, }, - backgroundColor: nwcSecret!=null && nwcSecret!=''? kUpdateColor : Colors.grey, + backgroundColor: nwcSecret!=null && nwcSecret!='' && app.developer.value!=null? kUpdateColor : Colors.grey, progressColor: Colors.blue[800], barRadius: Radius.circular(20), padding: EdgeInsets.all(0), From 1d96810b32378d1dddf442c05e876c4272841c03 Mon Sep 17 00:00:00 2001 From: Fmar Date: Wed, 18 Dec 2024 17:17:29 +0100 Subject: [PATCH 07/51] make it work --- android/app/build.gradle | 2 +- lib/main.data.dart | 12 +-- lib/screens/app_detail_screen.dart | 9 +- lib/screens/settings_screen.dart | 42 +++----- lib/utils/nwc.dart | 69 +++++++++++++ lib/utils/nwc_notifier.dart | 24 ----- lib/utils/zap.dart | 56 ++++++++++ lib/widgets/zap_button.dart | 161 ++++++++++++++++++++--------- pubspec.lock | 68 +++++++----- pubspec.yaml | 5 +- 10 files changed, 304 insertions(+), 144 deletions(-) create mode 100644 lib/utils/nwc.dart delete mode 100644 lib/utils/nwc_notifier.dart create mode 100644 lib/utils/zap.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 42a8c5e..259e595 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -52,7 +52,7 @@ android { defaultConfig { applicationId "dev.zapstore.app" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/main.data.dart b/lib/main.data.dart index 7fac59b..36d2d28 100644 --- a/lib/main.data.dart +++ b/lib/main.data.dart @@ -3,16 +3,13 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:ndk/ndk.dart'; - -import 'package:zapstore/models/app_curation_set.dart'; import 'package:zapstore/models/app.dart'; +import 'package:zapstore/models/app_curation_set.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/local_app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/nwc_notifier.dart'; final adapterProvidersMap = >>{ 'appCurationSets': appCurationSetsAdapterProvider, @@ -55,10 +52,3 @@ extension AdapterRefX on Ref { } -final nwcSecretProvider = - StateNotifierProvider((ref) { - return NwcSecretNotifier(); -}); - -final ndk = Ndk.emptyBootstrapRelaysConfig(); - diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index d12ebad..51bdeb9 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -12,6 +12,7 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nwc.dart'; import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/release_card.dart'; import 'package:zapstore/widgets/signer_and_developer_row.dart'; @@ -158,6 +159,8 @@ class AppDetailScreen extends HookConsumerWidget { child: SignerAndDeveloperRow(app: app), ), Gap(20), + ZapButton(app: app), + Gap(20), Container( padding: EdgeInsets.all(10), decoration: BoxDecoration( @@ -320,12 +323,6 @@ class AppDetailScreen extends HookConsumerWidget { ], ), ), - SizedBox( - height: 50, - child: Center( - child: ZapButton(app: app), - ), - ), SizedBox( height: 50, child: Center( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7dc55fd..2a17fb3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,18 +9,16 @@ import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; -import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; -import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; -import 'package:ndk/domain_layer/usecases/nwc/responses/get_balance_response.dart'; import 'package:path_provider/path_provider.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/feedback.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nwc.dart'; import 'package:zapstore/utils/system_info.dart'; import 'package:zapstore/widgets/app_drawer.dart'; -import 'package:http/http.dart' as http; class SettingsScreen extends HookConsumerWidget { const SettingsScreen({super.key}); @@ -38,7 +36,7 @@ class SettingsScreen extends HookConsumerWidget { .value; final isTextFieldEmpty = useState(true); - final nwcSecret = ref.watch(nwcSecretProvider); + final nwcConnection = ref.watch(nwcConnectionProvider); return SingleChildScrollView( child: Column( @@ -109,20 +107,23 @@ class SettingsScreen extends HookConsumerWidget { children: [ Gap(20), Text( - "Nostr Wallet Connect ${nwcSecret != null ? 'connected' : 'URI'}"), - if (nwcSecret != null && nwcSecret != '') + "Nostr Wallet Connect ${nwcConnection != null ? 'connected' : 'URI'}"), + if (nwcConnection != null && nwcConnection != '') ElevatedButton( onPressed: () async { await ref .read(nwcSecretProvider.notifier) .updateNwcSecret(null); + await ref + .read(nwcConnectionProvider.notifier) + .setConnection(null); }, style: ElevatedButton.styleFrom( disabledBackgroundColor: Colors.transparent, backgroundColor: Colors.transparent), child: Text('Disconnect'), ), - if (nwcSecret == null || nwcSecret == '') + if (nwcConnection == null) TextField( autocorrect: false, controller: nwcController, @@ -140,25 +141,12 @@ class SettingsScreen extends HookConsumerWidget { ), onPressed: () async { final nwcUri = nwcController.text.trim(); - NwcConnection connection = await ndk.nwc.connect(nwcUri, doGetInfoMethod: true, onError: (error) { - context.showError( - title: 'Unable to connect', - description: error); - }); - if (connection.info!=null && connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - await ref - .read(nwcSecretProvider.notifier) - .updateNwcSecret(nwcUri); - context.showInfo("Wallet connected!"); - if (connection.permissions.contains(NwcMethod.GET_BALANCE.name)) { - GetBalanceResponse balance = await ndk.nwc.getBalance(connection); - context.showInfo("${balance.balanceSats} sats in wallet"); - } - } else { - context.showError( - title: 'Wallet missing pay_invoice permission', - description: ''); - } + await ref + .read(nwcSecretProvider.notifier) + .updateNwcSecret(nwcUri); + await ref + .read(nwcConnectionProvider.notifier) + .ensureConnected(nwcUri); }, builder: (context, child, callback, buttonState) { return Padding( diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart new file mode 100644 index 0000000..7536851 --- /dev/null +++ b/lib/utils/nwc.dart @@ -0,0 +1,69 @@ +import 'package:flutter_data/flutter_data.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; +import 'package:ndk/ndk.dart'; + +class NwcSecretNotifier extends StateNotifier { + final _storage = const FlutterSecureStorage(); + + NwcSecretNotifier() : super(null) { + _loadNwcSecret(); + } + + Future _loadNwcSecret() async { + final secret = await _storage.read(key: 'nwc_secret'); + state = secret; + } + + Future updateNwcSecret(String? newSecret) async { + if (newSecret != null) { + await _storage.write(key: 'nwc_secret', value: newSecret); + } else { + await _storage.delete(key: 'nwc_secret'); + } + await _loadNwcSecret(); + } +} + +class NwcConnectionNotifier extends StateNotifier { + Ref ref; + NwcConnectionNotifier(this.ref) : super(null) { + ensureConnected(ref.watch(nwcSecretProvider)); + } + + setConnection(NwcConnection? c) { + state = c; + } + + Future ensureConnected(String? nwcSecret) async { + if (nwcSecret != null && nwcSecret != '' && state==null) { + NwcConnection connection = await ndkForNwc.nwc.connect( + nwcSecret, doGetInfoMethod: false, onError: (error) { + // TODO + // context.showError( + // title: 'Unable to connect', + // description: error); + }); + if ( + // connection.info != null && + connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + setConnection(connection); + } + } else { + setConnection(null); + } + } +} + +final nwcSecretProvider = +StateNotifierProvider((ref) { + return NwcSecretNotifier(); +}); + +final nwcConnectionProvider = +StateNotifierProvider((ref) { + return NwcConnectionNotifier(ref); +}); + +final ndkForNwc = Ndk.emptyBootstrapRelaysConfig(); + diff --git a/lib/utils/nwc_notifier.dart b/lib/utils/nwc_notifier.dart deleted file mode 100644 index ecdfe84..0000000 --- a/lib/utils/nwc_notifier.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -class NwcSecretNotifier extends StateNotifier { - final _storage = const FlutterSecureStorage(); - - NwcSecretNotifier() : super(null) { - _loadNwcSecret(); - } - - Future _loadNwcSecret() async { - final secret = await _storage.read(key: 'nwc_secret'); - state = secret; - } - - Future updateNwcSecret(String? newSecret) async { - if (newSecret != null) { - await _storage.write(key: 'nwc_secret', value: newSecret); - } else { - await _storage.delete(key: 'nwc_secret'); - } - await _loadNwcSecret(); - } -} \ No newline at end of file diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart new file mode 100644 index 0000000..4e47c42 --- /dev/null +++ b/lib/utils/zap.dart @@ -0,0 +1,56 @@ +import 'package:amberflutter/amberflutter.dart'; +import 'package:flutter_data/flutter_data.dart'; +import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_amber/ndk_amber.dart'; +import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/utils/nwc.dart'; + +import '../models/user.dart'; + +class ZapNotifier extends StateNotifier { + ZapNotifier() : super(null); + + Future zap( + {required NwcConnection nwcConnection, + required User user, + String? eventId, + required String lnurl, + required int amount, + required String pubKey}) async { + state = "zapping"; + String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); + final signer = AmberEventSigner( + publicKey: user.pubkey, amberFlutterDS: AmberFlutterDS(Amberflutter())); + String? invoice = await Lnurl.getInvoiceCode( + lud16Link: lud16Link!, + sats: amount, + recipientPubkey: pubKey, + eventId: eventId, + signer: signer, + relays: ndkForSocial.config.bootstrapRelays); + if (invoice == null) { + // TODO how show error? + // context.showError( + // title: "could not get invoice from ${lnurl}"); + return; + } + PayInvoiceResponse response = + await ndkForNwc.nwc.payInvoice(nwcConnection, invoice: invoice); + if (response.preimage != '') { + state = "zapped"; + Future.delayed(Duration(seconds: 3)).then((_) { + state = null; + }); + } + } +} + +final ndkForSocial = Ndk(NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: kSocialRelays.toList())); + +final zapProvider = StateNotifierProvider((ref) { + return ZapNotifier(); +}); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index f02e273..fcd5068 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,69 +1,136 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/local_app.dart'; +import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/install_alert_dialog.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; +import 'package:zapstore/utils/zap.dart'; + +import '../utils/nwc.dart'; class ZapButton extends HookConsumerWidget { ZapButton({ super.key, required this.app, - this.compact = false, }); - final bool compact; final App app; @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(installationProgressProvider(app.id!)); - final status = app.localApp.value?.status; - final nwcSecret = ref.watch(nwcSecretProvider); + final nwcConnection = ref.watch(nwcConnectionProvider); + final zapStatus = ref.watch(zapProvider); + final user = ref.settings + .watchOne('_', alsoWatch: (_) => {_.user}) + .model! + .user + .value; - useMemoized(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (progress is IdleInstallProgress && progress.success == true) { - context.showInfo('Success', - description: - '${app.name ?? app.identifier} was successfully installed'); - } - }); - }, [app.id, progress]); + final TextEditingController amountController = TextEditingController(); - return GestureDetector( - onTap: () { - User? developer = app.developer.value; - if (developer!=null) { - // TODO get dev lud16 and get invoice from it, then pay invoice. - } - }, - child: LinearPercentIndicator( - lineHeight: compact ? 30 : 42, - percent: switch (progress) { - VerifyingHashProgress() => 1, - DownloadingInstallProgress(:final progress) => progress, - _ => switch (status) { - AppInstallStatus.updated => 1, - _ => 0, + var text = 'Zap the dev ⚡'; + switch (zapStatus) { + case "zapping": + text = 'Zapping... ⚡⚡⚡'; + case "zapped": + text = 'Zapped!'; + } + + return app.developer.value != null + ? ElevatedButton( + onPressed: () async { + if (nwcConnection == null) { + context.showError(title: "need an NWC connection URI set"); + appRouter.go('/settings'); + return; + } + User? developer = app.developer.value; + if (developer == null) { + context.showError(title: "no dev set"); + return; + } + if (developer.lud16 == null) { + context.showError( + title: "${developer.nameOrNpub} has no LN address :("); + return; + } + if (user == null) { + context.showError(title: "must be logged in to sign zaps"); + appRouter.go('/settings'); + return; + } + int amount = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Choose Zap Amount'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: "Enter amount in sats"), + ), + SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(21), + child: Text('⚡ 21'), + ), + Gap(10), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(210), + child: Text('⚡ 210'), + ), + Gap(10), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(2100), + child: Text('⚡ 2100'), + ), + ], + ), + ], + ), + actions: [ + TextButton( + child: Text('Cancel'), + onPressed: () { + Navigator.of(context) + .pop(); // Dismiss the dialog without returning an amount + }, + ), + TextButton( + child: Text('Zap'), + onPressed: () { + Navigator.of(context).pop( + int.tryParse(amountController.text) ?? ''); + }, + ), + ], + ); + }, + ) ?? + 0; + final lnurl = developer.lud16!; + await ref.read(zapProvider.notifier).zap( + nwcConnection: nwcConnection, + user: user, + lnurl: lnurl, + eventId: app.latestMetadata!=null? app.latestMetadata!.id.toString(): app.latestRelease!.id.toString(), + amount: amount, + pubKey: developer.pubkey); }, - }, - backgroundColor: nwcSecret!=null && nwcSecret!='' && app.developer.value!=null? kUpdateColor : Colors.grey, - progressColor: Colors.blue[800], - barRadius: Radius.circular(20), - padding: EdgeInsets.all(0), - animateFromLastPercent: true, - center: Text('Zap ⚡'), - ), - ); + child: Text(text)) + : Container(); } } - -final kUpdateColor = Colors.blue[700]!; diff --git a/pubspec.lock b/pubspec.lock index f87169f..187c856 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,31 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" + amberflutter: + dependency: transitive + description: + name: amberflutter + sha256: "5101b36419a93e0620a227ada25690286efc93ab9de59dc80e7c3a0c29ccd348" + url: "https://pub.dev" + source: hosted + version: "0.0.9" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" android_package_manager: dependency: "direct main" description: @@ -266,10 +274,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: "direct main" description: @@ -714,18 +722,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -762,10 +770,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" markdown: dependency: transitive description: @@ -818,11 +826,19 @@ packages: dependency: "direct main" description: path: "packages/ndk" - ref: c279076973ff7b017697f4fbd172738535756c50 - resolved-ref: c279076973ff7b017697f4fbd172738535756c50 + ref: lnurl-zaps + resolved-ref: b68c1f1ef0d790dbecc98792f91816527dbde5f8 url: "https://github.com/relaystr/ndk" source: git version: "0.2.0-dev005" + ndk_amber: + dependency: "direct main" + description: + name: ndk_amber + sha256: "2f364d54b71df8883219b699bca3c9b0e878341c8f25024d511f0585103b5278" + url: "https://pub.dev" + source: hosted + version: "0.2.0-dev002" octo_image: dependency: transitive description: @@ -1019,8 +1035,8 @@ packages: dependency: "direct main" description: path: "." - ref: "44f6388ab42fe706df61e30531d9361b19c1a19a" - resolved-ref: "44f6388ab42fe706df61e30531d9361b19c1a19a" + ref: upgrade-bip340 + resolved-ref: "0fdc08652bfcc56c579cf2093f53b1a1aa03bf79" url: "https://github.com/purplebase/purplebase" source: git version: "0.2.0" @@ -1148,7 +1164,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -1241,10 +1257,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" state_notifier: dependency: transitive description: @@ -1273,10 +1289,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" synchronized: dependency: transitive description: @@ -1305,10 +1321,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" timing: dependency: transitive description: @@ -1425,10 +1441,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 62a371e..e95849c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,17 +53,18 @@ dependencies: easy_image_viewer: ^1.5.1 flutter_secure_storage: ^9.2.2 ndk: ^0.2.0-dev005 + ndk_amber: ^0.2.0-dev002 dependency_overrides: ndk: git: url: https://github.com/relaystr/ndk path: packages/ndk - ref: c279076973ff7b017697f4fbd172738535756c50 + ref: lnurl-zaps purplebase: git: url: https://github.com/purplebase/purplebase - ref: 44f6388ab42fe706df61e30531d9361b19c1a19a + ref: upgrade-bip340 flutter_data: git: url: https://github.com/zapstore/flutter_data From e15ccecb995b10a5f8cee234a41587110b7c41a3 Mon Sep 17 00:00:00 2001 From: Fmar Date: Wed, 18 Dec 2024 17:19:19 +0100 Subject: [PATCH 08/51] cleanup --- lib/screens/app_detail_screen.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 51bdeb9..accfb08 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -41,8 +41,6 @@ class AppDetailScreen extends HookConsumerWidget { } return ref.apps.findOne(model.identifier!, remote: true); })); - final nwcSecret = ref.watch(nwcSecretProvider); - final state = ref.apps.watchOne(model.id!, alsoWatch: (_) => { _.localApp, From 8fd71841423df6a45e45fdbde7596376f8af24e3 Mon Sep 17 00:00:00 2001 From: Fmar Date: Wed, 18 Dec 2024 18:36:11 +0100 Subject: [PATCH 09/51] allowBackup false + androidOptions --- android/app/src/main/AndroidManifest.xml | 1 + lib/utils/nwc.dart | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f009f25..72ca7c6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ android:label="Zapstore" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" + android:allowBackup="false" android:requestLegacyExternalStorage="true"> const AndroidOptions( + encryptedSharedPreferences: true, +); + class NwcSecretNotifier extends StateNotifier { - final _storage = const FlutterSecureStorage(); + final _storage = FlutterSecureStorage(aOptions: _getAndroidOptions()); NwcSecretNotifier() : super(null) { _loadNwcSecret(); From b59171ad19c11d5d6b9640e21ccbff08982993ef Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 19 Dec 2024 20:42:27 -0300 Subject: [PATCH 10/51] Integrate Amber signing, closes #3 (update to latest purplebase with Signer class) Move feedback to DMs, closes #188 Fix relationships in user, utils --- lib/models/nostr_adapter.dart | 2 +- lib/models/settings.dart | 6 + lib/models/settings.g.dart | 15 +- lib/models/user.dart | 20 ++- lib/models/user.g.dart | 14 ++ lib/navigation/app_initializer.dart | 5 + lib/screens/settings_screen.dart | 39 +++-- lib/utils/extensions.dart | 13 +- lib/utils/signers.dart | 60 +++++++ lib/widgets/app_drawer.dart | 124 -------------- lib/widgets/drawer_container.dart | 10 +- lib/widgets/error_container.dart | 3 +- lib/widgets/install_alert_dialog.dart | 6 +- lib/widgets/sign_in_container.dart | 227 ++++++++++++++++++++++++++ pubspec.lock | 53 +++++- pubspec.yaml | 7 +- 16 files changed, 437 insertions(+), 167 deletions(-) create mode 100644 lib/utils/signers.dart delete mode 100644 lib/widgets/app_drawer.dart create mode 100644 lib/widgets/sign_in_container.dart diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index fdb61d4..db25fe8 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -13,7 +13,7 @@ import 'package:zapstore/widgets/latest_releases_container.dart'; // NOTE: Very important to use const in relay args to preserve equality in Riverpod families // const kAppRelays = {'ws://10.0.2.2:3000'}; const kAppRelays = {'wss://relay.zapstore.dev'}; -const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net'}; +const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net', 'ndk'}; mixin NostrAdapter> on Adapter { RelayMessageNotifier get relay => diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7791fea..3c8c709 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,6 +11,12 @@ class Settings extends DataModel { String get id => '_'; final BelongsTo user = BelongsTo(); final HasMany trustedUsers = HasMany(); + SignInMethod? signInMethod; +} + +enum SignInMethod { + pubkey, + nip55, } mixin SettingsAdapter on Adapter { diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 93a04ef..35e55c4 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -12,12 +12,14 @@ mixin _$SettingsAdapter on Adapter { static final Map _kSettingsRelationshipMetas = { 'user': RelationshipMeta( name: 'user', + inverseName: 'settings', type: 'users', kind: 'BelongsTo', instance: (_) => (_ as Settings).user, ), 'trustedUsers': RelationshipMeta( name: 'trustedUsers', + inverseName: 'settings', type: 'users', kind: 'HasMany', instance: (_) => (_ as Settings).trustedUsers, @@ -73,6 +75,15 @@ extension SettingsRelationshipGraphNodeX on RelationshipGraphNode { // JsonSerializableGenerator // ************************************************************************** -Settings _$SettingsFromJson(Map json) => Settings(); +Settings _$SettingsFromJson(Map json) => Settings() + ..signInMethod = + $enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']); -Map _$SettingsToJson(Settings instance) => {}; +Map _$SettingsToJson(Settings instance) => { + 'signInMethod': _$SignInMethodEnumMap[instance.signInMethod], + }; + +const _$SignInMethodEnumMap = { + SignInMethod.pubkey: 'pubkey', + SignInMethod.nip55: 'nip55', +}; diff --git a/lib/models/user.dart b/lib/models/user.dart index 31a7239..223d44f 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/models/settings.dart'; import 'package:zapstore/utils/extensions.dart'; part 'user.g.dart'; @@ -10,14 +12,20 @@ part 'user.g.dart'; @DataAdapter([NostrAdapter, UserAdapter]) class User extends BaseUser with DataModelMixin { User( - {super.createdAt, + {super.name, + super.createdAt, super.tags, - required this.followers, - required this.following}); + HasMany? followers, + HasMany? following, + BelongsTo? settings}) + : followers = followers ?? HasMany(), + following = following ?? HasMany(), + settings = settings ?? BelongsTo(); User.fromJson(super.map) : followers = hasMany(map['followers']), following = hasMany(map['following']), + settings = belongsTo(map['settings']), super.fromJson(); Map toJson() => super.toMap(); @@ -26,8 +34,10 @@ class User extends BaseUser with DataModelMixin { final HasMany following; @DataRelationship(inverse: 'following') final HasMany followers; + @DataRelationship(inverse: 'user') + final BelongsTo settings; - String get nameOrNpub => name ?? '${npub.substring(0, 10)}...'; + String get nameOrNpub => name ?? npub.substringMax(18); } mixin UserAdapter on NostrAdapter { @@ -116,7 +126,7 @@ mixin UserAdapter on NostrAdapter { )); final data = await deserializeAsync(result, save: true); - return data.models.firstWhere((e) { + return data.models.firstWhereOrNull((e) { return e.id == publicKey; }); } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index bd412f0..a2b3429 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -23,6 +23,13 @@ mixin _$UserAdapter on Adapter { type: 'users', kind: 'HasMany', instance: (_) => (_ as User).followers, + ), + 'settings': RelationshipMeta( + name: 'settings', + inverseName: 'user', + type: 'settings', + kind: 'BelongsTo', + instance: (_) => (_ as User).settings, ) }; @@ -70,4 +77,11 @@ extension UserRelationshipGraphNodeX on RelationshipGraphNode { return meta.clone( parent: this is RelationshipMeta ? this as RelationshipMeta : null); } + + RelationshipGraphNode get settings { + final meta = _$UserAdapter._kUserRelationshipMetas['settings'] + as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } } diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index be3be21..fe5ff54 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -7,10 +7,12 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/local_app.dart'; import 'package:zapstore/navigation/router.dart'; +import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/widgets/app_curation_container.dart'; AppLifecycleListener? _lifecycleListener; SharedPreferences? sharedPreferences; +final amberSigner = AmberSigner(); final appInitializer = FutureProvider((ref) async { sharedPreferences = await SharedPreferences.getInstance(); @@ -42,6 +44,9 @@ final appInitializer = FutureProvider((ref) async { // Preload zapstore's nostr curation set ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch(); + // Initialize signer + await amberSigner.initialize(); + // App-wide listeners // Register app lifecycle listener diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 952cdaa..2e16025 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,14 +9,15 @@ import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/feedback.dart'; import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/app_drawer.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; import 'package:http/http.dart' as http; class SettingsScreen extends HookConsumerWidget { @@ -55,20 +56,35 @@ class SettingsScreen extends HookConsumerWidget { maxLines: 10, ), Gap(20), - if (user == null) LoginContainer(minimal: true), - if (user != null) + if (user == null) SignInButton(minimal: true), + if (user != null && + user.settings.value!.signInMethod != SignInMethod.nip55 || + !amberSigner.isAvailable) + Text( + 'To share feedback, you must be signed in with Amber. You can also message us on nostr!', + style: TextStyle( + color: Colors.red[300], fontWeight: FontWeight.bold)), + if (user != null && + user.settings.value!.signInMethod == SignInMethod.nip55 && + amberSigner.isAvailable) AsyncButtonBuilder( loadingWidget: SizedBox( width: 14, height: 14, child: CircularProgressIndicator()), onPressed: () async { if (controller.text.trim().isNotEmpty) { - final text = - '${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; - final event = AppFeedback(content: text).sign(kI); + final signedDirectMessage = await amberSigner.sign( + BaseDirectMessage( + content: controller.text.trim(), + receiver: kZapstorePubkey.npub), + asUser: user.pubkey); try { - await http.post(Uri.parse('https://relay.zapstore.dev/'), - body: jsonEncode(event.toMap()), + final response = await http.post( + Uri.parse('https://relay.zapstore.dev/'), + body: jsonEncode(signedDirectMessage.toMap()), headers: {'Content-Type': 'application/json'}); + if (response.statusCode >= 400) { + throw Exception(response.body); + } if (context.mounted) { context.showInfo('Thank you', description: 'Message sent successfully'); @@ -77,8 +93,7 @@ class SettingsScreen extends HookConsumerWidget { } catch (e, stack) { if (context.mounted) { context.showError( - title: (e as dynamic).message ?? e.toString(), - description: stack.toString()); + title: e.toString(), description: stack.toString()); } } } diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index b2f30f6..93e91dc 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -44,6 +44,7 @@ extension ContextX on BuildContext { title, style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis, + maxLines: 4, ), showProgressBar: false, closeOnClick: true, @@ -108,12 +109,12 @@ class _ToastDescriptionWidget extends StatelessWidget { } } -BelongsTo belongsTo>(Map map) { - return BelongsTo.fromJson(map); +BelongsTo belongsTo>(Map? map) { + return map != null ? BelongsTo.fromJson(map) : BelongsTo(); } -HasMany hasMany>(Map map) { - return HasMany.fromJson(map); +HasMany hasMany>(Map? map) { + return map != null ? HasMany.fromJson(map) : HasMany(); } final emojiParser = EmojiParser(); @@ -126,7 +127,7 @@ extension StringWidget on String { } String substringMax(int size) { - return substring(0, min(length, size)); + return substring(0, min(length, size)) + (size < length ? '...' : ''); } String removeParenthesis() { @@ -134,6 +135,4 @@ extension StringWidget on String { } } -const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58"; - const kZapstoreAppIdentifier = 'dev.zapstore.app'; diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart new file mode 100644 index 0000000..c598463 --- /dev/null +++ b/lib/utils/signers.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:purplebase/purplebase.dart'; +import 'package:signer_plugin/signer_plugin.dart'; + +class AmberSigner extends Signer { + final _signerPlugin = SignerPlugin(); + bool isAvailable = false; + + @override + Future initialize() async { + final hasExternalSigner = await _signerPlugin + .isExternalSignerInstalled('com.greenart7c3.nostrsigner'); + if (hasExternalSigner) { + await _signerPlugin.setPackageName('com.greenart7c3.nostrsigner'); + isAvailable = true; + } + return this; + } + + @override + Future getPublicKey() async { + final map = await _signerPlugin.getPublicKey(); + print('got $map'); + return map['npub'] ?? map['result']; + } + + @override + Future sign>(T model, {String? asUser}) async { + if (!isAvailable) { + throw Exception("Cannot sign, missing Amber"); + } + + if (model is BaseDirectMessage) { + final signedMessage = await _signerPlugin.nip04Encrypt( + model.content, + "", + (asUser ?? model.pubkey).npub, + (model as BaseDirectMessage).receiver.hexKey); + final encryptedContent = signedMessage['result']; + model = + (model as BaseDirectMessage).copyWith(content: encryptedContent) as T; + } + + final pubkey = asUser ?? model.pubkey; + // Remove all null fields (Amber otherwise crashes) + final map = { + for (final e in model.toMap().entries) + if (e.value != null) e.key: e.value + }; + final signedMessage = + await _signerPlugin.signEvent(jsonEncode(map), "", pubkey); + final signedEvent = jsonDecode(signedMessage['event']); + final fn = BaseEvent.constructorForKind(model.kind)!; + return fn.call(signedEvent); + } +} + +final pkSigner = PrivateKeySigner( + 'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58'); diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart deleted file mode 100644 index 1abddcb..0000000 --- a/lib/widgets/app_drawer.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class LoginContainer extends HookConsumerWidget { - final String labelText; - final bool minimal; - LoginContainer( - {super.key, - this.minimal = false, - this.labelText = 'Input your NIP-05 or npub (no nsec!)'}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model - ?.user - .value; - final controller = useTextEditingController(); - final isTextFieldEmpty = useState(true); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), - if (!minimal) Gap(10), - if (user != null && !minimal) - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - user.nameOrNpub, - style: TextStyle(fontWeight: FontWeight.bold), - ), - Gap(4), - Icon(Icons.verified, color: Colors.lightBlue, size: 18), - ], - ), - // if (user.following.isNotEmpty) - // Text('${user.following.length} contacts'), - ], - ), - ], - ), - if (user == null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(20), - Text(labelText), - TextField( - autocorrect: false, - controller: controller, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - decoration: InputDecoration( - hintText: 'your@nip05 or npub', - suffixIcon: AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), - onPressed: () async { - return ref.users - .findOne(controller.text.trim()) - .then((user) { - ref.settings.findOneLocalById('_')!.user.value = user; - }).catchError((e, stack) { - if (context.mounted) { - context.showError( - title: e.message ?? e.toString(), - description: stack?.toString().substringMax(200)); - } - }); - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), - ), - ); - }, - child: Text('Log in'), - ), - ), - ), - ], - ), - Gap(5), - if (user != null && !minimal) - ElevatedButton( - onPressed: () async { - ref.settings.findOneLocalById('_')!.user.value = null; - controller.clear(); - }, - child: Text('Log out'), - ), - ], - ); - } -} diff --git a/lib/widgets/drawer_container.dart b/lib/widgets/drawer_container.dart index 98a4645..d1b24af 100644 --- a/lib/widgets/drawer_container.dart +++ b/lib/widgets/drawer_container.dart @@ -2,12 +2,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/app_drawer.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; class DrawerContainer extends StatelessWidget { - const DrawerContainer({ - super.key, - }); + const DrawerContainer({super.key}); @override Widget build(BuildContext context) { @@ -17,7 +15,9 @@ class DrawerContainer extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: LoginContainer()), + Expanded( + child: SignInButton(), + ), Consumer( builder: (context, ref, _) { final state = ref.watch(systemInfoNotifierProvider); diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart index e54eb61..4bdb7a4 100644 --- a/lib/widgets/error_container.dart +++ b/lib/widgets/error_container.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/models/feedback.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/utils/system_info.dart'; import 'package:zapstore/utils/theme.dart'; @@ -148,7 +149,7 @@ Future _sendErrorReport( 'info': systemInfo.androidInfo.toString() }; - final event = AppFeedback(content: jsonEncode(map)).sign(kI); + final event = await pkSigner.sign(AppFeedback(content: jsonEncode(map))); await http.post(Uri.parse('https://relay.zapstore.dev/'), body: jsonEncode(event.toMap()), headers: {'Content-Type': 'application/json'}); diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index 4b4692e..b53ca43 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -7,7 +7,7 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/app_drawer.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/wot_container.dart'; @@ -54,9 +54,9 @@ class InstallAlertDialog extends HookConsumerWidget { toNpub: app.signer.value!.npub, ), if (user == null) - LoginContainer( + SignInButton( + label: 'Sign in to view your web of trust', minimal: true, - labelText: 'Log in to view your own web of trust', ), Gap(16), Row( diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart new file mode 100644 index 0000000..05d6848 --- /dev/null +++ b/lib/widgets/sign_in_container.dart @@ -0,0 +1,227 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_data/flutter_data.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class SignInButton extends ConsumerWidget { + final bool minimal; + final String label; + SignInButton({super.key, this.minimal = false, this.label = 'Sign in'}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.settings + .watchOne('_', alsoWatch: (_) => {_.user}) + .model + ?.user + .value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), + if (!minimal) Gap(10), + if (user != null) + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user.nameOrNpub, + // softWrap: true, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold), + ), + Gap(4), + Icon(Icons.verified, color: Colors.lightBlue, size: 18), + ], + ), + // if (user.following.isNotEmpty) + // Text('${user.following.length} contacts'), + ], + ), + ], + ), + Gap(10), + ElevatedButton( + onPressed: () async { + if (user == null) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text( + label, + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SignInContainer(), + ), + ); + } else { + ref.settings.findOneLocalById('_')!.user.value = null; + } + }, + child: Text(user == null ? label : 'Sign out'), + ), + ], + ); + } +} + +class SignInContainer extends HookConsumerWidget { + const SignInContainer({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final isTextFieldEmpty = useState(true); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (amberSigner.isAvailable) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AsyncButtonBuilder( + loadingWidget: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), + ), + ); + }, + onPressed: () async { + if (!amberSigner.isAvailable) { + Navigator.of(context).pop(); + } + final signedInNpub = await amberSigner.getPublicKey(); + if (signedInNpub == null) { + if (context.mounted) { + Navigator.of(context).pop(); + context.showError( + title: "Could not sign in", + description: "Signer did not respond"); + } + return; + } + + var user = await ref.users.findOne(signedInNpub); + if (user == null) { + // TODO: Should be able to sign User() + final newBaseUser = await amberSigner.sign(BaseUser(), + asUser: signedInNpub.hexKey); + // NOTE: Converting to User via serialization + user = User.fromJson(newBaseUser.toMap()) + .init() + .saveLocal(); + } + + final settings = ref.settings.findOneLocalById('_')!; + settings.signInMethod = SignInMethod.nip55; + settings.user.value = user; + settings.saveLocal(); + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text('Sign in with Amber'), + ), + Gap(10), + Text('or'), + Gap(10), + ], + ), + Text('Input an npub or nostr address\n(read-only):'), + TextField( + autocorrect: false, + controller: controller, + onChanged: (value) { + isTextFieldEmpty.value = value.isEmpty; + }, + ), + Gap(10), + AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + onPressed: () async { + try { + final input = controller.text.trim(); + if (input.startsWith('nsec')) { + controller.clear(); + throw Exception('Never give away your nsec!'); + } + final user = await ref.users.findOne(input); + + final settings = ref.settings.findOneLocalById('_')!; + settings.signInMethod = SignInMethod.pubkey; + settings.user.value = user; + settings.saveLocal(); + + if (context.mounted) { + Navigator.pop(context); + } + } on Exception catch (e, stack) { + if (context.mounted) { + context.showError( + title: e.toString(), + description: stack.toString().substringMax(200)); + } + } + }, + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), + ), + ); + }, + child: Text('Sign in with public key'), + ) + ], + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 94c4e75..8e48913 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,10 +130,10 @@ packages: dependency: transitive description: name: bip340 - sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" + sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.0" boolean_selector: dependency: transitive description: @@ -526,6 +526,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" hooks_riverpod: dependency: "direct main" description: @@ -622,14 +630,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + isar: + dependency: transitive + description: + name: isar + sha256: ebf74d87c400bd9f7da14acb31932b50c2407edbbd40930da3a6c2a8143f85a8 + url: "https://pub.dev" + source: hosted + version: "4.0.0-dev.14" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -678,6 +694,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: @@ -742,6 +766,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + ndk: + dependency: transitive + description: + name: ndk + sha256: "5a3b1c2f7c915be55b63a33c44c1bd71ade465bf0661e45f2ba9eb4ac5faf679" + url: "https://pub.dev" + source: hosted + version: "0.2.0-dev005" octo_image: dependency: transitive description: @@ -938,8 +970,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a" - resolved-ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a" + ref: "807305296f93518435c8eb8b4f7cf66690b1d5e6" + resolved-ref: "807305296f93518435c8eb8b4f7cf66690b1d5e6" url: "https://github.com/purplebase/purplebase" source: git version: "0.2.0" @@ -1055,6 +1087,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + signer_plugin: + dependency: "direct main" + description: + path: "." + ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + url: "https://github.com/chebizarro/flutter-signer-plugin" + source: git + version: "0.0.1" skeletonizer: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 50deee6..1b73928 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: intl: ^0.18.1 device_info_plus: ^10.1.0 flutter_layout_grid: ^2.0.6 + signer_plugin: ^1.0.0 skeletonizer: ^1.1.2+1 flutter_phoenix: ^1.1.1 percent_indicator: ^4.2.3 @@ -57,11 +58,15 @@ dependency_overrides: # path: ../purplebase git: url: https://github.com/purplebase/purplebase - ref: 7f0eefd1fd1bcbc947123dc377183dbae58ea97a + ref: 807305296f93518435c8eb8b4f7cf66690b1d5e6 flutter_data: git: url: https://github.com/zapstore/flutter_data ref: d7376cf1eac8e9c563d4c6eed8e567adc877415c + signer_plugin: + git: + url: https://github.com/chebizarro/flutter-signer-plugin + ref: baa5b2fcf7ba8db606a76794582348a1403438b0 dev_dependencies: flutter_test: From 52e7b291ed1a8b0eca4c27f4471dc41b727e54bc Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 19 Dec 2024 20:48:21 -0300 Subject: [PATCH 11/51] Upgrade purplebase --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8e48913..4061324 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -970,8 +970,8 @@ packages: dependency: "direct main" description: path: "." - ref: "807305296f93518435c8eb8b4f7cf66690b1d5e6" - resolved-ref: "807305296f93518435c8eb8b4f7cf66690b1d5e6" + ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc + resolved-ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc url: "https://github.com/purplebase/purplebase" source: git version: "0.2.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1b73928..0a43838 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,7 +58,7 @@ dependency_overrides: # path: ../purplebase git: url: https://github.com/purplebase/purplebase - ref: 807305296f93518435c8eb8b4f7cf66690b1d5e6 + ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc flutter_data: git: url: https://github.com/zapstore/flutter_data From 43a8f54a9c24d5f15b27985a4ed128ddff53ec36 Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 01:34:11 +0100 Subject: [PATCH 12/51] merge --- lib/screens/settings_screen.dart | 15 ++++++++++----- pubspec.lock | 13 +++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8a5d6a3..52ad4fa 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,7 +10,6 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.data.dart'; @@ -20,7 +19,7 @@ import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nwc.dart'; import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/app_drawer.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; class SettingsScreen extends HookConsumerWidget { const SettingsScreen({super.key}); @@ -78,9 +77,15 @@ class SettingsScreen extends HookConsumerWidget { width: 14, height: 14, child: CircularProgressIndicator()), onPressed: () async { if (feedbackController.text.trim().isNotEmpty) { - final text = - '${feedbackController.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; - final event = AppFeedback(content: text).sign(kI); + final signedDirectMessage = await amberSigner.sign( + BaseDirectMessage( + content: feedbackController.text.trim(), + receiver: kZapstorePubkey.npub), + asUser: user.pubkey); + + // final text = + // '${feedbackController.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; + // final event = AppFeedback(content: text).sign(kI); try { final response = await http.post( Uri.parse('https://relay.zapstore.dev/'), diff --git a/pubspec.lock b/pubspec.lock index 187c856..2d68db8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1035,8 +1035,8 @@ packages: dependency: "direct main" description: path: "." - ref: upgrade-bip340 - resolved-ref: "0fdc08652bfcc56c579cf2093f53b1a1aa03bf79" + ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc + resolved-ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc url: "https://github.com/purplebase/purplebase" source: git version: "0.2.0" @@ -1152,6 +1152,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + signer_plugin: + dependency: "direct main" + description: + path: "." + ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + url: "https://github.com/chebizarro/flutter-signer-plugin" + source: git + version: "0.0.1" skeletonizer: dependency: "direct main" description: From 96e96de5bd49e844c53fb7218e721b230725dab8 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 19 Dec 2024 21:38:10 -0300 Subject: [PATCH 13/51] Keep unsigned internal user --- lib/widgets/sign_in_container.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 05d6848..382f458 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -136,15 +136,15 @@ class SignInContainer extends HookConsumerWidget { } var user = await ref.users.findOne(signedInNpub); - if (user == null) { - // TODO: Should be able to sign User() - final newBaseUser = await amberSigner.sign(BaseUser(), - asUser: signedInNpub.hexKey); - // NOTE: Converting to User via serialization - user = User.fromJson(newBaseUser.toMap()) - .init() - .saveLocal(); - } + + user ??= User.fromJson({ + 'id': signedInNpub.hexKey, + 'pubkey': signedInNpub.hexKey, + 'created_at': + DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'content': '{}', + 'tags': [], + }).init().saveLocal(); final settings = ref.settings.findOneLocalById('_')!; settings.signInMethod = SignInMethod.nip55; From c8f11c296fc6fa89ed35bd111db30e5111b0db65 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 00:32:23 +0100 Subject: [PATCH 14/51] PB master --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 2d68db8..7ca5acb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -827,7 +827,7 @@ packages: description: path: "packages/ndk" ref: lnurl-zaps - resolved-ref: b68c1f1ef0d790dbecc98792f91816527dbde5f8 + resolved-ref: e643d3b87d6764afdf6bccb76704aec12cba07a1 url: "https://github.com/relaystr/ndk" source: git version: "0.2.0-dev005" @@ -1035,7 +1035,7 @@ packages: dependency: "direct main" description: path: "." - ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc + ref: master resolved-ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc url: "https://github.com/purplebase/purplebase" source: git diff --git a/pubspec.yaml b/pubspec.yaml index 56bdcc1..18bb0d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,7 +66,7 @@ dependency_overrides: # path: ../purplebase git: url: https://github.com/purplebase/purplebase - ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc + ref: master flutter_data: git: url: https://github.com/zapstore/flutter_data From ed08d8c76a72e9c2e61809fbbd39d6d4d10825b8 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 01:22:54 +0100 Subject: [PATCH 15/51] add proguard rules to allow secure storage --- android/app/build.gradle | 3 +++ android/app/proguard-rules.pro | 6 ++++++ lib/utils/zap.dart | 3 +++ lib/widgets/zap_button.dart | 19 +++++++++++-------- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 android/app/proguard-rules.pro diff --git a/android/app/build.gradle b/android/app/build.gradle index 259e595..cccb10a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -71,6 +71,9 @@ android { release { signingConfig signingConfigs.debug signingConfig signingConfigs.release + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..a2ef6bc --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,6 @@ +-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue +-dontwarn com.google.errorprone.annotations.CheckReturnValue +-dontwarn com.google.errorprone.annotations.Immutable +-dontwarn com.google.errorprone.annotations.RestrictedApi +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 4e47c42..ff5d05c 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -33,6 +33,7 @@ class ZapNotifier extends StateNotifier { // TODO how show error? // context.showError( // title: "could not get invoice from ${lnurl}"); + state = null; return; } PayInvoiceResponse response = @@ -42,6 +43,8 @@ class ZapNotifier extends StateNotifier { Future.delayed(Duration(seconds: 3)).then((_) { state = null; }); + } else { + state = null; } } } diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index fcd5068..189a74c 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -121,14 +121,17 @@ class ZapButton extends HookConsumerWidget { }, ) ?? 0; - final lnurl = developer.lud16!; - await ref.read(zapProvider.notifier).zap( - nwcConnection: nwcConnection, - user: user, - lnurl: lnurl, - eventId: app.latestMetadata!=null? app.latestMetadata!.id.toString(): app.latestRelease!.id.toString(), - amount: amount, - pubKey: developer.pubkey); + if (amount > 0) { + final lnurl = developer.lud16!; + await ref.read(zapProvider.notifier).zap( + nwcConnection: nwcConnection, + user: user, + lnurl: lnurl, + eventId: app.latestMetadata != null ? app.latestMetadata!.id + .toString() : app.latestRelease!.id.toString(), + amount: amount, + pubKey: developer.pubkey); + } }, child: Text(text)) : Container(); From cef58d6915b8c21252933306aa314e0fbadbe91f Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 01:29:01 +0100 Subject: [PATCH 16/51] rollback to flutter.ndkVersion --- android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index cccb10a..eb6b8a9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) { android { namespace "dev.zapstore.app" compileSdk flutter.compileSdkVersion - ndkVersion "25.1.8937393" + ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_21 From 9561b42387a2395e96cbfe5ba158bf223180616c Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:24:36 +0100 Subject: [PATCH 17/51] lints and replace AmberEvenSigner with Nip55EventSigner --- android/app/build.gradle | 6 +----- lib/screens/settings_screen.dart | 2 +- lib/utils/nwc.dart | 8 ++------ lib/utils/zap.dart | 19 +++++-------------- lib/widgets/zap_button.dart | 2 +- pubspec.lock | 9 +++++---- pubspec.yaml | 5 +++++ 7 files changed, 20 insertions(+), 31 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index eb6b8a9..cd44325 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,10 +38,6 @@ android { targetCompatibility JavaVersion.VERSION_21 } - kotlin { - jvmToolchain(21) - } - kotlinOptions { jvmTarget = '21' } @@ -52,7 +48,7 @@ android { defaultConfig { applicationId "dev.zapstore.app" - minSdkVersion 23 + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 52ad4fa..b796031 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -127,7 +127,7 @@ class SettingsScreen extends HookConsumerWidget { Gap(20), Text( "Nostr Wallet Connect ${nwcConnection != null ? 'connected' : 'URI'}"), - if (nwcConnection != null && nwcConnection != '') + if (nwcConnection != null) ElevatedButton( onPressed: () async { await ref diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index c598df5..93271d6 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -40,16 +40,12 @@ class NwcConnectionNotifier extends StateNotifier { } Future ensureConnected(String? nwcSecret) async { - if (nwcSecret != null && nwcSecret != '' && state==null) { + if (nwcSecret != null && nwcSecret.isNotEmpty && state==null) { NwcConnection connection = await ndkForNwc.nwc.connect( nwcSecret, doGetInfoMethod: false, onError: (error) { - // TODO - // context.showError( - // title: 'Unable to connect', - // description: error); + // TODO: how to handle errors? }); if ( - // connection.info != null && connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { setConnection(connection); } diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index ff5d05c..3d85578 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -1,8 +1,7 @@ -import 'package:amberflutter/amberflutter.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/ndk.dart'; -import 'package:ndk_amber/ndk_amber.dart'; +import 'package:ndk_amber/data_layer/repositories/signers/nip55_event_signer.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/utils/nwc.dart'; @@ -20,25 +19,22 @@ class ZapNotifier extends StateNotifier { required String pubKey}) async { state = "zapping"; String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); - final signer = AmberEventSigner( - publicKey: user.pubkey, amberFlutterDS: AmberFlutterDS(Amberflutter())); + final signer = Nip55EventSigner(publicKey: user.pubkey); String? invoice = await Lnurl.getInvoiceCode( lud16Link: lud16Link!, sats: amount, recipientPubkey: pubKey, eventId: eventId, signer: signer, - relays: ndkForSocial.config.bootstrapRelays); + relays: kSocialRelays.where((e) => e != 'ndk')); if (invoice == null) { - // TODO how show error? - // context.showError( - // title: "could not get invoice from ${lnurl}"); + // TODO: how show error? state = null; return; } PayInvoiceResponse response = await ndkForNwc.nwc.payInvoice(nwcConnection, invoice: invoice); - if (response.preimage != '') { + if (response.preimage.isNotEmpty) { state = "zapped"; Future.delayed(Duration(seconds: 3)).then((_) { state = null; @@ -49,11 +45,6 @@ class ZapNotifier extends StateNotifier { } } -final ndkForSocial = Ndk(NdkConfig( - eventVerifier: Bip340EventVerifier(), - cache: MemCacheManager(), - bootstrapRelays: kSocialRelays.toList())); - final zapProvider = StateNotifierProvider((ref) { return ZapNotifier(); }); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 189a74c..df4a856 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -29,7 +29,7 @@ class ZapButton extends HookConsumerWidget { .user .value; - final TextEditingController amountController = TextEditingController(); + final amountController = TextEditingController(); var text = 'Zap the dev ⚡'; switch (zapStatus) { diff --git a/pubspec.lock b/pubspec.lock index 7ca5acb..a601381 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -834,10 +834,11 @@ packages: ndk_amber: dependency: "direct main" description: - name: ndk_amber - sha256: "2f364d54b71df8883219b699bca3c9b0e878341c8f25024d511f0585103b5278" - url: "https://pub.dev" - source: hosted + path: "packages/amber" + ref: lnurl-zaps + resolved-ref: e3be4c7e880d145ccfca3356aa1aa3a0c399d807 + url: "https://github.com/relaystr/ndk" + source: git version: "0.2.0-dev002" octo_image: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 18bb0d4..caa812f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,11 @@ dependency_overrides: url: https://github.com/relaystr/ndk path: packages/ndk ref: lnurl-zaps + ndk_amber: + git: + url: https://github.com/relaystr/ndk + path: packages/amber + ref: lnurl-zaps purplebase: # path: ../purplebase git: From 15d4a1d2d29a2fb2ff4e7db86f52861ee2636eda Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:49:16 +0100 Subject: [PATCH 18/51] fix --- lib/screens/settings_screen.dart | 4 ---- lib/utils/nwc.dart | 8 ++------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b796031..00f30fb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -82,10 +82,6 @@ class SettingsScreen extends HookConsumerWidget { content: feedbackController.text.trim(), receiver: kZapstorePubkey.npub), asUser: user.pubkey); - - // final text = - // '${feedbackController.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; - // final event = AppFeedback(content: text).sign(kI); try { final response = await http.post( Uri.parse('https://relay.zapstore.dev/'), diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 93271d6..db13faf 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -35,10 +35,6 @@ class NwcConnectionNotifier extends StateNotifier { ensureConnected(ref.watch(nwcSecretProvider)); } - setConnection(NwcConnection? c) { - state = c; - } - Future ensureConnected(String? nwcSecret) async { if (nwcSecret != null && nwcSecret.isNotEmpty && state==null) { NwcConnection connection = await ndkForNwc.nwc.connect( @@ -47,10 +43,10 @@ class NwcConnectionNotifier extends StateNotifier { }); if ( connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - setConnection(connection); + state = connection; } } else { - setConnection(null); + state = null; } } } From e765e6ad7e697e8554719de7c5fbea8312da3fa7 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:55:56 +0100 Subject: [PATCH 19/51] fix --- lib/widgets/zap_button.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index df4a856..2dae41f 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -113,7 +113,7 @@ class ZapButton extends HookConsumerWidget { child: Text('Zap'), onPressed: () { Navigator.of(context).pop( - int.tryParse(amountController.text) ?? ''); + int.tryParse(amountController.text) ?? 0); }, ), ], From 063b6203cd0be8fb80df172ff399b28c76e463e2 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 17:44:50 +0100 Subject: [PATCH 20/51] use AsyncValue --- lib/screens/settings_screen.dart | 2 +- lib/utils/nwc.dart | 15 ++++++---- lib/utils/zap.dart | 48 ++++++++++++++++++++------------ lib/widgets/zap_button.dart | 12 ++++---- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 00f30fb..12f5c6d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -131,7 +131,7 @@ class SettingsScreen extends HookConsumerWidget { .updateNwcSecret(null); await ref .read(nwcConnectionProvider.notifier) - .setConnection(null); + .disconnect(); }, style: ElevatedButton.styleFrom( disabledBackgroundColor: Colors.transparent, diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index db13faf..c3a2742 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -29,21 +29,26 @@ class NwcSecretNotifier extends StateNotifier { } } -class NwcConnectionNotifier extends StateNotifier { - Ref ref; +class NwcConnectionNotifier extends StateNotifier?> { + Ref?> ref; NwcConnectionNotifier(this.ref) : super(null) { ensureConnected(ref.watch(nwcSecretProvider)); } + Future disconnect() async { + state = null; + } + Future ensureConnected(String? nwcSecret) async { if (nwcSecret != null && nwcSecret.isNotEmpty && state==null) { + state = AsyncValue.loading(); NwcConnection connection = await ndkForNwc.nwc.connect( nwcSecret, doGetInfoMethod: false, onError: (error) { - // TODO: how to handle errors? + state = AsyncValue.error(error??"couldn't connect", StackTrace.current); }); if ( connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - state = connection; + state = AsyncValue.data(connection); } } else { state = null; @@ -57,7 +62,7 @@ StateNotifierProvider((ref) { }); final nwcConnectionProvider = -StateNotifierProvider((ref) { +StateNotifierProvider?>((ref) { return NwcConnectionNotifier(ref); }); diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 3d85578..66e52d3 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -7,17 +7,22 @@ import 'package:zapstore/utils/nwc.dart'; import '../models/user.dart'; -class ZapNotifier extends StateNotifier { - ZapNotifier() : super(null); +class ZapNotifier extends StateNotifier?> { + Ref?> ref; + + ZapNotifier(this.ref) : super(null); Future zap( - {required NwcConnection nwcConnection, - required User user, + {required User user, String? eventId, required String lnurl, required int amount, required String pubKey}) async { - state = "zapping"; + final nwcConnection = ref.read(nwcConnectionProvider.notifier).state; + if (nwcConnection==null || !nwcConnection.hasValue) { + return; + } + state = AsyncValue.loading(); String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); final signer = Nip55EventSigner(publicKey: user.pubkey); String? invoice = await Lnurl.getInvoiceCode( @@ -28,23 +33,30 @@ class ZapNotifier extends StateNotifier { signer: signer, relays: kSocialRelays.where((e) => e != 'ndk')); if (invoice == null) { - // TODO: how show error? - state = null; + state = AsyncValue.error("could not generate invoice for $lnurl", StackTrace.current); return; } - PayInvoiceResponse response = - await ndkForNwc.nwc.payInvoice(nwcConnection, invoice: invoice); - if (response.preimage.isNotEmpty) { - state = "zapped"; - Future.delayed(Duration(seconds: 3)).then((_) { - state = null; - }); - } else { - state = null; + try { + PayInvoiceResponse response = + await ndkForNwc.nwc.payInvoice(nwcConnection.value!, invoice: invoice); + if (response.preimage.isNotEmpty && response.errorCode != null) { + state = AsyncValue.data(response.preimage); + } else { + state = AsyncValue.error( + response.errorMessage ?? "couldn't pay, unknown error", + StackTrace.current); + } + } catch (e) { + state = AsyncValue.error( + e.toString(), + StackTrace.current); } + Future.delayed(Duration(seconds: 5)).then((_) { + state = null; + }); } } -final zapProvider = StateNotifierProvider((ref) { - return ZapNotifier(); +final zapProvider = StateNotifierProvider?>((ref) { + return ZapNotifier(ref.read(nwcConnectionProvider.notifier).ref); }); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 2dae41f..5c69930 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -32,11 +32,13 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); var text = 'Zap the dev ⚡'; - switch (zapStatus) { - case "zapping": + if (zapStatus!=null) { + if (zapStatus.isLoading) { text = 'Zapping... ⚡⚡⚡'; - case "zapped": - text = 'Zapped!'; + } else if (zapStatus.error != null) { + text = "error zapping ${zapStatus.error.toString()}"; + // TODO: how to do an error toast here? context.showError complaints about being used during widget build + } } return app.developer.value != null @@ -123,8 +125,8 @@ class ZapButton extends HookConsumerWidget { 0; if (amount > 0) { final lnurl = developer.lud16!; + // final lnurl = "fmar@getalby.com"; await ref.read(zapProvider.notifier).zap( - nwcConnection: nwcConnection, user: user, lnurl: lnurl, eventId: app.latestMetadata != null ? app.latestMetadata!.id From 948f55b585da18bb2b1fd33e7d4961d0a6e9472e Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 17:46:26 +0100 Subject: [PATCH 21/51] clean testing lnurl --- lib/widgets/zap_button.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 5c69930..8c09ad3 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -125,7 +125,6 @@ class ZapButton extends HookConsumerWidget { 0; if (amount > 0) { final lnurl = developer.lud16!; - // final lnurl = "fmar@getalby.com"; await ref.read(zapProvider.notifier).zap( user: user, lnurl: lnurl, From b641c3fc000cc6c33b9735c66c98c40c792e0bb2 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 19:21:12 +0100 Subject: [PATCH 22/51] socialRelays.ndk!=null ? socialRelays.ndk!.config.bootstrapRelays: kSocialRelays.where((r) => r!='ndk') --- lib/utils/zap.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 66e52d3..0f0a510 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -2,6 +2,7 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_amber/data_layer/repositories/signers/nip55_event_signer.dart'; +import 'package:purplebase/purplebase.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/utils/nwc.dart'; @@ -25,13 +26,15 @@ class ZapNotifier extends StateNotifier?> { state = AsyncValue.loading(); String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); final signer = Nip55EventSigner(publicKey: user.pubkey); + final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); String? invoice = await Lnurl.getInvoiceCode( lud16Link: lud16Link!, sats: amount, recipientPubkey: pubKey, eventId: eventId, signer: signer, - relays: kSocialRelays.where((e) => e != 'ndk')); + relays: socialRelays.ndk!=null ? socialRelays.ndk!.config.bootstrapRelays: kSocialRelays.where((r) => r!='ndk') + ); if (invoice == null) { state = AsyncValue.error("could not generate invoice for $lnurl", StackTrace.current); return; From eaf14ba08beec1bacce272feb0c116f154dff604 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 19:59:29 +0100 Subject: [PATCH 23/51] revert changes --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 644cd98..b734807 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": "/usr/bin/flutter", + "dart.flutterSdkPath": "~/fvm/default", // Remove .fvm files from search "search.exclude": { "**/.fvm": true From 2aa289a3f843056aad5fff0ddd3665ef7cea6b72 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 20:02:09 +0100 Subject: [PATCH 24/51] revert changes --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index caa812f..c90de96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,7 +71,7 @@ dependency_overrides: # path: ../purplebase git: url: https://github.com/purplebase/purplebase - ref: master + ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc flutter_data: git: url: https://github.com/zapstore/flutter_data From a4cf68bc67cc6dcaf77ad53790de494bf309a89d Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 15:27:51 +0100 Subject: [PATCH 25/51] adapt to ndk new zaps and zap receipt --- lib/utils/nwc.dart | 27 ++++++++++++++--------- lib/utils/zap.dart | 54 +++++++++++++++++++++++++--------------------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index c3a2742..8ce9885 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -31,6 +31,7 @@ class NwcSecretNotifier extends StateNotifier { class NwcConnectionNotifier extends StateNotifier?> { Ref?> ref; + NwcConnectionNotifier(this.ref) : super(null) { ensureConnected(ref.watch(nwcSecretProvider)); } @@ -40,14 +41,14 @@ class NwcConnectionNotifier extends StateNotifier?> { } Future ensureConnected(String? nwcSecret) async { - if (nwcSecret != null && nwcSecret.isNotEmpty && state==null) { + if (nwcSecret != null && nwcSecret.isNotEmpty && state == null) { state = AsyncValue.loading(); - NwcConnection connection = await ndkForNwc.nwc.connect( - nwcSecret, doGetInfoMethod: false, onError: (error) { - state = AsyncValue.error(error??"couldn't connect", StackTrace.current); + NwcConnection connection = await ndkForNwc.nwc + .connect(nwcSecret, doGetInfoMethod: false, onError: (error) { + state = + AsyncValue.error(error ?? "couldn't connect", StackTrace.current); }); - if ( - connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { state = AsyncValue.data(connection); } } else { @@ -57,14 +58,20 @@ class NwcConnectionNotifier extends StateNotifier?> { } final nwcSecretProvider = -StateNotifierProvider((ref) { + StateNotifierProvider((ref) { return NwcSecretNotifier(); }); final nwcConnectionProvider = -StateNotifierProvider?>((ref) { + StateNotifierProvider?>( + (ref) { return NwcConnectionNotifier(ref); }); -final ndkForNwc = Ndk.emptyBootstrapRelaysConfig(); - +final ndkForNwc = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: Bip340EventVerifier(), + bootstrapRelays: [], + logLevel: LogLevels().info), +); diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 0f0a510..6ba87fe 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -1,5 +1,6 @@ import 'package:flutter_data/flutter_data.dart'; -import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_amber/data_layer/repositories/signers/nip55_event_signer.dart'; import 'package:purplebase/purplebase.dart'; @@ -19,40 +20,42 @@ class ZapNotifier extends StateNotifier?> { required String lnurl, required int amount, required String pubKey}) async { - final nwcConnection = ref.read(nwcConnectionProvider.notifier).state; - if (nwcConnection==null || !nwcConnection.hasValue) { + final nwcConnection = ref.read(nwcConnectionProvider.notifier).state; + if (nwcConnection == null || !nwcConnection.hasValue) { return; } state = AsyncValue.loading(); - String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); final signer = Nip55EventSigner(publicKey: user.pubkey); final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); - String? invoice = await Lnurl.getInvoiceCode( - lud16Link: lud16Link!, - sats: amount, - recipientPubkey: pubKey, - eventId: eventId, - signer: signer, - relays: socialRelays.ndk!=null ? socialRelays.ndk!.config.bootstrapRelays: kSocialRelays.where((r) => r!='ndk') - ); - if (invoice == null) { - state = AsyncValue.error("could not generate invoice for $lnurl", StackTrace.current); - return; - } + final relays = socialRelays.ndk != null + ? socialRelays.ndk!.config.bootstrapRelays + : kSocialRelays.where((r) => r != 'ndk'); try { - PayInvoiceResponse response = - await ndkForNwc.nwc.payInvoice(nwcConnection.value!, invoice: invoice); - if (response.preimage.isNotEmpty && response.errorCode != null) { - state = AsyncValue.data(response.preimage); + ZapResponse zapResponse = await ndkForNwc.zaps.zap( + nwcConnection: nwcConnection.value!, + lnurl: lnurl, + amountSats: amount, + fetchZapReceipt: true, + signer: signer, + relays: relays, + pubKey: pubKey, + eventId: eventId, + ); + ZapReceipt? zapReceipt = await zapResponse.zapReceipt; + if (zapReceipt!=null) { + print("ZAP RECEIPT: $zapReceipt"); + } + if (zapResponse.payInvoiceResponse != null && + zapResponse.payInvoiceResponse!.preimage.isNotEmpty) { + state = AsyncValue.data(zapResponse.payInvoiceResponse!.preimage); } else { state = AsyncValue.error( - response.errorMessage ?? "couldn't pay, unknown error", + zapResponse.payInvoiceResponse?.errorMessage ?? + "couldn't pay, unknown error", StackTrace.current); } } catch (e) { - state = AsyncValue.error( - e.toString(), - StackTrace.current); + state = AsyncValue.error(e.toString(), StackTrace.current); } Future.delayed(Duration(seconds: 5)).then((_) { state = null; @@ -60,6 +63,7 @@ class ZapNotifier extends StateNotifier?> { } } -final zapProvider = StateNotifierProvider?>((ref) { +final zapProvider = + StateNotifierProvider?>((ref) { return ZapNotifier(ref.read(nwcConnectionProvider.notifier).ref); }); From f032ef1dd8ef64b7140b54395811d3ffe1ba742b Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 21:36:44 +0100 Subject: [PATCH 26/51] zap receipts --- lib/screens/app_detail_screen.dart | 3 + lib/utils/zap.dart | 51 ++++++++++++-- lib/widgets/zap_button.dart | 2 + lib/widgets/zaps.dart | 105 +++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 lib/widgets/zaps.dart diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index accfb08..41356b8 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -20,6 +20,7 @@ import 'package:zapstore/widgets/spinning_logo.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; import 'package:zapstore/widgets/wot_container.dart'; import 'package:zapstore/widgets/zap_button.dart'; +import 'package:zapstore/widgets/zaps.dart'; class AppDetailScreen extends HookConsumerWidget { final App model; @@ -157,6 +158,8 @@ class AppDetailScreen extends HookConsumerWidget { child: SignerAndDeveloperRow(app: app), ), Gap(20), + Zaps(app: app), + Gap(20), ZapButton(app: app), Gap(20), Container( diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 6ba87fe..5759b1c 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -4,12 +4,44 @@ import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_amber/data_layer/repositories/signers/nip55_event_signer.dart'; import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/utils/nwc.dart'; import '../models/user.dart'; -class ZapNotifier extends StateNotifier?> { +class ZapReceiptsNotifier extends StateNotifier>?> { + Ref?> ref; + + ZapReceiptsNotifier(this.ref) : super(null); + + Future fetchZaps(FileMetadata fileMetadata) async { + state = null; + final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); + if (socialRelays.ndk != null) { + state = AsyncValue.loading(); + + NdkResponse receiptsResponse = socialRelays.ndk!.zaps + .subscribeToZapReceipts( + pubKey: fileMetadata.pubkey, eventId: fileMetadata.id!.toString()); + receiptsResponse.stream.listen((event) { + addZapReceipt(ZapReceipt.fromEvent(event)); + }); + } + } + + void addZapReceipt(ZapReceipt receipt) { + if (state==null || state!.value==null) { + state = AsyncValue.data([receipt]); + } else { + List list = state!.value!; + list.add(receipt); + state = AsyncValue.data(list); + } + } +} + +class ZapNotifier extends StateNotifier?> { Ref?> ref; ZapNotifier(this.ref) : super(null); @@ -41,13 +73,13 @@ class ZapNotifier extends StateNotifier?> { pubKey: pubKey, eventId: eventId, ); - ZapReceipt? zapReceipt = await zapResponse.zapReceipt; - if (zapReceipt!=null) { - print("ZAP RECEIPT: $zapReceipt"); - } if (zapResponse.payInvoiceResponse != null && zapResponse.payInvoiceResponse!.preimage.isNotEmpty) { - state = AsyncValue.data(zapResponse.payInvoiceResponse!.preimage); + state = AsyncValue.data(zapResponse); + ZapReceipt? zapReceipt = await zapResponse.zapReceipt; + if (zapReceipt != null) { + ref.read(zapReceiptsNotifier.notifier).addZapReceipt(zapReceipt); + } } else { state = AsyncValue.error( zapResponse.payInvoiceResponse?.errorMessage ?? @@ -64,6 +96,11 @@ class ZapNotifier extends StateNotifier?> { } final zapProvider = - StateNotifierProvider?>((ref) { + StateNotifierProvider?>((ref) { return ZapNotifier(ref.read(nwcConnectionProvider.notifier).ref); }); + +final zapReceiptsNotifier = +StateNotifierProvider>?>((ref) { + return ZapReceiptsNotifier(ref.read(nwcConnectionProvider.notifier).ref); +}); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 8c09ad3..4099caa 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -38,6 +38,8 @@ class ZapButton extends HookConsumerWidget { } else if (zapStatus.error != null) { text = "error zapping ${zapStatus.error.toString()}"; // TODO: how to do an error toast here? context.showError complaints about being used during widget build + } else { + text = "Zapped!"; } } diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart new file mode 100644 index 0000000..8cb6fbd --- /dev/null +++ b/lib/widgets/zaps.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/app.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/utils/zap.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class Zaps extends HookConsumerWidget { + Zaps({ + super.key, + required this.app, + }); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final zapReceipts = ref.watch(zapReceiptsNotifier); + + if (app.developer.value != null && + app.developer.value!.lud16 != null && + app.latestMetadata != null) { + if (zapReceipts == null) { + Future(() { + ref.read(zapReceiptsNotifier.notifier).fetchZaps(app.latestMetadata!); + }); + } else { + List? receipts = zapReceipts.value; + if (receipts != null) { + receipts + .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); + + int eventSum = 0; + for (var receipt in receipts) { + eventSum += receipt.amountSats ?? 0; + } + Set senderIds = + receipts.map((receipt) => receipt.sender).nonNulls.toSet(); + + final senders = ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); + + Widget zapperAvatars = switch (senders) { + AsyncData>(value: final users) => Builder( + builder: (context) { + return RichText( + text: TextSpan(children: [ + for (final user in users) + TextSpan( + style: TextStyle(height: 1.6), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + RoundedImage(url: user.avatarUrl, size: 20), + ], + ), + ), + ], + ) + ])); + }, + ), + AsyncError(:final error) => + Center(child: Text('Error loading zapper profiles: $error')), + // Loading state + _ => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + ], + ), + ), + ) + }; + return Row(children: [ + Text("⚡ $eventSum sats (${receipts.length} zaps)"), + Gap(5), + zapperAvatars + ]); + } + } + } + return Container(); + } + + +} + +final zappersProvider = FutureProvider.autoDispose + .family, ({Iterable zapperIds})>((ref, arg) { + List list = ref.users.findManyLocalByIds(arg.zapperIds); + return list; + // return await ref.users.findAll(params: {'authors': arg.zapperIds, 'ignoreReturn': true}); +}); From 0c9bd000378a48a03081665e49c53a19d11036b5 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 23:37:31 +0100 Subject: [PATCH 27/51] adapt to changes in ndk.zaps --- lib/utils/zap.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 5759b1c..6b4374b 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -21,11 +21,11 @@ class ZapReceiptsNotifier extends StateNotifier>?> { if (socialRelays.ndk != null) { state = AsyncValue.loading(); - NdkResponse receiptsResponse = socialRelays.ndk!.zaps - .subscribeToZapReceipts( + Stream receiptsResponse = socialRelays.ndk!.zaps + .fetchZappedReceipts( pubKey: fileMetadata.pubkey, eventId: fileMetadata.id!.toString()); - receiptsResponse.stream.listen((event) { - addZapReceipt(ZapReceipt.fromEvent(event)); + receiptsResponse.listen((receipt) { + addZapReceipt(receipt); }); } } From 1f1d88e17d93d64441f1144eae95e96652b3ffb9 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 23:39:40 +0100 Subject: [PATCH 28/51] TODO --- lib/widgets/zaps.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart index 8cb6fbd..0111342 100644 --- a/lib/widgets/zaps.dart +++ b/lib/widgets/zaps.dart @@ -99,6 +99,7 @@ class Zaps extends HookConsumerWidget { final zappersProvider = FutureProvider.autoDispose .family, ({Iterable zapperIds})>((ref, arg) { + // TODO it should load from either local or remote if not cached locally still List list = ref.users.findManyLocalByIds(arg.zapperIds); return list; // return await ref.users.findAll(params: {'authors': arg.zapperIds, 'ignoreReturn': true}); From 7dbe548e03bc7371892714941862edb4ae1b1d04 Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:01:56 +0100 Subject: [PATCH 29/51] add nip55_event_signer --- lib/utils/nip55_event_signer.dart | 59 +++++++++++++++++++++++++++++++ lib/utils/zap.dart | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 lib/utils/nip55_event_signer.dart diff --git a/lib/utils/nip55_event_signer.dart b/lib/utils/nip55_event_signer.dart new file mode 100644 index 0000000..140e586 --- /dev/null +++ b/lib/utils/nip55_event_signer.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart'; +import 'package:signer_plugin/signer_plugin.dart'; + +class Nip55EventSigner implements EventSigner { + final _signerPlugin = SignerPlugin(); + bool isAvailable = false; + + final String publicKey; + + /// get a amber event signer + Nip55EventSigner({ + required this.publicKey, + }); + + @override + Future sign(Nip01Event event) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + final signedMessage = + await _signerPlugin.signEvent(jsonEncode(event.toJson()), "", npub); + final signedEvent = jsonDecode(signedMessage['event']); + + event.sig = signedEvent['sig']; + } + + @override + String getPublicKey() { + return publicKey; + } + + @override + Future decrypt(String msg, String destPubKey, {String? id}) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + Map map = + await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey); + return map['signature']; + } + + @override + Future encrypt(String msg, String destPubKey, {String? id}) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + Map map = + await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey); + return map['signature']; + } + + @override + bool canSign() { + return publicKey.isNotEmpty; + } +} diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index 6b4374b..ac752a1 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -2,10 +2,10 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; import 'package:ndk/ndk.dart'; -import 'package:ndk_amber/data_layer/repositories/signers/nip55_event_signer.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/utils/nip55_event_signer.dart'; import 'package:zapstore/utils/nwc.dart'; import '../models/user.dart'; From 4c99eef2c2d6c60ba4d76cd290ce729e98a3a8c0 Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:28:37 +0100 Subject: [PATCH 30/51] remove ndk_amber dep --- pubspec.lock | 13 ++++++------- pubspec.yaml | 5 ----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index a601381..93506be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -827,18 +827,17 @@ packages: description: path: "packages/ndk" ref: lnurl-zaps - resolved-ref: e643d3b87d6764afdf6bccb76704aec12cba07a1 + resolved-ref: "23e4afbcbc97946b99fec7d3119719e3c2af7215" url: "https://github.com/relaystr/ndk" source: git version: "0.2.0-dev005" ndk_amber: dependency: "direct main" description: - path: "packages/amber" - ref: lnurl-zaps - resolved-ref: e3be4c7e880d145ccfca3356aa1aa3a0c399d807 - url: "https://github.com/relaystr/ndk" - source: git + name: ndk_amber + sha256: "2f364d54b71df8883219b699bca3c9b0e878341c8f25024d511f0585103b5278" + url: "https://pub.dev" + source: hosted version: "0.2.0-dev002" octo_image: dependency: transitive @@ -1036,7 +1035,7 @@ packages: dependency: "direct main" description: path: "." - ref: master + ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc resolved-ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc url: "https://github.com/purplebase/purplebase" source: git diff --git a/pubspec.yaml b/pubspec.yaml index c90de96..56bdcc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,11 +62,6 @@ dependency_overrides: url: https://github.com/relaystr/ndk path: packages/ndk ref: lnurl-zaps - ndk_amber: - git: - url: https://github.com/relaystr/ndk - path: packages/amber - ref: lnurl-zaps purplebase: # path: ../purplebase git: From 985e17353ea7af828b396cf41ce11ca633bcb241 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 24 Dec 2024 12:31:21 +0100 Subject: [PATCH 31/51] pin ndk git version --- pubspec.lock | 48 ++++++++++++++++++++++++------------------------ pubspec.yaml | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 93506be..939dc6e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,15 +5,15 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "76.0.0" + version: "72.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.3" + version: "0.3.2" amberflutter: dependency: transitive description: @@ -26,10 +26,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "6.7.0" android_package_manager: dependency: "direct main" description: @@ -274,10 +274,10 @@ packages: dependency: "direct main" description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" convert: dependency: "direct main" description: @@ -722,18 +722,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -770,10 +770,10 @@ packages: dependency: transitive description: name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" url: "https://pub.dev" source: hosted - version: "0.1.3-main.0" + version: "0.1.2-main.4" markdown: dependency: transitive description: @@ -826,8 +826,8 @@ packages: dependency: "direct main" description: path: "packages/ndk" - ref: lnurl-zaps - resolved-ref: "23e4afbcbc97946b99fec7d3119719e3c2af7215" + ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 + resolved-ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 url: "https://github.com/relaystr/ndk" source: git version: "0.2.0-dev005" @@ -1173,7 +1173,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.0" + version: "0.0.99" source_gen: dependency: transitive description: @@ -1266,10 +1266,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.1" state_notifier: dependency: transitive description: @@ -1298,10 +1298,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.2.0" synchronized: dependency: transitive description: @@ -1330,10 +1330,10 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.2" timing: dependency: transitive description: @@ -1450,10 +1450,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.2.5" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 56bdcc1..ea2be0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependency_overrides: git: url: https://github.com/relaystr/ndk path: packages/ndk - ref: lnurl-zaps + ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 #lnurl-zaps purplebase: # path: ../purplebase git: From fd745798779e759d3d19dbf3e280237a86afc81c Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 24 Dec 2024 12:52:41 +0100 Subject: [PATCH 32/51] remove ndk_amber dep --- pubspec.lock | 60 +++++++++++++++++++--------------------------------- pubspec.yaml | 1 - 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 939dc6e..2304908 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,31 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" - amberflutter: - dependency: transitive - description: - name: amberflutter - sha256: "5101b36419a93e0620a227ada25690286efc93ab9de59dc80e7c3a0c29ccd348" - url: "https://pub.dev" - source: hosted - version: "0.0.9" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" android_package_manager: dependency: "direct main" description: @@ -274,10 +266,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: "direct main" description: @@ -722,18 +714,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -770,10 +762,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" markdown: dependency: transitive description: @@ -831,14 +823,6 @@ packages: url: "https://github.com/relaystr/ndk" source: git version: "0.2.0-dev005" - ndk_amber: - dependency: "direct main" - description: - name: ndk_amber - sha256: "2f364d54b71df8883219b699bca3c9b0e878341c8f25024d511f0585103b5278" - url: "https://pub.dev" - source: hosted - version: "0.2.0-dev002" octo_image: dependency: transitive description: @@ -1173,7 +1157,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -1266,10 +1250,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" state_notifier: dependency: transitive description: @@ -1298,10 +1282,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" synchronized: dependency: transitive description: @@ -1330,10 +1314,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" timing: dependency: transitive description: @@ -1450,10 +1434,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ea2be0c..f313527 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,7 +54,6 @@ dependencies: easy_image_viewer: ^1.5.1 flutter_secure_storage: ^9.2.2 ndk: ^0.2.0-dev005 - ndk_amber: ^0.2.0-dev002 dependency_overrides: ndk: From cd0ffe0d619f467497c7302f4509895aa058d70b Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Mon, 30 Dec 2024 16:56:11 -0300 Subject: [PATCH 33/51] Providers and other misc fixes --- lib/screens/app_detail_screen.dart | 1 - lib/screens/settings_screen.dart | 16 ++---- lib/utils/nwc.dart | 91 ++++++++++++++--------------- lib/utils/zap.dart | 61 ++++++++++---------- lib/widgets/zap_button.dart | 92 +++++++++++++++++++----------- lib/widgets/zaps.dart | 28 +++++---- 6 files changed, 152 insertions(+), 137 deletions(-) diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 41356b8..f3f37ef 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -12,7 +12,6 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/nwc.dart'; import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/release_card.dart'; import 'package:zapstore/widgets/signer_and_developer_row.dart'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12f5c6d..4cc537c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -122,23 +122,20 @@ class SettingsScreen extends HookConsumerWidget { children: [ Gap(20), Text( - "Nostr Wallet Connect ${nwcConnection != null ? 'connected' : 'URI'}"), - if (nwcConnection != null) + "Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"), + if (nwcConnection.value != null) ElevatedButton( onPressed: () async { - await ref - .read(nwcSecretProvider.notifier) - .updateNwcSecret(null); await ref .read(nwcConnectionProvider.notifier) - .disconnect(); + .disconnectWallet(); }, style: ElevatedButton.styleFrom( disabledBackgroundColor: Colors.transparent, backgroundColor: Colors.transparent), child: Text('Disconnect'), ), - if (nwcConnection == null) + if (nwcConnection.value == null) TextField( autocorrect: false, controller: nwcController, @@ -156,12 +153,9 @@ class SettingsScreen extends HookConsumerWidget { ), onPressed: () async { final nwcUri = nwcController.text.trim(); - await ref - .read(nwcSecretProvider.notifier) - .updateNwcSecret(nwcUri); await ref .read(nwcConnectionProvider.notifier) - .ensureConnected(nwcUri); + .connectWallet(nwcUri); }, builder: (context, child, callback, buttonState) { return Padding( diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 8ce9885..53741dd 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -3,70 +3,65 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk/ndk.dart'; -AndroidOptions _getAndroidOptions() => const AndroidOptions( - encryptedSharedPreferences: true, -); +const kNwcSecretKey = 'nwc_secret'; -class NwcSecretNotifier extends StateNotifier { - final _storage = FlutterSecureStorage(aOptions: _getAndroidOptions()); +class NwcConnectionNotifier extends StateNotifier> { + final _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); - NwcSecretNotifier() : super(null) { - _loadNwcSecret(); + NwcConnectionNotifier() : super(AsyncData(null)) { + // Attempt to load from local storage on initialization + _storage + .read(key: kNwcSecretKey) + .then(connectWallet) + .catchError((e, stack) { + state = AsyncError(e, stack); + }); } - Future _loadNwcSecret() async { - final secret = await _storage.read(key: 'nwc_secret'); - state = secret; - } - - Future updateNwcSecret(String? newSecret) async { - if (newSecret != null) { - await _storage.write(key: 'nwc_secret', value: newSecret); - } else { - await _storage.delete(key: 'nwc_secret'); + Future connectWallet([String? nwcSecret]) async { + if (nwcSecret == null) { + // We get here when storage has no secret, do nothing + state = AsyncData(null); + return; } - await _loadNwcSecret(); - } -} -class NwcConnectionNotifier extends StateNotifier?> { - Ref?> ref; + state = AsyncValue.loading(); - NwcConnectionNotifier(this.ref) : super(null) { - ensureConnected(ref.watch(nwcSecretProvider)); - } + // If secret is supplied and a connection is still active, + // it means it is a new connection string so disconnect NWC + if (state.value != null) { + await ndkForNwc.nwc.disconnect(state.value!); + } - Future disconnect() async { - state = null; - } + // Write secret to storage and connect + await _storage.write(key: kNwcSecretKey, value: nwcSecret); - Future ensureConnected(String? nwcSecret) async { - if (nwcSecret != null && nwcSecret.isNotEmpty && state == null) { - state = AsyncValue.loading(); - NwcConnection connection = await ndkForNwc.nwc - .connect(nwcSecret, doGetInfoMethod: false, onError: (error) { + final connection = await ndkForNwc.nwc.connect( + nwcSecret, + doGetInfoMethod: false, + onError: (error) { state = - AsyncValue.error(error ?? "couldn't connect", StackTrace.current); - }); - if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - state = AsyncValue.data(connection); - } + AsyncValue.error(error ?? 'Could not connect', StackTrace.current); + }, + ); + if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + state = AsyncValue.data(connection); } else { - state = null; + state = AsyncError('No permission to zap', StackTrace.current); } } -} -final nwcSecretProvider = - StateNotifierProvider((ref) { - return NwcSecretNotifier(); -}); + Future disconnectWallet() async { + await _storage.delete(key: kNwcSecretKey); + state = AsyncData(null); + } +} final nwcConnectionProvider = - StateNotifierProvider?>( - (ref) { - return NwcConnectionNotifier(ref); -}); + StateNotifierProvider>( + (ref) => NwcConnectionNotifier()); final ndkForNwc = Ndk( NdkConfig( diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index ac752a1..d5ba327 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -1,7 +1,8 @@ +import 'dart:async'; + import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; -import 'package:ndk/ndk.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.dart'; @@ -11,40 +12,47 @@ import 'package:zapstore/utils/nwc.dart'; import '../models/user.dart'; class ZapReceiptsNotifier extends StateNotifier>?> { - Ref?> ref; + Ref ref; ZapReceiptsNotifier(this.ref) : super(null); + StreamSubscription? sub; + Future fetchZaps(FileMetadata fileMetadata) async { state = null; final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); if (socialRelays.ndk != null) { state = AsyncValue.loading(); - Stream receiptsResponse = socialRelays.ndk!.zaps - .fetchZappedReceipts( - pubKey: fileMetadata.pubkey, eventId: fileMetadata.id!.toString()); - receiptsResponse.listen((receipt) { + final receiptsResponse = socialRelays.ndk!.zaps.fetchZappedReceipts( + pubKey: fileMetadata.pubkey, eventId: fileMetadata.id!.toString()); + sub = receiptsResponse.listen((receipt) { addZapReceipt(receipt); }); } } void addZapReceipt(ZapReceipt receipt) { - if (state==null || state!.value==null) { + if (state == null || state!.value == null) { state = AsyncValue.data([receipt]); } else { - List list = state!.value!; - list.add(receipt); - state = AsyncValue.data(list); + final receipts = state!.value!; + receipts.add(receipt); + state = AsyncValue.data(receipts); } } + + @override + void dispose() { + sub?.cancel(); + super.dispose(); + } } -class ZapNotifier extends StateNotifier?> { - Ref?> ref; +class ZapNotifier extends StateNotifier> { + Ref ref; - ZapNotifier(this.ref) : super(null); + ZapNotifier(this.ref) : super(AsyncData(null)); Future zap( {required User user, @@ -52,8 +60,8 @@ class ZapNotifier extends StateNotifier?> { required String lnurl, required int amount, required String pubKey}) async { - final nwcConnection = ref.read(nwcConnectionProvider.notifier).state; - if (nwcConnection == null || !nwcConnection.hasValue) { + final nwcConnectionState = ref.read(nwcConnectionProvider.notifier).state; + if (nwcConnectionState.value == null) { return; } state = AsyncValue.loading(); @@ -63,8 +71,8 @@ class ZapNotifier extends StateNotifier?> { ? socialRelays.ndk!.config.bootstrapRelays : kSocialRelays.where((r) => r != 'ndk'); try { - ZapResponse zapResponse = await ndkForNwc.zaps.zap( - nwcConnection: nwcConnection.value!, + final zapResponse = await ndkForNwc.zaps.zap( + nwcConnection: nwcConnectionState.value!, lnurl: lnurl, amountSats: amount, fetchZapReceipt: true, @@ -76,7 +84,7 @@ class ZapNotifier extends StateNotifier?> { if (zapResponse.payInvoiceResponse != null && zapResponse.payInvoiceResponse!.preimage.isNotEmpty) { state = AsyncValue.data(zapResponse); - ZapReceipt? zapReceipt = await zapResponse.zapReceipt; + final zapReceipt = await zapResponse.zapReceipt; if (zapReceipt != null) { ref.read(zapReceiptsNotifier.notifier).addZapReceipt(zapReceipt); } @@ -86,21 +94,16 @@ class ZapNotifier extends StateNotifier?> { "couldn't pay, unknown error", StackTrace.current); } - } catch (e) { - state = AsyncValue.error(e.toString(), StackTrace.current); + } catch (e, stack) { + state = AsyncValue.error(e, stack); } - Future.delayed(Duration(seconds: 5)).then((_) { - state = null; - }); } } final zapProvider = - StateNotifierProvider?>((ref) { - return ZapNotifier(ref.read(nwcConnectionProvider.notifier).ref); -}); + StateNotifierProvider>( + ZapNotifier.new); final zapReceiptsNotifier = -StateNotifierProvider>?>((ref) { - return ZapReceiptsNotifier(ref.read(nwcConnectionProvider.notifier).ref); -}); + StateNotifierProvider>?>( + ZapReceiptsNotifier.new); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 4099caa..705387c 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,10 +1,11 @@ +import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/zap.dart'; @@ -31,46 +32,65 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); - var text = 'Zap the dev ⚡'; - if (zapStatus!=null) { - if (zapStatus.isLoading) { - text = 'Zapping... ⚡⚡⚡'; - } else if (zapStatus.error != null) { - text = "error zapping ${zapStatus.error.toString()}"; - // TODO: how to do an error toast here? context.showError complaints about being used during widget build - } else { - text = "Zapped!"; - } - } + // We use value as default text, for progress only interested in loading/error substates + final zapButtonText = switch (zapStatus) { + AsyncLoading() => 'Zapping... ⚡⚡⚡', + AsyncError() => 'Error zapping!', + AsyncValue() => 'Zap the dev ⚡', + }; + + // Show toast if could not zap + useMemoized(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (zapStatus.value != null) { + // final receipt = await zapStatus.value!.zapReceipt; + // TODO: zapStatus should include the amount of sats without needing async + // I could read zapReceiptsNotifier's state, but how I know it's this one? + context.showInfo('Zapped!', + description: 'You just sent the dev some sats'); + } + if (zapStatus.hasError) { + context.showError( + title: 'Could not zap', + description: 'Error sending zap: ${zapStatus.error}'); + } + }); + }, [zapStatus.value, zapStatus.error]); return app.developer.value != null - ? ElevatedButton( + ? AsyncButtonBuilder( + loadingWidget: SizedBox( + width: 14, height: 14, child: CircularProgressIndicator()), onPressed: () async { - if (nwcConnection == null) { - context.showError(title: "need an NWC connection URI set"); + if (user == null) { + context.showError( + title: 'Not signed in', + description: 'Please sign in to zap'); + return; + } + + if (nwcConnection.value == null) { + context.showError( + title: 'No wallet connected', + description: 'Please connect your NWC wallet'); appRouter.go('/settings'); return; } - User? developer = app.developer.value; - if (developer == null) { - context.showError(title: "no dev set"); - return; - } + + final developer = app.developer.value!; if (developer.lud16 == null) { context.showError( - title: "${developer.nameOrNpub} has no LN address :("); + title: 'Unable to zap', + description: + 'The developer (${developer.nameOrNpub}) does not have a Lightning address'); return; } - if (user == null) { - context.showError(title: "must be logged in to sign zaps"); - appRouter.go('/settings'); - return; - } - int amount = await showDialog( + + final amount = await showDialog( context: context, - builder: (BuildContext context) { + builder: (context) { return AlertDialog( - title: Text('Choose Zap Amount'), + title: Text('Choose zap amount'), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -105,7 +125,7 @@ class ZapButton extends HookConsumerWidget { ), ], ), - actions: [ + actions: [ TextButton( child: Text('Cancel'), onPressed: () { @@ -125,18 +145,24 @@ class ZapButton extends HookConsumerWidget { }, ) ?? 0; + if (amount > 0) { final lnurl = developer.lud16!; await ref.read(zapProvider.notifier).zap( user: user, lnurl: lnurl, - eventId: app.latestMetadata != null ? app.latestMetadata!.id - .toString() : app.latestRelease!.id.toString(), + eventId: app.latestMetadata != null + ? app.latestMetadata!.id.toString() + : app.latestRelease!.id.toString(), amount: amount, pubKey: developer.pubkey); } }, - child: Text(text)) + builder: (context, child, callback, state) { + return ElevatedButton(onPressed: callback, child: child); + }, + child: Text(zapButtonText), + ) : Container(); } } diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart index 0111342..4859e69 100644 --- a/lib/widgets/zaps.dart +++ b/lib/widgets/zaps.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/user.dart'; @@ -20,15 +19,13 @@ class Zaps extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final zapReceipts = ref.watch(zapReceiptsNotifier); - if (app.developer.value != null && - app.developer.value!.lud16 != null && - app.latestMetadata != null) { + if (app.developer.value?.lud16 != null && app.latestMetadata != null) { if (zapReceipts == null) { Future(() { ref.read(zapReceiptsNotifier.notifier).fetchZaps(app.latestMetadata!); }); } else { - List? receipts = zapReceipts.value; + final receipts = zapReceipts.value; if (receipts != null) { receipts .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); @@ -37,10 +34,11 @@ class Zaps extends HookConsumerWidget { for (var receipt in receipts) { eventSum += receipt.amountSats ?? 0; } - Set senderIds = + final senderIds = receipts.map((receipt) => receipt.sender).nonNulls.toSet(); - final senders = ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); + final senders = + ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); Widget zapperAvatars = switch (senders) { AsyncData>(value: final users) => Builder( @@ -83,23 +81,23 @@ class Zaps extends HookConsumerWidget { ), ) }; - return Row(children: [ - Text("⚡ $eventSum sats (${receipts.length} zaps)"), - Gap(5), - zapperAvatars - ]); + return Row( + children: [ + Text("⚡ $eventSum sats (${receipts.length} zaps)"), + Gap(5), + zapperAvatars + ], + ); } } } return Container(); } - - } final zappersProvider = FutureProvider.autoDispose .family, ({Iterable zapperIds})>((ref, arg) { - // TODO it should load from either local or remote if not cached locally still + // TODO it should load from either local or remote if not cached locally still List list = ref.users.findManyLocalByIds(arg.zapperIds); return list; // return await ref.users.findAll(params: {'authors': arg.zapperIds, 'ignoreReturn': true}); From 5cba51c846089ce2776e88e96fa508e673126943 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 31 Dec 2024 14:10:54 -0300 Subject: [PATCH 34/51] New purplebase and zaps integration WIP --- analysis_options.yaml | 1 + lib/models/app.dart | 33 ++++--- lib/models/app_curation_set.dart | 13 ++- lib/models/feedback.dart | 17 ++-- lib/models/file_metadata.dart | 26 +++--- lib/models/local_app.dart | 2 +- lib/models/nostr_adapter.dart | 23 +++-- lib/models/release.dart | 29 +++--- lib/models/user.dart | 103 ++++++++++++++++++--- lib/screens/app_detail_screen.dart | 10 +- lib/screens/settings_screen.dart | 2 +- lib/utils/signers.dart | 24 +++-- lib/utils/zap.dart | 5 +- lib/widgets/app_card.dart | 5 +- lib/widgets/app_curation_container.dart | 12 +-- lib/widgets/error_container.dart | 3 +- lib/widgets/install_alert_dialog.dart | 2 +- lib/widgets/install_button.dart | 2 +- lib/widgets/latest_releases_container.dart | 2 +- lib/widgets/release_card.dart | 10 +- lib/widgets/sign_in_container.dart | 4 +- lib/widgets/wot_container.dart | 2 +- lib/widgets/zap_button.dart | 20 ++-- lib/widgets/zaps.dart | 8 +- pubspec.lock | 32 +------ pubspec.yaml | 8 +- 26 files changed, 237 insertions(+), 161 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 4c4650c..0cf006c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -32,6 +32,7 @@ linter: avoid_print: false prefer_const_literals_to_create_immutables: false no_wildcard_variable_uses: false + omit_local_variable_types: true # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/models/app.dart b/lib/models/app.dart index 50e69a2..47b0ab1 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -13,7 +13,7 @@ import 'package:install_plugin/install_plugin.dart'; import 'package:mutex/mutex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/local_app.dart'; @@ -29,7 +29,10 @@ part 'app.g.dart'; final mutex = Mutex(); @DataAdapter([NostrAdapter, AppAdapter]) -class App extends BaseApp with DataModelMixin { +class App extends base.App with DataModelMixin { + @override + Object? get id => event.id; + final HasMany releases; final BelongsTo signer; final BelongsTo developer; @@ -195,14 +198,14 @@ class App extends BaseApp with DataModelMixin { ); final i = await packageManager.getPackageInfo( - packageName: identifier!, flags: flags); + packageName: identifier, flags: flags); if (i == null) { return null; } final bytes = i.signingInfo!.signingCertificateHistory!.first; final installedApkSigHash = sha256.convert(bytes).toString().toLowerCase(); final metadataSigHashes = - latestMetadata?.tagMap['apk_signature_hash'] ?? {}; + latestMetadata?.event.getTagSet('apk_signature_hash') ?? {}; return metadataSigHashes .any((msh) => msh.toLowerCase() == installedApkSigHash); } @@ -287,14 +290,14 @@ mixin AppAdapter on Adapter { // Find most recent `created_at` from returned apps // and assign it to their queried at value for caching final m = apps.isNotEmpty - ? apps.fold(apps.first.createdAt!, (acc, e) { - return acc.isAfter(e.createdAt!) ? acc : e.createdAt!; + ? apps.fold(apps.first.event.createdAt, (acc, e) { + return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt; }) : null; if (m != null) { for (final app in apps) { - queriedAtMap[app.identifier!] = m; + queriedAtMap[app.identifier] = m; } } } else { @@ -304,8 +307,9 @@ mixin AppAdapter on Adapter { // Find all appid@version ($3) as we need to pick one tag to query on // (filters by kind ($1) and pubkey ($2) done locally) - final latestReleaseIdentifiers = - apps.map((app) => app.linkedReplaceableEvents.firstOrNull?.$3).nonNulls; + final latestReleaseIdentifiers = apps + .map((app) => app.event.linkedReplaceableEvents.firstOrNull?.$3) + .nonNulls; if (latestReleaseIdentifiers.isNotEmpty) { releases = await ref.releases.findAll( params: {'#d': latestReleaseIdentifiers}, @@ -369,10 +373,11 @@ mixin AppAdapter on Adapter { final list = data is Iterable ? data : [data as Map]; for (final Map map in list) { - final tagMap = tagsToMap(map['tags']); - map['developer'] = tagMap['zap']?.firstOrNull ?? - (map['pubkey'] != kZapstorePubkey ? map['pubkey'] : null); - map['localApp'] = tagMap['d']!.first; + final tags = map['tags']; + final pubkey = base.BaseUtil.getTag(tags, 'pubkey'); + map['developer'] = base.BaseUtil.getTag(tags, 'zap') ?? + (pubkey != kZapstorePubkey ? pubkey : null); + map['localApp'] = base.BaseUtil.getTag(tags, 'd')!; } return super.deserialize(data); @@ -408,7 +413,7 @@ Future _isHashMismatch(String path, String hash) async { extension AppExt on Iterable { List get sortedByLatest => - sorted((a, b) => b.createdAt!.compareTo(a.createdAt!)); + sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); } // class diff --git a/lib/models/app_curation_set.dart b/lib/models/app_curation_set.dart index 11da409..ca96ac9 100644 --- a/lib/models/app_curation_set.dart +++ b/lib/models/app_curation_set.dart @@ -1,5 +1,5 @@ import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/nostr_adapter.dart'; @@ -9,8 +9,11 @@ import 'package:zapstore/utils/extensions.dart'; part 'app_curation_set.g.dart'; @DataAdapter([NostrAdapter, AppCurationSetAdapter]) -class AppCurationSet extends BaseAppCurationSet +class AppCurationSet extends base.AppCurationSet with DataModelMixin { + @override + Object get id => event.id; + final HasMany apps; final BelongsTo signer; @@ -21,7 +24,7 @@ class AppCurationSet extends BaseAppCurationSet Map toJson() => super.toMap(); - String get name => content.isNotEmpty ? content : identifier!; + String get name => event.content.isNotEmpty ? event.content : identifier; } mixin AppCurationSetAdapter on Adapter { @@ -45,8 +48,8 @@ mixin AppCurationSetAdapter on Adapter { DeserializedData deserialize(Object? data, {String? key}) { final list = data is Iterable ? data : [data as Map]; for (final Map map in list) { - final tagMap = tagsToMap(map['tags']); - map['apps'] = tagMap['a']; + final tags = map['tags']; + map['apps'] = base.BaseUtil.getTagSet(tags, 'a'); } return super.deserialize(data); } diff --git a/lib/models/feedback.dart b/lib/models/feedback.dart index eab3b66..012518f 100644 --- a/lib/models/feedback.dart +++ b/lib/models/feedback.dart @@ -1,10 +1,13 @@ import 'package:purplebase/purplebase.dart'; -class AppFeedback extends BaseEvent { - @override - int get kind => 1011; - - AppFeedback({ - required String content, - }) : super(content: content); +class AppFeedback extends RegularEvent { + AppFeedback.fromJson(super.map) : super.fromJson(); +} + +class PartialAppFeedback extends RegularPartialEvent { + PartialAppFeedback({ + required String content, + }) { + event.content = content; + } } diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index 7bcd66d..2879562 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -1,5 +1,5 @@ import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/user.dart'; @@ -8,18 +8,21 @@ import 'package:zapstore/utils/extensions.dart'; part 'file_metadata.g.dart'; @DataAdapter([NostrAdapter, FileMetadataAdapter]) -class FileMetadata extends BaseFileMetadata with DataModelMixin { +class FileMetadata extends base.FileMetadata with DataModelMixin { final BelongsTo author; final BelongsTo release; final BelongsTo signer; - FileMetadata( - {super.createdAt, - super.content, - super.tags, - required this.author, - required this.release, - required this.signer}); + @override + Object? get id => event.id; + + // FileMetadata( + // {super.createdAt, + // super.content, + // super.tags, + // required this.author, + // required this.release, + // required this.signer}); FileMetadata.fromJson(super.map) : author = belongsTo(map['author']), @@ -30,9 +33,8 @@ class FileMetadata extends BaseFileMetadata with DataModelMixin { Map toJson() => super.toMap(); // String? get version => tagMap['version']?.firstOrNull; - int? get versionCode => - int.tryParse(tagMap['version_code']?.firstOrNull ?? ''); - String? get apkSignatureHash => tagMap['apk_signature_hash']?.firstOrNull; + int? get versionCode => int.tryParse(event.getTag('version') ?? ''); + String? get apkSignatureHash => event.getTag('apk_signature_hash'); } mixin FileMetadataAdapter on Adapter { diff --git a/lib/models/local_app.dart b/lib/models/local_app.dart index 31f03df..7058dce 100644 --- a/lib/models/local_app.dart +++ b/lib/models/local_app.dart @@ -144,7 +144,7 @@ mixin LocalAppAdapter on Adapter { final installedApkSigHash = sha256.convert(signingCertificateBytes).toString().toLowerCase(); final metadataSigHashes = - app.latestMetadata?.tagMap['apk_signature_hash'] ?? {}; + app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {}; final matches = metadataSigHashes .any((msh) => msh.toLowerCase() == installedApkSigHash); if (!matches) { diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index db25fe8..4f8cb9e 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' hide Release; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/release.dart'; @@ -13,7 +13,7 @@ import 'package:zapstore/widgets/latest_releases_container.dart'; // NOTE: Very important to use const in relay args to preserve equality in Riverpod families // const kAppRelays = {'ws://10.0.2.2:3000'}; const kAppRelays = {'wss://relay.zapstore.dev'}; -const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net', 'ndk'}; +const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net'}; mixin NostrAdapter> on Adapter { RelayMessageNotifier get relay => @@ -52,7 +52,10 @@ mixin NostrAdapter> on Adapter { } int get kind { - return BaseEvent.kindForType(internalType)!; + var pbType = internalType.singularize().capitalize(); + pbType = + pbType.replaceFirst('datum', 'data'); // handle FileMetadata edge case + return Event.types[pbType]!.kind; } @override @@ -66,13 +69,13 @@ mixin NostrAdapter> on Adapter { map['signer'] = map['pubkey']; // ID should be the replaceable link/reference so as to make it replaceable in local db too - final tagMap = tagsToMap(map['tags']); - final isReplaceable = tagMap.containsKey('d'); + final tags = map['tags']; + final isReplaceable = BaseUtil.containsTag(tags, 'd'); if (isReplaceable) { map['id'] = ( map['kind'] as int, map['pubkey'].toString(), - tagMap['d']?.firstOrNull + BaseUtil.getTag(tags, 'd')!, ).formatted; } @@ -80,7 +83,11 @@ mixin NostrAdapter> on Adapter { map.remove('sig'); // Collect models for current kind and included for others - final eventType = BaseEvent.typeForKind(kind)!; + final eventType = Event.types.entries + .firstWhere((e) => e.value.kind == kind) + .key + .pluralize() + .decapitalize(); if (eventType == internalType) { final newData = super.deserialize(map); models.addAll(newData.models as Iterable); @@ -144,7 +151,7 @@ mixin NostrAdapter> on Adapter { Future loadArtifactsAndUsers(Iterable releases) async { final metadataIds = - releases.map((r) => r.linkedEvents).nonNulls.expand((_) => _); + releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _); final apps = releases.map((r) => r.app.value).nonNulls; final userIds = { diff --git a/lib/models/release.dart b/lib/models/release.dart index 80d0ced..8573044 100644 --- a/lib/models/release.dart +++ b/lib/models/release.dart @@ -1,6 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.dart'; @@ -10,18 +10,21 @@ import 'package:zapstore/utils/extensions.dart'; part 'release.g.dart'; @DataAdapter([NostrAdapter, ReleaseAdapter]) -class Release extends BaseRelease with DataModelMixin { +class Release extends base.Release with DataModelMixin { + @override + Object? get id => event.id; + final HasMany artifacts; final BelongsTo app; final BelongsTo signer; - Release( - {super.createdAt, - super.content, - super.tags, - required this.artifacts, - required this.app, - required this.signer}); + // Release( + // {super.createdAt, + // super.content, + // super.tags, + // required this.artifacts, + // required this.app, + // required this.signer}); Release.fromJson(super.map) : app = belongsTo(map['app']), @@ -38,9 +41,9 @@ mixin ReleaseAdapter on Adapter { final list = data is Iterable ? data : [data as Map]; for (final Map map in list) { - final tagMap = tagsToMap(map['tags']); - map['app'] = tagMap['a']?.firstOrNull; - map['artifacts'] = tagMap['e']!.toList(); + final tags = map['tags']; + map['app'] = base.BaseUtil.getTag(tags, 'a'); + map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList(); } return super.deserialize(data); } @@ -48,5 +51,5 @@ mixin ReleaseAdapter on Adapter { extension ReleaseExt on Iterable { List get sortedByLatest => - sorted((a, b) => b.createdAt!.compareTo(a.createdAt!)); + sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); } diff --git a/lib/models/user.dart b/lib/models/user.dart index 223d44f..541709e 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -2,25 +2,100 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; +import 'package:http/http.dart' as http; +import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nwc.dart'; part 'user.g.dart'; @DataAdapter([NostrAdapter, UserAdapter]) -class User extends BaseUser with DataModelMixin { - User( - {super.name, - super.createdAt, - super.tags, - HasMany? followers, - HasMany? following, - BelongsTo? settings}) - : followers = followers ?? HasMany(), - following = following ?? HasMany(), - settings = settings ?? BelongsTo(); +class User extends base.User with DataModelMixin { + @override + Object? get id => event.id; + + Future zap(int amount, + {required base.Event event, String? comment}) async { + final adapter = DataModel.adapterFor(this) as NostrAdapter; + + final authorPubkey = event.event.pubkey; + final author = adapter.ref.users.findOneLocalById(authorPubkey); + + if (author?.lud16 == null) { + throw 'cant zap'; + } + + final partialZapRequest = base.PartialZapRequest(); + partialZapRequest + ..addLinkedEvent(event) + ..addLinkedUser(this) + ..amount = amount; + final zapRequest = await partialZapRequest + .signWith(base.Bip340PrivateKeySigner(kZapstorePubkey)); + print(zapRequest); + + // Now we fetch the invoice + final lnurlResponse = await author!.fetchLnUrl(); + + final recipientAllowsNostr = lnurlResponse['allowsNostr']; + final recipientPubkey = lnurlResponse['nostrPubkey']; + // All amounts are in millisats + final recipientMin = lnurlResponse['minSendable']; + final recipientMax = lnurlResponse['maxSendable']; + final amountInMillisats = amount * 1000; + + if (recipientAllowsNostr != true && recipientPubkey != null) { + throw 'cant zap, recipient does not allow nostr'; + } + if ((recipientMin != null && amountInMillisats < recipientMin) || + (recipientMax != null && amountInMillisats > recipientMax)) { + throw 'cant zap, amount not between min and max sendable'; + } + + final commentLength = lnurlResponse['commentAllowed']; + comment = + commentLength != null ? comment?.substringMax(commentLength) : comment; + + var callbackUri = Uri.parse(lnurlResponse['callback']!); + callbackUri = callbackUri.replace( + queryParameters: { + ...callbackUri.queryParameters, + 'amount': amountInMillisats.toString(), + if (comment != null) 'comment': comment, + 'nostr': jsonEncode(zapRequest.toMap()), + }, + ); + + print(callbackUri); + + final response = await http.get(callbackUri); + final invoiceMap = jsonDecode(response.body); + + print(invoiceMap); + final invoice = invoiceMap['pr']; + if (invoice == null) { + throw 'cant zap, no invoice'; + } + + final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value; + if (nwcConnection == null) { + throw 'cant zap, no nwc connection'; + } + + // Pay the invoice via NWC + adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, invoice: invoice); + } + + Future> fetchLnUrl() async { + final [userName, domainName] = lud16!.split('@'); + final lnurl = 'https://$domainName/.well-known/lnurlp/$userName'; + final response = await http.get(Uri.parse(lnurl)); + final map = jsonDecode(response.body); + return map; + } User.fromJson(super.map) : followers = hasMany(map['followers']), @@ -58,7 +133,7 @@ mixin UserAdapter on NostrAdapter { return []; } - final request = RelayRequest( + final request = base.RelayRequest( kinds: {0}, // 3 authors: {...authors}, since: queriedAtMap[authors.join()], @@ -119,7 +194,7 @@ mixin UserAdapter on NostrAdapter { ); } - final result = await socialRelays.queryRaw(RelayRequest( + final result = await socialRelays.queryRaw(base.RelayRequest( kinds: {0}, // 3 tags: params ?? {}, authors: {publicKey}, diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index f3f37ef..cfe846c 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -35,11 +35,11 @@ class AppDetailScreen extends HookConsumerWidget { // TODO: Bug using remote=true in watchOne, use hook for now final snapshot = useFuture(useMemoized(() async { final storedApps = - ref.apps.appAdapter.findWhereIdentifierInLocal({model.identifier!}); + ref.apps.appAdapter.findWhereIdentifierInLocal({model.identifier}); if (storedApps.firstOrNull?.latestMetadata != null) { return storedApps.first; } - return ref.apps.findOne(model.identifier!, remote: true); + return ref.apps.findOne(model.identifier, remote: true); })); final state = ref.apps.watchOne(model.id!, alsoWatch: (_) => { @@ -59,7 +59,7 @@ class AppDetailScreen extends HookConsumerWidget { .nonNulls; return RefreshIndicator( - onRefresh: () => ref.apps.findOne(model.identifier!, remote: true), + onRefresh: () => ref.apps.findOne(model.identifier, remote: true), child: Column( children: [ Expanded( @@ -149,7 +149,7 @@ class AppDetailScreen extends HookConsumerWidget { ), ), selectable: false, - data: app.content.parseEmojis(), + data: app.event.content.parseEmojis(), ), Gap(10), Padding( @@ -224,7 +224,7 @@ class AppDetailScreen extends HookConsumerWidget { Gap(10), Flexible( child: AutoSizeText( - app.identifier!, + app.identifier, minFontSize: 12, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4cc537c..5de3f60 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -78,7 +78,7 @@ class SettingsScreen extends HookConsumerWidget { onPressed: () async { if (feedbackController.text.trim().isNotEmpty) { final signedDirectMessage = await amberSigner.sign( - BaseDirectMessage( + PartialDirectMessage( content: feedbackController.text.trim(), receiver: kZapstorePubkey.npub), asUser: user.pubkey); diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart index c598463..dd1afb6 100644 --- a/lib/utils/signers.dart +++ b/lib/utils/signers.dart @@ -26,35 +26,33 @@ class AmberSigner extends Signer { } @override - Future sign>(T model, {String? asUser}) async { + Future sign>(PartialEvent partialEvent, + {String? asUser}) async { if (!isAvailable) { throw Exception("Cannot sign, missing Amber"); } - if (model is BaseDirectMessage) { + if (partialEvent is DirectMessage) { final signedMessage = await _signerPlugin.nip04Encrypt( - model.content, + partialEvent.event.content, "", - (asUser ?? model.pubkey).npub, - (model as BaseDirectMessage).receiver.hexKey); + asUser!.npub, + (partialEvent as DirectMessage).receiver.hexKey); final encryptedContent = signedMessage['result']; - model = - (model as BaseDirectMessage).copyWith(content: encryptedContent) as T; + partialEvent.event.content = encryptedContent; } - final pubkey = asUser ?? model.pubkey; // Remove all null fields (Amber otherwise crashes) final map = { - for (final e in model.toMap().entries) + for (final e in partialEvent.toMap().entries) if (e.value != null) e.key: e.value }; final signedMessage = - await _signerPlugin.signEvent(jsonEncode(map), "", pubkey); + await _signerPlugin.signEvent(jsonEncode(map), "", asUser!); final signedEvent = jsonDecode(signedMessage['event']); - final fn = BaseEvent.constructorForKind(model.kind)!; - return fn.call(signedEvent); + return Event.getConstructor()!.call(signedEvent); } } -final pkSigner = PrivateKeySigner( +final pkSigner = Bip340PrivateKeySigner( 'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58'); diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart index d5ba327..0745f8c 100644 --- a/lib/utils/zap.dart +++ b/lib/utils/zap.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' hide FileMetadata, User; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/utils/nip55_event_signer.dart'; @@ -25,7 +25,8 @@ class ZapReceiptsNotifier extends StateNotifier>?> { state = AsyncValue.loading(); final receiptsResponse = socialRelays.ndk!.zaps.fetchZappedReceipts( - pubKey: fileMetadata.pubkey, eventId: fileMetadata.id!.toString()); + pubKey: fileMetadata.event.pubkey, + eventId: fileMetadata.id!.toString()); sub = receiptsResponse.listen((receipt) { addZapReceipt(receipt); }); diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index ea62eca..c4c6d1a 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -74,7 +74,7 @@ class AppCard extends HookConsumerWidget { ), Gap(6), Text( - app.content.removeMarkdown().parseEmojis(), + app.description.removeMarkdown().parseEmojis(), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w300), overflow: TextOverflow.ellipsis, @@ -93,7 +93,8 @@ class AppCard extends HookConsumerWidget { size: 12, ), if (showDate) - Text(app.latestRelease!.createdAt!.toIso8601String()), + Text( + app.latestRelease!.event.createdAt.toIso8601String()), if (isUpdate) Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), diff --git a/lib/widgets/app_curation_container.dart b/lib/widgets/app_curation_container.dart index c863c9f..6bd4b11 100644 --- a/lib/widgets/app_curation_container.dart +++ b/lib/widgets/app_curation_container.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app_curation_set.dart'; import 'package:zapstore/models/user.dart'; @@ -20,7 +20,7 @@ class AppCurationContainer extends HookConsumerWidget { final selectedAppCurationSet = ref.watch(_selectedIdProvider); final appCurationSets = ref.appCurationSets .findAllLocal() - .sortedBy((s) => s.createdAt!) + .sortedBy((s) => s.event.createdAt) .reversed .toList(); @@ -101,9 +101,9 @@ class AppCurationContainer extends HookConsumerWidget { // Providers class AppCurationSetNotifier - extends FamilyAsyncNotifier { + extends FamilyAsyncNotifier { @override - Future build(ReplaceableEventLink arg) async { + Future build(base.ReplaceableEventLink arg) async { final appCurationSet = ref.appCurationSets.findOneLocalById(arg.formatted)!; fetch(); return appCurationSet; @@ -133,9 +133,9 @@ class AppCurationSetNotifier final appCurationSetProvider = AsyncNotifierProvider.family< AppCurationSetNotifier, AppCurationSet, - ReplaceableEventLink>(AppCurationSetNotifier.new); + base.ReplaceableEventLink>(AppCurationSetNotifier.new); final _selectedIdProvider = - StateProvider((_) => kNostrCurationSetLink); + StateProvider((_) => kNostrCurationSetLink); const kNostrCurationSetLink = (30267, kFranzapPubkey, 'nostr'); diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart index 4bdb7a4..cd13cc3 100644 --- a/lib/widgets/error_container.dart +++ b/lib/widgets/error_container.dart @@ -149,7 +149,8 @@ Future _sendErrorReport( 'info': systemInfo.androidInfo.toString() }; - final event = await pkSigner.sign(AppFeedback(content: jsonEncode(map))); + final event = + await pkSigner.sign(PartialAppFeedback(content: jsonEncode(map))); await http.post(Uri.parse('https://relay.zapstore.dev/'), body: jsonEncode(event.toMap()), headers: {'Content-Type': 'application/json'}); diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index b53ca43..d585b1c 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 2f81bdf..db3f2a7 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -42,7 +42,7 @@ class InstallButton extends HookConsumerWidget { AppInstallStatus.certificateMismatch => null, AppInstallStatus.updated => () { - packageManager.openApp(app.identifier!); + packageManager.openApp(app.identifier); }, _ => switch (progress) { IdleInstallProgress() => () { diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 6cffdeb..4252cf5 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -125,7 +125,7 @@ class LatestReleasesAppNotifier extends PreloadingStateNotifier> { // Set timestamp of oldest, to prepare for next query if (apps.isNotEmpty) { - _oldestCreatedAt = apps.last.createdAt; + _oldestCreatedAt = apps.last.event.createdAt; } return AsyncData(apps); } diff --git a/lib/widgets/release_card.dart b/lib/widgets/release_card.dart index eb2a7f0..0340fba 100644 --- a/lib/widgets/release_card.dart +++ b/lib/widgets/release_card.dart @@ -28,16 +28,16 @@ class ReleaseCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(release.version!, + Text(release.version, style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)), Gap(10), - release.content.length < 3000 - ? MarkdownBody(data: release.content) + release.releaseNotes.length < 3000 + ? MarkdownBody(data: release.releaseNotes) : Container( constraints: BoxConstraints( maxHeight: 300, ), - child: Markdown(data: release.content), + child: Markdown(data: release.releaseNotes), ), Gap(30), if (metadata != null) @@ -54,7 +54,7 @@ class ReleaseCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Date'), - Text(formatter.format(release.createdAt!)), + Text(formatter.format(release.event.createdAt)), ], ), ), diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 382f458..c68a6bf 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -4,7 +4,7 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; @@ -137,6 +137,8 @@ class SignInContainer extends HookConsumerWidget { var user = await ref.users.findOne(signedInNpub); + // If user was not found on relays, we create a + // local user to represent this new npub user ??= User.fromJson({ 'id': signedInNpub.hexKey, 'pubkey': signedInNpub.hexKey, diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index 15fdf09..2e373ca 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -2,7 +2,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart'; +import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 705387c..9eba80f 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -147,15 +147,17 @@ class ZapButton extends HookConsumerWidget { 0; if (amount > 0) { - final lnurl = developer.lud16!; - await ref.read(zapProvider.notifier).zap( - user: user, - lnurl: lnurl, - eventId: app.latestMetadata != null - ? app.latestMetadata!.id.toString() - : app.latestRelease!.id.toString(), - amount: amount, - pubKey: developer.pubkey); + // User should be able to sign any event + // Proposed API: + await user.zap(amount, event: app.latestMetadata!); + + // final lnurl = developer.lud16!; + // await ref.read(zapProvider.notifier).zap( + // user: user, + // lnurl: lnurl, + // eventId: app.latestMetadata!.id.toString(), + // amount: amount, + // pubKey: developer.pubkey); } }, builder: (context, child, callback, state) { diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart index 4859e69..5f6d12d 100644 --- a/lib/widgets/zaps.dart +++ b/lib/widgets/zaps.dart @@ -30,7 +30,7 @@ class Zaps extends HookConsumerWidget { receipts .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); - int eventSum = 0; + var eventSum = 0; for (var receipt in receipts) { eventSum += receipt.amountSats ?? 0; } @@ -40,7 +40,7 @@ class Zaps extends HookConsumerWidget { final senders = ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); - Widget zapperAvatars = switch (senders) { + final zapperAvatars = switch (senders) { AsyncData>(value: final users) => Builder( builder: (context) { return RichText( @@ -98,7 +98,5 @@ class Zaps extends HookConsumerWidget { final zappersProvider = FutureProvider.autoDispose .family, ({Iterable zapperIds})>((ref, arg) { // TODO it should load from either local or remote if not cached locally still - List list = ref.users.findManyLocalByIds(arg.zapperIds); - return list; - // return await ref.users.findAll(params: {'authors': arg.zapperIds, 'ignoreReturn': true}); + return ref.users.findManyLocalByIds(arg.zapperIds); }); diff --git a/pubspec.lock b/pubspec.lock index 2304908..73a79b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -310,14 +310,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" - decimal: - dependency: transitive - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" device_info_plus: dependency: "direct main" description: @@ -614,14 +606,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - humanizer: - dependency: transitive - description: - name: humanizer - sha256: "08728a4b6d62accd7d09e668bd54e81e6e09a82c8cfda30553224b3eb868d4f2" - url: "https://pub.dev" - source: hosted - version: "2.2.0" icons_launcher: dependency: "direct dev" description: @@ -1018,11 +1002,9 @@ packages: purplebase: dependency: "direct main" description: - path: "." - ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc - resolved-ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc - url: "https://github.com/purplebase/purplebase" - source: git + path: "../purplebase" + relative: true + source: path version: "0.2.0" quiver: dependency: transitive @@ -1032,14 +1014,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" remove_markdown: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f313527..90995b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,10 +62,10 @@ dependency_overrides: path: packages/ndk ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 #lnurl-zaps purplebase: - # path: ../purplebase - git: - url: https://github.com/purplebase/purplebase - ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc + path: ../purplebase + # git: + # url: https://github.com/purplebase/purplebase + # ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc flutter_data: git: url: https://github.com/zapstore/flutter_data From 076338b51ec57317dbe03dd3332e8fcbbea60e63 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 31 Dec 2024 18:21:43 -0300 Subject: [PATCH 35/51] Fix build.gradle --- android/app/build.gradle | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 42e7549..a59bcea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -35,8 +35,8 @@ android { ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { @@ -70,10 +70,6 @@ android { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' -<<<<<<< HEAD - -======= ->>>>>>> master } } } From 14a54936fc2998469117aee33699e4bbdbb3255d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 2 Jan 2025 15:00:15 -0300 Subject: [PATCH 36/51] Working zaps WIP --- lib/main.data.dart | 13 ++-- lib/models/app.dart | 11 ++- lib/models/nostr_adapter.dart | 2 +- lib/models/user.dart | 28 ++++---- lib/models/zap_receipt.dart | 35 +++++++++ lib/models/zap_receipt.g.dart | 71 +++++++++++++++++++ lib/screens/app_detail_screen.dart | 5 +- lib/screens/settings_screen.dart | 2 +- lib/utils/nip55_event_signer.dart | 59 ---------------- lib/utils/signers.dart | 6 +- lib/utils/zap.dart | 110 ----------------------------- lib/widgets/sign_in_container.dart | 1 + lib/widgets/zap_button.dart | 51 +++++++------ lib/widgets/zap_receipts.dart | 110 +++++++++++++++++++++++++++++ lib/widgets/zaps.dart | 102 -------------------------- pubspec.lock | 35 +++++++-- pubspec.yaml | 7 +- 17 files changed, 313 insertions(+), 335 deletions(-) create mode 100644 lib/models/zap_receipt.dart create mode 100644 lib/models/zap_receipt.g.dart delete mode 100644 lib/utils/nip55_event_signer.dart delete mode 100644 lib/utils/zap.dart create mode 100644 lib/widgets/zap_receipts.dart delete mode 100644 lib/widgets/zaps.dart diff --git a/lib/main.data.dart b/lib/main.data.dart index 36d2d28..cfb5041 100644 --- a/lib/main.data.dart +++ b/lib/main.data.dart @@ -3,13 +3,15 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:zapstore/models/app.dart'; + import 'package:zapstore/models/app_curation_set.dart'; +import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/local_app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/models/zap_receipt.dart'; final adapterProvidersMap = >>{ 'appCurationSets': appCurationSetsAdapterProvider, @@ -18,7 +20,8 @@ final adapterProvidersMap = >>{ 'localApps': localAppsAdapterProvider, 'releases': releasesAdapterProvider, 'settings': settingsAdapterProvider, - 'users': usersAdapterProvider + 'users': usersAdapterProvider, + 'zapReceipts': zapReceiptsAdapterProvider }; extension AdapterWidgetRefX on WidgetRef { @@ -34,6 +37,8 @@ extension AdapterWidgetRefX on WidgetRef { Adapter get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; + Adapter get zapReceipts => + watch(zapReceiptsAdapterProvider)..internalWatch = watch; } extension AdapterRefX on Ref { @@ -49,6 +54,6 @@ extension AdapterRefX on Ref { Adapter get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; - + Adapter get zapReceipts => + watch(zapReceiptsAdapterProvider)..internalWatch = watch; } - diff --git a/lib/models/app.dart b/lib/models/app.dart index 502d8a7..a20c90c 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -353,10 +353,15 @@ mixin AppAdapter on Adapter { for (final Map map in list) { final tags = map['tags']; - final pubkey = base.BaseUtil.getTag(tags, 'pubkey'); - map['developer'] = base.BaseUtil.getTag(tags, 'zap') ?? - (pubkey != kZapstorePubkey ? pubkey : null); + final pubkey = map['pubkey']; map['localApp'] = base.BaseUtil.getTag(tags, 'd')!; + // Find zap recipient as specified in event or fall back to author's pubkey + map['developer'] = base.BaseUtil.getTag(tags, 'zap') ?? pubkey; + // If app is signed by Zapstore (except the Zapstore app), remove from being the developer + if (pubkey == kZapstorePubkey && + map['localApp'] != kZapstoreAppIdentifier) { + map.remove('developer'); + } } return super.deserialize(data); diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index 4f8cb9e..8057da3 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -13,7 +13,7 @@ import 'package:zapstore/widgets/latest_releases_container.dart'; // NOTE: Very important to use const in relay args to preserve equality in Riverpod families // const kAppRelays = {'ws://10.0.2.2:3000'}; const kAppRelays = {'wss://relay.zapstore.dev'}; -const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net'}; +const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net', 'ndk'}; mixin NostrAdapter> on Adapter { RelayMessageNotifier get relay => diff --git a/lib/models/user.dart b/lib/models/user.dart index 541709e..15dc2f4 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nwc.dart'; @@ -17,35 +18,39 @@ class User extends base.User with DataModelMixin { @override Object? get id => event.id; - Future zap(int amount, + Future zap(int amountInSats, {required base.Event event, String? comment}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; final authorPubkey = event.event.pubkey; + print( + 'bro wants to zap $amountInSats to $authorPubkey for ${event.event.id}'); final author = adapter.ref.users.findOneLocalById(authorPubkey); if (author?.lud16 == null) { throw 'cant zap'; } + final amountInMillisats = amountInSats * 1000; final partialZapRequest = base.PartialZapRequest(); partialZapRequest ..addLinkedEvent(event) - ..addLinkedUser(this) - ..amount = amount; - final zapRequest = await partialZapRequest - .signWith(base.Bip340PrivateKeySigner(kZapstorePubkey)); - print(zapRequest); + ..addLinkedUser(author!) + ..relays = kSocialRelays.where((r) => r != 'ndk') + ..amount = amountInMillisats + ..comment = comment; + + final zapRequest = + await partialZapRequest.signWith(amberSigner, withPubkey: pubkey); // Now we fetch the invoice - final lnurlResponse = await author!.fetchLnUrl(); + final lnurlResponse = await author.fetchLnUrl(); final recipientAllowsNostr = lnurlResponse['allowsNostr']; final recipientPubkey = lnurlResponse['nostrPubkey']; // All amounts are in millisats final recipientMin = lnurlResponse['minSendable']; final recipientMax = lnurlResponse['maxSendable']; - final amountInMillisats = amount * 1000; if (recipientAllowsNostr != true && recipientPubkey != null) { throw 'cant zap, recipient does not allow nostr'; @@ -69,12 +74,9 @@ class User extends base.User with DataModelMixin { }, ); - print(callbackUri); - final response = await http.get(callbackUri); final invoiceMap = jsonDecode(response.body); - print(invoiceMap); final invoice = invoiceMap['pr']; if (invoice == null) { throw 'cant zap, no invoice'; @@ -86,7 +88,9 @@ class User extends base.User with DataModelMixin { } // Pay the invoice via NWC - adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, invoice: invoice); + final rw = await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, + invoice: invoice, timeout: Duration(seconds: 20)); + print(rw); } Future> fetchLnUrl() async { diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart new file mode 100644 index 0000000..1c6f66d --- /dev/null +++ b/lib/models/zap_receipt.dart @@ -0,0 +1,35 @@ +import 'package:flutter_data/flutter_data.dart'; +import 'package:purplebase/purplebase.dart' as base; +import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/utils/extensions.dart'; + +part 'zap_receipt.g.dart'; + +@DataAdapter([NostrAdapter]) +class ZapReceipt extends base.ZapReceipt with DataModelMixin { + final BelongsTo recipient; + final BelongsTo signer; + + @override + Object? get id => event.id; + + ZapReceipt.fromJson(super.map) + : recipient = belongsTo(map['recipient']), + signer = belongsTo(map['signer']), + super.fromJson(); + + Map toJson() => super.toMap(); +} + +mixin ZapReceiptAdapter on Adapter { + @override + DeserializedData deserialize(Object? data, {String? key}) { + final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + final map = e as Map; + map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p'); + } + return super.deserialize(data); + } +} diff --git a/lib/models/zap_receipt.g.dart b/lib/models/zap_receipt.g.dart new file mode 100644 index 0000000..6f100fa --- /dev/null +++ b/lib/models/zap_receipt.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'zap_receipt.dart'; + +// ************************************************************************** +// AdapterGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, duplicate_ignore + +mixin _$ZapReceiptAdapter on Adapter { + static final Map _kZapReceiptRelationshipMetas = { + 'recipient': RelationshipMeta( + name: 'recipient', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as ZapReceipt).recipient, + ), + 'signer': RelationshipMeta( + name: 'signer', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as ZapReceipt).signer, + ) + }; + + @override + Map get relationshipMetas => + _kZapReceiptRelationshipMetas; + + @override + ZapReceipt deserializeLocal(map, {String? key}) { + map = transformDeserialize(map); + return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key); + } + + @override + Map serializeLocal(model, {bool withRelationships = true}) { + final map = model.toJson(); + return transformSerialize(map, withRelationships: withRelationships); + } +} + +final _zapReceiptsFinders = {}; + +class $ZapReceiptAdapter = Adapter + with _$ZapReceiptAdapter, NostrAdapter; + +final zapReceiptsAdapterProvider = Provider>( + (ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders))); + +extension ZapReceiptAdapterX on Adapter { + NostrAdapter get nostrAdapter => this as NostrAdapter; +} + +extension ZapReceiptRelationshipGraphNodeX + on RelationshipGraphNode { + RelationshipGraphNode get recipient { + final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient'] + as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } + + RelationshipGraphNode get signer { + final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer'] + as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } +} diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index cfe846c..bc4578d 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -19,7 +19,7 @@ import 'package:zapstore/widgets/spinning_logo.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; import 'package:zapstore/widgets/wot_container.dart'; import 'package:zapstore/widgets/zap_button.dart'; -import 'package:zapstore/widgets/zaps.dart'; +import 'package:zapstore/widgets/zap_receipts.dart'; class AppDetailScreen extends HookConsumerWidget { final App model; @@ -157,7 +157,8 @@ class AppDetailScreen extends HookConsumerWidget { child: SignerAndDeveloperRow(app: app), ), Gap(20), - Zaps(app: app), + if (app.latestMetadata != null) + ZapReceipts(fileMetadata: app.latestMetadata!), Gap(20), ZapButton(app: app), Gap(20), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5de3f60..9577e54 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -81,7 +81,7 @@ class SettingsScreen extends HookConsumerWidget { PartialDirectMessage( content: feedbackController.text.trim(), receiver: kZapstorePubkey.npub), - asUser: user.pubkey); + withPubkey: user.pubkey); try { final response = await http.post( Uri.parse('https://relay.zapstore.dev/'), diff --git a/lib/utils/nip55_event_signer.dart b/lib/utils/nip55_event_signer.dart deleted file mode 100644 index 140e586..0000000 --- a/lib/utils/nip55_event_signer.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:convert'; - -import 'package:ndk/ndk.dart'; -import 'package:ndk/shared/nips/nip19/nip19.dart'; -import 'package:signer_plugin/signer_plugin.dart'; - -class Nip55EventSigner implements EventSigner { - final _signerPlugin = SignerPlugin(); - bool isAvailable = false; - - final String publicKey; - - /// get a amber event signer - Nip55EventSigner({ - required this.publicKey, - }); - - @override - Future sign(Nip01Event event) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - final signedMessage = - await _signerPlugin.signEvent(jsonEncode(event.toJson()), "", npub); - final signedEvent = jsonDecode(signedMessage['event']); - - event.sig = signedEvent['sig']; - } - - @override - String getPublicKey() { - return publicKey; - } - - @override - Future decrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - Future encrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - bool canSign() { - return publicKey.isNotEmpty; - } -} diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart index dd1afb6..e7e6cc1 100644 --- a/lib/utils/signers.dart +++ b/lib/utils/signers.dart @@ -27,7 +27,7 @@ class AmberSigner extends Signer { @override Future sign>(PartialEvent partialEvent, - {String? asUser}) async { + {String? withPubkey}) async { if (!isAvailable) { throw Exception("Cannot sign, missing Amber"); } @@ -36,7 +36,7 @@ class AmberSigner extends Signer { final signedMessage = await _signerPlugin.nip04Encrypt( partialEvent.event.content, "", - asUser!.npub, + withPubkey!.npub, (partialEvent as DirectMessage).receiver.hexKey); final encryptedContent = signedMessage['result']; partialEvent.event.content = encryptedContent; @@ -48,7 +48,7 @@ class AmberSigner extends Signer { if (e.value != null) e.key: e.value }; final signedMessage = - await _signerPlugin.signEvent(jsonEncode(map), "", asUser!); + await _signerPlugin.signEvent(jsonEncode(map), "", withPubkey!); final signedEvent = jsonDecode(signedMessage['event']); return Event.getConstructor()!.call(signedEvent); } diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart deleted file mode 100644 index 0745f8c..0000000 --- a/lib/utils/zap.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_data/flutter_data.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; -import 'package:purplebase/purplebase.dart' hide FileMetadata, User; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/utils/nip55_event_signer.dart'; -import 'package:zapstore/utils/nwc.dart'; - -import '../models/user.dart'; - -class ZapReceiptsNotifier extends StateNotifier>?> { - Ref ref; - - ZapReceiptsNotifier(this.ref) : super(null); - - StreamSubscription? sub; - - Future fetchZaps(FileMetadata fileMetadata) async { - state = null; - final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); - if (socialRelays.ndk != null) { - state = AsyncValue.loading(); - - final receiptsResponse = socialRelays.ndk!.zaps.fetchZappedReceipts( - pubKey: fileMetadata.event.pubkey, - eventId: fileMetadata.id!.toString()); - sub = receiptsResponse.listen((receipt) { - addZapReceipt(receipt); - }); - } - } - - void addZapReceipt(ZapReceipt receipt) { - if (state == null || state!.value == null) { - state = AsyncValue.data([receipt]); - } else { - final receipts = state!.value!; - receipts.add(receipt); - state = AsyncValue.data(receipts); - } - } - - @override - void dispose() { - sub?.cancel(); - super.dispose(); - } -} - -class ZapNotifier extends StateNotifier> { - Ref ref; - - ZapNotifier(this.ref) : super(AsyncData(null)); - - Future zap( - {required User user, - String? eventId, - required String lnurl, - required int amount, - required String pubKey}) async { - final nwcConnectionState = ref.read(nwcConnectionProvider.notifier).state; - if (nwcConnectionState.value == null) { - return; - } - state = AsyncValue.loading(); - final signer = Nip55EventSigner(publicKey: user.pubkey); - final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); - final relays = socialRelays.ndk != null - ? socialRelays.ndk!.config.bootstrapRelays - : kSocialRelays.where((r) => r != 'ndk'); - try { - final zapResponse = await ndkForNwc.zaps.zap( - nwcConnection: nwcConnectionState.value!, - lnurl: lnurl, - amountSats: amount, - fetchZapReceipt: true, - signer: signer, - relays: relays, - pubKey: pubKey, - eventId: eventId, - ); - if (zapResponse.payInvoiceResponse != null && - zapResponse.payInvoiceResponse!.preimage.isNotEmpty) { - state = AsyncValue.data(zapResponse); - final zapReceipt = await zapResponse.zapReceipt; - if (zapReceipt != null) { - ref.read(zapReceiptsNotifier.notifier).addZapReceipt(zapReceipt); - } - } else { - state = AsyncValue.error( - zapResponse.payInvoiceResponse?.errorMessage ?? - "couldn't pay, unknown error", - StackTrace.current); - } - } catch (e, stack) { - state = AsyncValue.error(e, stack); - } - } -} - -final zapProvider = - StateNotifierProvider>( - ZapNotifier.new); - -final zapReceiptsNotifier = - StateNotifierProvider>?>( - ZapReceiptsNotifier.new); diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index c68a6bf..554254b 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -141,6 +141,7 @@ class SignInContainer extends HookConsumerWidget { // local user to represent this new npub user ??= User.fromJson({ 'id': signedInNpub.hexKey, + 'kind': 0, 'pubkey': signedInNpub.hexKey, 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 9eba80f..8217f0c 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,6 +1,5 @@ import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; @@ -8,7 +7,6 @@ import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/zap.dart'; import '../utils/nwc.dart'; @@ -23,7 +21,7 @@ class ZapButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nwcConnection = ref.watch(nwcConnectionProvider); - final zapStatus = ref.watch(zapProvider); + // final zapStatus = ref.watch(zapProvider); final user = ref.settings .watchOne('_', alsoWatch: (_) => {_.user}) .model! @@ -33,29 +31,29 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); // We use value as default text, for progress only interested in loading/error substates - final zapButtonText = switch (zapStatus) { - AsyncLoading() => 'Zapping... ⚡⚡⚡', - AsyncError() => 'Error zapping!', - AsyncValue() => 'Zap the dev ⚡', - }; + // final zapButtonText = switch (zapStatus) { + // AsyncLoading() => 'Zapping... ⚡⚡⚡', + // AsyncError() => 'Error zapping!', + // AsyncValue() => 'Zap the dev ⚡', + // }; // Show toast if could not zap - useMemoized(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (zapStatus.value != null) { - // final receipt = await zapStatus.value!.zapReceipt; - // TODO: zapStatus should include the amount of sats without needing async - // I could read zapReceiptsNotifier's state, but how I know it's this one? - context.showInfo('Zapped!', - description: 'You just sent the dev some sats'); - } - if (zapStatus.hasError) { - context.showError( - title: 'Could not zap', - description: 'Error sending zap: ${zapStatus.error}'); - } - }); - }, [zapStatus.value, zapStatus.error]); + // useMemoized(() { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // if (zapStatus.value != null) { + // // final receipt = await zapStatus.value!.zapReceipt; + // // TODO: zapStatus should include the amount of sats without needing async + // // I could read zapReceiptsNotifier's state, but how I know it's this one? + // context.showInfo('Zapped!', + // description: 'You just sent the dev some sats'); + // } + // if (zapStatus.hasError) { + // context.showError( + // title: 'Could not zap', + // description: 'Error sending zap: ${zapStatus.error}'); + // } + // }); + // }, [zapStatus.value, zapStatus.error]); return app.developer.value != null ? AsyncButtonBuilder( @@ -149,7 +147,8 @@ class ZapButton extends HookConsumerWidget { if (amount > 0) { // User should be able to sign any event // Proposed API: - await user.zap(amount, event: app.latestMetadata!); + await user.zap(amount, + event: app.latestMetadata!, comment: 'From zapstore!'); // final lnurl = developer.lud16!; // await ref.read(zapProvider.notifier).zap( @@ -163,7 +162,7 @@ class ZapButton extends HookConsumerWidget { builder: (context, child, callback, state) { return ElevatedButton(onPressed: callback, child: child); }, - child: Text(zapButtonText), + child: Text('Zap!'), ) : Container(); } diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart new file mode 100644 index 0000000..0e7f487 --- /dev/null +++ b/lib/widgets/zap_receipts.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:purplebase/purplebase.dart' hide FileMetadata; +import 'package:zapstore/main.data.dart'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/models/file_metadata.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class ZapReceipts extends HookConsumerWidget { + ZapReceipts({ + super.key, + required this.fileMetadata, + }); + + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata)); + + if (zapReceipts.isLoading) { + return CircularProgressIndicator(); + } + + if (zapReceipts.value!.isEmpty) { + // Do not show anything if there are no zaps + return Container(); + } + + final receipts = zapReceipts.value!; + final totalAmountInSats = receipts.fold(0, (acc, e) => acc + e.amount); + + final senderIds = receipts.map((r) => r.senderPubkey).toSet(); + final senders = ref.users.findManyLocalByIds(senderIds); + + return Row( + children: [ + Text("⚡ $totalAmountInSats sats (${receipts.length} zaps)"), + Gap(5), + RichText( + text: TextSpan( + children: [ + for (final user in senders) + TextSpan( + style: TextStyle(height: 1.6), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + RoundedImage(url: user.avatarUrl, size: 20), + ], + ), + ), + ], + ) + ], + ), + ) + ], + ); + } +} + +class ZapReceiptsNotifier extends StateNotifier>> { + Ref ref; + FileMetadata fileMetadata; + StreamSubscription? sub; + + ZapReceiptsNotifier(this.ref, this.fileMetadata) + : super(AsyncData(ref.zapReceipts + // TODO: Perform an actual query + .findAllLocal() + .where((r) => + r.recipient.id == fileMetadata.author.id && + r.eventId == fileMetadata.id) + .toSet())) { + final adapter = ref.users.nostrAdapter; + + final recipient = + fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey; + print('querying for $recipient and id ${fileMetadata.event.id}'); + final receiptsResponse = adapter.socialRelays.ndk!.zaps + .subscribeToZapReceipts( + pubKey: recipient, eventId: fileMetadata.event.id); + + sub = receiptsResponse.stream.listen((data) { + state = AsyncData({ + if (state.hasValue) ...state.value!, + Event.getConstructor()!.call(data.toJson()) + }); + }); + } + + @override + void dispose() { + sub?.cancel(); + super.dispose(); + } +} + +final zapReceiptsNotifier = StateNotifierProvider.autoDispose + .family>, FileMetadata>( + ZapReceiptsNotifier.new); diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart deleted file mode 100644 index 5f6d12d..0000000 --- a/lib/widgets/zaps.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/zap.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class Zaps extends HookConsumerWidget { - Zaps({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final zapReceipts = ref.watch(zapReceiptsNotifier); - - if (app.developer.value?.lud16 != null && app.latestMetadata != null) { - if (zapReceipts == null) { - Future(() { - ref.read(zapReceiptsNotifier.notifier).fetchZaps(app.latestMetadata!); - }); - } else { - final receipts = zapReceipts.value; - if (receipts != null) { - receipts - .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); - - var eventSum = 0; - for (var receipt in receipts) { - eventSum += receipt.amountSats ?? 0; - } - final senderIds = - receipts.map((receipt) => receipt.sender).nonNulls.toSet(); - - final senders = - ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); - - final zapperAvatars = switch (senders) { - AsyncData>(value: final users) => Builder( - builder: (context) { - return RichText( - text: TextSpan(children: [ - for (final user in users) - TextSpan( - style: TextStyle(height: 1.6), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - RoundedImage(url: user.avatarUrl, size: 20), - ], - ), - ), - ], - ) - ])); - }, - ), - AsyncError(:final error) => - Center(child: Text('Error loading zapper profiles: $error')), - // Loading state - _ => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), - ], - ), - ), - ) - }; - return Row( - children: [ - Text("⚡ $eventSum sats (${receipts.length} zaps)"), - Gap(5), - zapperAvatars - ], - ); - } - } - } - return Container(); - } -} - -final zappersProvider = FutureProvider.autoDispose - .family, ({Iterable zapperIds})>((ref, arg) { - // TODO it should load from either local or remote if not cached locally still - return ref.users.findManyLocalByIds(arg.zapperIds); -}); diff --git a/pubspec.lock b/pubspec.lock index 0b4007a..e727d67 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + url: "https://pub.dev" + source: hosted + version: "2.7.0" cupertino_icons: dependency: "direct main" description: @@ -334,6 +342,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + elliptic: + dependency: transitive + description: + name: elliptic + sha256: "0c303d810603953a65dc39c4c542fb7538defd9e212403c54c266140819523b6" + url: "https://pub.dev" + source: hosted + version: "0.3.11" equatable: dependency: transitive description: @@ -718,6 +734,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: @@ -785,12 +809,11 @@ packages: ndk: dependency: "direct main" description: - path: "packages/ndk" - ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 - resolved-ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 - url: "https://github.com/relaystr/ndk" - source: git - version: "0.2.0-dev005" + name: ndk + sha256: e17cb3aad2936c62978891e0d3db4dc37f3ded7efbdf89e571c3927158da9135 + url: "https://pub.dev" + source: hosted + version: "0.2.2" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4b68a89..43bec19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,14 +53,9 @@ dependencies: mutex: ^3.1.0 easy_image_viewer: ^1.5.1 flutter_secure_storage: ^9.2.2 - ndk: ^0.2.0-dev005 + ndk: ^0.2.2 dependency_overrides: - ndk: - git: - url: https://github.com/relaystr/ndk - path: packages/ndk - ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 #lnurl-zaps purplebase: path: ../purplebase # git: From 5533b38ad176e1983e268b8cfd930fc9eaf41296 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 2 Jan 2025 16:11:44 -0300 Subject: [PATCH 37/51] Fix findByRecipient --- lib/models/zap_receipt.dart | 12 +++++++++++- lib/models/zap_receipt.g.dart | 3 ++- lib/widgets/zap_receipts.dart | 19 ++++++++----------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart index 1c6f66d..0a51cb0 100644 --- a/lib/models/zap_receipt.dart +++ b/lib/models/zap_receipt.dart @@ -6,7 +6,7 @@ import 'package:zapstore/utils/extensions.dart'; part 'zap_receipt.g.dart'; -@DataAdapter([NostrAdapter]) +@DataAdapter([NostrAdapter, ZapReceiptAdapter]) class ZapReceipt extends base.ZapReceipt with DataModelMixin { final BelongsTo recipient; final BelongsTo signer; @@ -32,4 +32,14 @@ mixin ZapReceiptAdapter on Adapter { } return super.deserialize(data); } + + List findByRecipient( + {required String pubkey, required String eventId}) { + // NOTE: This query is technically incorrect using IN, but the probability of clashing p/e IDs is extremely low + // This will be fixed with the new purplebase + final result = db.select( + "SELECT z.key, data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') in ('p', 'e') and json_extract(value, '\$[1]') in (?, ?)", + [pubkey, eventId]); + return deserializeFromResult(result); + } } diff --git a/lib/models/zap_receipt.g.dart b/lib/models/zap_receipt.g.dart index 6f100fa..a106043 100644 --- a/lib/models/zap_receipt.g.dart +++ b/lib/models/zap_receipt.g.dart @@ -44,13 +44,14 @@ mixin _$ZapReceiptAdapter on Adapter { final _zapReceiptsFinders = {}; class $ZapReceiptAdapter = Adapter - with _$ZapReceiptAdapter, NostrAdapter; + with _$ZapReceiptAdapter, NostrAdapter, ZapReceiptAdapter; final zapReceiptsAdapterProvider = Provider>( (ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders))); extension ZapReceiptAdapterX on Adapter { NostrAdapter get nostrAdapter => this as NostrAdapter; + ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter; } extension ZapReceiptRelationshipGraphNodeX diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 0e7f487..5728092 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -1,7 +1,7 @@ import 'dart:async'; +import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/domain_layer/entities/nip_01_event.dart'; -import 'package:purplebase/purplebase.dart' hide FileMetadata; import 'package:zapstore/main.data.dart'; import 'package:flutter/material.dart'; @@ -9,6 +9,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/models/zap_receipt.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class ZapReceipts extends HookConsumerWidget { @@ -74,12 +75,10 @@ class ZapReceiptsNotifier extends StateNotifier>> { StreamSubscription? sub; ZapReceiptsNotifier(this.ref, this.fileMetadata) - : super(AsyncData(ref.zapReceipts - // TODO: Perform an actual query - .findAllLocal() - .where((r) => - r.recipient.id == fileMetadata.author.id && - r.eventId == fileMetadata.id) + : super(AsyncData(ref.zapReceipts.zapReceiptAdapter + .findByRecipient( + pubkey: fileMetadata.author.id!.toString(), + eventId: fileMetadata.id!.toString()) .toSet())) { final adapter = ref.users.nostrAdapter; @@ -91,10 +90,8 @@ class ZapReceiptsNotifier extends StateNotifier>> { pubKey: recipient, eventId: fileMetadata.event.id); sub = receiptsResponse.stream.listen((data) { - state = AsyncData({ - if (state.hasValue) ...state.value!, - Event.getConstructor()!.call(data.toJson()) - }); + final model = ZapReceipt.fromJson(data.toJson()).init().saveLocal(); + state = AsyncData({if (state.hasValue) ...state.value!, model}); }); } From b51507200d6f519e65df5bc5e404f878d9ea4cd3 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 2 Jan 2025 21:35:40 -0300 Subject: [PATCH 38/51] Zap UX improvements --- lib/models/user.dart | 46 +++++---- lib/screens/app_detail_screen.dart | 2 +- lib/screens/settings_screen.dart | 145 ++++++++++++++++------------- lib/utils/nwc.dart | 22 ++--- lib/widgets/sign_in_container.dart | 133 ++++++++++++++------------ lib/widgets/users_rich_text.dart | 53 +++++++++++ lib/widgets/wot_container.dart | 52 +---------- lib/widgets/zap_button.dart | 102 ++++++++------------ lib/widgets/zap_receipts.dart | 33 +------ pubspec.lock | 4 +- pubspec.yaml | 2 +- 11 files changed, 286 insertions(+), 308 deletions(-) create mode 100644 lib/widgets/users_rich_text.dart diff --git a/lib/models/user.dart b/lib/models/user.dart index 15dc2f4..6266cf5 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -22,13 +22,17 @@ class User extends base.User with DataModelMixin { {required base.Event event, String? comment}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; + // First ensure connection is available + final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value; + if (nwcConnection == null) { + throw 'No NWC connection'; + } + final authorPubkey = event.event.pubkey; - print( - 'bro wants to zap $amountInSats to $authorPubkey for ${event.event.id}'); final author = adapter.ref.users.findOneLocalById(authorPubkey); if (author?.lud16 == null) { - throw 'cant zap'; + throw 'Recipient has no Lightning address'; } final amountInMillisats = amountInSats * 1000; @@ -44,27 +48,27 @@ class User extends base.User with DataModelMixin { await partialZapRequest.signWith(amberSigner, withPubkey: pubkey); // Now we fetch the invoice - final lnurlResponse = await author.fetchLnUrl(); + final lnResponse = await author.fetchLightningAddress(); - final recipientAllowsNostr = lnurlResponse['allowsNostr']; - final recipientPubkey = lnurlResponse['nostrPubkey']; + final recipientAllowsNostr = lnResponse['allowsNostr']; + final recipientPubkey = lnResponse['nostrPubkey']; // All amounts are in millisats - final recipientMin = lnurlResponse['minSendable']; - final recipientMax = lnurlResponse['maxSendable']; + final recipientMin = lnResponse['minSendable']; + final recipientMax = lnResponse['maxSendable']; if (recipientAllowsNostr != true && recipientPubkey != null) { - throw 'cant zap, recipient does not allow nostr'; + throw 'Recipient does not allow nostr'; } if ((recipientMin != null && amountInMillisats < recipientMin) || (recipientMax != null && amountInMillisats > recipientMax)) { - throw 'cant zap, amount not between min and max sendable'; + throw 'Amount not between min and max sendable'; } - final commentLength = lnurlResponse['commentAllowed']; + final commentLength = lnResponse['commentAllowed']; comment = commentLength != null ? comment?.substringMax(commentLength) : comment; - var callbackUri = Uri.parse(lnurlResponse['callback']!); + var callbackUri = Uri.parse(lnResponse['callback']!); callbackUri = callbackUri.replace( queryParameters: { ...callbackUri.queryParameters, @@ -74,26 +78,20 @@ class User extends base.User with DataModelMixin { }, ); - final response = await http.get(callbackUri); - final invoiceMap = jsonDecode(response.body); + final invoiceResponse = await http.get(callbackUri); + final invoiceMap = jsonDecode(invoiceResponse.body); final invoice = invoiceMap['pr']; if (invoice == null) { - throw 'cant zap, no invoice'; + throw 'No invoice'; } - final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value; - if (nwcConnection == null) { - throw 'cant zap, no nwc connection'; - } - - // Pay the invoice via NWC - final rw = await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, + // Pay the invoice via NWC (errors are thrown) + await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, invoice: invoice, timeout: Duration(seconds: 20)); - print(rw); } - Future> fetchLnUrl() async { + Future> fetchLightningAddress() async { final [userName, domainName] = lud16!.split('@'); final lnurl = 'https://$domainName/.well-known/lnurlp/$userName'; final response = await http.get(Uri.parse(lnurl)); diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index bc4578d..978d0fb 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -16,8 +16,8 @@ import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/release_card.dart'; import 'package:zapstore/widgets/signer_and_developer_row.dart'; import 'package:zapstore/widgets/spinning_logo.dart'; +import 'package:zapstore/widgets/users_rich_text.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; -import 'package:zapstore/widgets/wot_container.dart'; import 'package:zapstore/widgets/zap_button.dart'; import 'package:zapstore/widgets/zap_receipts.dart'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9577e54..1c25356 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,6 +10,7 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; import 'package:path_provider/path_provider.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.data.dart'; @@ -29,15 +30,11 @@ class SettingsScreen extends HookConsumerWidget { final systemInfoState = ref.watch(systemInfoNotifierProvider); final feedbackController = useTextEditingController(); - final nwcController = useTextEditingController(); final user = ref.settings .watchOne('_', alsoWatch: (_) => {_.user}) .model! .user .value; - final isTextFieldEmpty = useState(true); - - final nwcConnection = ref.watch(nwcConnectionProvider); return SingleChildScrollView( child: Column( @@ -116,67 +113,7 @@ class SettingsScreen extends HookConsumerWidget { Gap(40), Divider(), Gap(40), - if (user != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(20), - Text( - "Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"), - if (nwcConnection.value != null) - ElevatedButton( - onPressed: () async { - await ref - .read(nwcConnectionProvider.notifier) - .disconnectWallet(); - }, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: Text('Disconnect'), - ), - if (nwcConnection.value == null) - TextField( - autocorrect: false, - controller: nwcController, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - decoration: InputDecoration( - hintText: 'nostr+walletconnect://...', - suffixIcon: AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), - onPressed: () async { - final nwcUri = nwcController.text.trim(); - await ref - .read(nwcConnectionProvider.notifier) - .connectWallet(nwcUri); - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), - ), - ); - }, - child: Text('Connect'), - ), - ), - ), - ], - ), + if (user != null) NostrWalletConnectContainer(), Gap(40), Divider(), Gap(40), @@ -280,3 +217,81 @@ class SettingsScreen extends HookConsumerWidget { ); } } + +class NostrWalletConnectContainer extends HookConsumerWidget { + final bool dialogMode; + const NostrWalletConnectContainer({ + super.key, + this.dialogMode = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nwcConnection = ref.watch(nwcConnectionProvider); + final controller = useTextEditingController(text: ''); + final isTextFieldEmpty = useState(true); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Gap(20), + Text( + "Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"), + if (nwcConnection.value != null && !dialogMode) + ElevatedButton( + onPressed: () async { + await ref.read(nwcConnectionProvider.notifier).disconnectWallet(); + }, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: Text('Disconnect'), + ), + if (nwcConnection.value == null) + TextField( + autocorrect: false, + controller: controller, + onChanged: (value) { + isTextFieldEmpty.value = value.isEmpty; + }, + decoration: InputDecoration( + hintText: 'nostr+walletconnect://...', + suffixIcon: AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + onPressed: () async { + final nwcUri = controller.text.trim(); + await ref + .read(nwcConnectionProvider.notifier) + .connectWallet(nwcUri); + if (dialogMode && context.mounted) { + Navigator.of(context).pop(); + } + }, + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), + ), + ); + }, + child: Text('Connect'), + ), + ), + ), + ], + ); + } +} diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 53741dd..39637d8 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -2,15 +2,21 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:ndk/ndk.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/user.dart'; const kNwcSecretKey = 'nwc_secret'; class NwcConnectionNotifier extends StateNotifier> { + Ref ref; + late Ndk ndk; + final _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - NwcConnectionNotifier() : super(AsyncData(null)) { + NwcConnectionNotifier(this.ref) : super(AsyncLoading()) { + ndk = ref.users.nostrAdapter.socialRelays.ndk!; // Attempt to load from local storage on initialization _storage .read(key: kNwcSecretKey) @@ -32,13 +38,13 @@ class NwcConnectionNotifier extends StateNotifier> { // If secret is supplied and a connection is still active, // it means it is a new connection string so disconnect NWC if (state.value != null) { - await ndkForNwc.nwc.disconnect(state.value!); + await ndk.nwc.disconnect(state.value!); } // Write secret to storage and connect await _storage.write(key: kNwcSecretKey, value: nwcSecret); - final connection = await ndkForNwc.nwc.connect( + final connection = await ndk.nwc.connect( nwcSecret, doGetInfoMethod: false, onError: (error) { @@ -61,12 +67,4 @@ class NwcConnectionNotifier extends StateNotifier> { final nwcConnectionProvider = StateNotifierProvider>( - (ref) => NwcConnectionNotifier()); - -final ndkForNwc = Ndk( - NdkConfig( - cache: MemCacheManager(), - eventVerifier: Bip340EventVerifier(), - bootstrapRelays: [], - logLevel: LogLevels().info), -); + (ref) => NwcConnectionNotifier(ref)); diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 554254b..ef90a43 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -67,7 +67,7 @@ class SignInButton extends ConsumerWidget { label, style: TextStyle(fontWeight: FontWeight.bold), ), - content: SignInContainer(), + content: SignInDialogBox(), ), ); } else { @@ -81,8 +81,9 @@ class SignInButton extends ConsumerWidget { } } -class SignInContainer extends HookConsumerWidget { - const SignInContainer({super.key}); +class SignInDialogBox extends HookConsumerWidget { + final bool publicKeyAllowed; + const SignInDialogBox({super.key, this.publicKeyAllowed = true}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -155,73 +156,81 @@ class SignInContainer extends HookConsumerWidget { settings.saveLocal(); if (context.mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(user); } }, child: Text('Sign in with Amber'), ), - Gap(10), - Text('or'), - Gap(10), ], ), - Text('Input an npub or nostr address\n(read-only):'), - TextField( - autocorrect: false, - controller: controller, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - ), - Gap(10), - AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), + if (amberSigner.isAvailable && publicKeyAllowed) + Padding( + padding: const EdgeInsets.all(10), + child: Text('or'), ), - onPressed: () async { - try { - final input = controller.text.trim(); - if (input.startsWith('nsec')) { - controller.clear(); - throw Exception('Never give away your nsec!'); - } - final user = await ref.users.findOne(input); - - final settings = ref.settings.findOneLocalById('_')!; - settings.signInMethod = SignInMethod.pubkey; - settings.user.value = user; - settings.saveLocal(); - - if (context.mounted) { - Navigator.pop(context); - } - } on Exception catch (e, stack) { - if (context.mounted) { - context.showError( - title: e.toString(), - description: stack.toString().substringMax(200)); - } - } - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), + if (publicKeyAllowed) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Input an npub or nostr address\n(read-only):'), + TextField( + autocorrect: false, + controller: controller, + onChanged: (value) { + isTextFieldEmpty.value = value.isEmpty; + }, ), - ); - }, - child: Text('Sign in with public key'), - ) + Gap(10), + AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), + onPressed: () async { + try { + final input = controller.text.trim(); + if (input.startsWith('nsec')) { + controller.clear(); + throw Exception('Never give away your nsec!'); + } + final user = await ref.users.findOne(input); + + final settings = ref.settings.findOneLocalById('_')!; + settings.signInMethod = SignInMethod.pubkey; + settings.user.value = user; + settings.saveLocal(); + + if (context.mounted) { + Navigator.pop(context); + } + } on Exception catch (e, stack) { + if (context.mounted) { + context.showError( + title: e.toString(), + description: stack.toString().substringMax(200)); + } + } + }, + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), + ), + ); + }, + child: Text('Sign in with public key'), + ), + ], + ), ], ), ], diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart new file mode 100644 index 0000000..2f0e5b6 --- /dev/null +++ b/lib/widgets/users_rich_text.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class UsersRichText extends StatelessWidget { + const UsersRichText({ + super.key, + this.preSpan, + this.trailingText, + required this.users, + }); + + final TextSpan? preSpan; + final String? trailingText; + final List users; + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + children: [ + if (preSpan != null) preSpan!, + for (final user in users) + TextSpan( + style: TextStyle(height: 1.6), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + RoundedImage(url: user.avatarUrl, size: 20), + Text( + ' ${user.nameOrNpub}', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(users.indexOf(user) == users.length - 1 + ? '' + : (users.indexOf(user) == users.length - 2 + ? ' and ' + : ', ')), + ], + ), + ), + if (users.indexOf(user) == users.length - 1) + TextSpan(text: trailingText, style: TextStyle(fontSize: 15)) + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index 2e373ca..bd0e737 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -6,7 +6,7 @@ import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; +import 'package:zapstore/widgets/users_rich_text.dart'; class WebOfTrustContainer extends HookConsumerWidget { const WebOfTrustContainer({ @@ -68,56 +68,6 @@ class WebOfTrustContainer extends HookConsumerWidget { } } -class UsersRichText extends StatelessWidget { - const UsersRichText({ - super.key, - this.preSpan, - this.trailingText, - required this.users, - }); - - final TextSpan? preSpan; - final String? trailingText; - final List users; - - @override - Widget build(BuildContext context) { - return RichText( - text: TextSpan( - children: [ - if (preSpan != null) preSpan!, - for (final user in users) - TextSpan( - style: TextStyle(height: 1.6), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - RoundedImage(url: user.avatarUrl, size: 20), - Text( - ' ${user.nameOrNpub}', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text(users.indexOf(user) == users.length - 1 - ? '' - : (users.indexOf(user) == users.length - 2 - ? ' and ' - : ', ')), - ], - ), - ), - if (users.indexOf(user) == users.length - 1) - TextSpan(text: trailingText, style: TextStyle(fontSize: 15)) - ], - ), - ], - ), - ); - } -} - final followsWhoFollowProvider = FutureProvider.autoDispose .family, ({String from, String to})>((ref, arg) async { final _ = diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 8217f0c..f5f7067 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,12 +1,12 @@ -import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/navigation/router.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; import '../utils/nwc.dart'; @@ -21,7 +21,6 @@ class ZapButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nwcConnection = ref.watch(nwcConnectionProvider); - // final zapStatus = ref.watch(zapProvider); final user = ref.settings .watchOne('_', alsoWatch: (_) => {_.user}) .model! @@ -30,61 +29,45 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); - // We use value as default text, for progress only interested in loading/error substates - // final zapButtonText = switch (zapStatus) { - // AsyncLoading() => 'Zapping... ⚡⚡⚡', - // AsyncError() => 'Error zapping!', - // AsyncValue() => 'Zap the dev ⚡', - // }; - - // Show toast if could not zap - // useMemoized(() { - // WidgetsBinding.instance.addPostFrameCallback((_) { - // if (zapStatus.value != null) { - // // final receipt = await zapStatus.value!.zapReceipt; - // // TODO: zapStatus should include the amount of sats without needing async - // // I could read zapReceiptsNotifier's state, but how I know it's this one? - // context.showInfo('Zapped!', - // description: 'You just sent the dev some sats'); - // } - // if (zapStatus.hasError) { - // context.showError( - // title: 'Could not zap', - // description: 'Error sending zap: ${zapStatus.error}'); - // } - // }); - // }, [zapStatus.value, zapStatus.error]); - - return app.developer.value != null - ? AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, height: 14, child: CircularProgressIndicator()), + return app.developer.value?.lud16 != null + ? ElevatedButton( onPressed: () async { - if (user == null) { - context.showError( - title: 'Not signed in', - description: 'Please sign in to zap'); + final loggedInUser = user ?? + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text( + 'Sign in to zap', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SignInDialogBox(publicKeyAllowed: false), + ), + ); + + if (loggedInUser == null) { return; } if (nwcConnection.value == null) { - context.showError( - title: 'No wallet connected', - description: 'Please connect your NWC wallet'); - appRouter.go('/settings'); - return; + await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (_) => AlertDialog( + title: Text( + 'Connect your wallet to zap', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: NostrWalletConnectContainer(dialogMode: true), + ), + ); } - final developer = app.developer.value!; - if (developer.lud16 == null) { - context.showError( - title: 'Unable to zap', - description: - 'The developer (${developer.nameOrNpub}) does not have a Lightning address'); + if (ref.read(nwcConnectionProvider).value == null) { return; } final amount = await showDialog( + // ignore: use_build_context_synchronously context: context, builder: (context) { return AlertDialog( @@ -145,23 +128,18 @@ class ZapButton extends HookConsumerWidget { 0; if (amount > 0) { - // User should be able to sign any event - // Proposed API: - await user.zap(amount, - event: app.latestMetadata!, comment: 'From zapstore!'); - - // final lnurl = developer.lud16!; - // await ref.read(zapProvider.notifier).zap( - // user: user, - // lnurl: lnurl, - // eventId: app.latestMetadata!.id.toString(), - // amount: amount, - // pubKey: developer.pubkey); + try { + // ignore: use_build_context_synchronously + context.showInfo('Zap sent!', + description: '$amount sats on their way'); + await loggedInUser.zap(amount, event: app.latestMetadata!); + } catch (e) { + // ignore: use_build_context_synchronously + context.showError( + title: 'Unable to zap', description: e.toString()); + } } }, - builder: (context, child, callback, state) { - return ElevatedButton(onPressed: callback, child: child); - }, child: Text('Zap!'), ) : Container(); diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 5728092..7f4b5ad 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -5,12 +5,11 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:zapstore/main.data.dart'; import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/zap_receipt.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; +import 'package:zapstore/widgets/users_rich_text.dart'; class ZapReceipts extends HookConsumerWidget { ZapReceipts({ @@ -39,32 +38,10 @@ class ZapReceipts extends HookConsumerWidget { final senderIds = receipts.map((r) => r.senderPubkey).toSet(); final senders = ref.users.findManyLocalByIds(senderIds); - return Row( - children: [ - Text("⚡ $totalAmountInSats sats (${receipts.length} zaps)"), - Gap(5), - RichText( - text: TextSpan( - children: [ - for (final user in senders) - TextSpan( - style: TextStyle(height: 1.6), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - RoundedImage(url: user.avatarUrl, size: 20), - ], - ), - ), - ], - ) - ], - ), - ) - ], + return UsersRichText( + preSpan: TextSpan(text: '⚡ $totalAmountInSats sats by'), + trailingText: ' and others', + users: senders, ); } } diff --git a/pubspec.lock b/pubspec.lock index e727d67..47c6921 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -810,10 +810,10 @@ packages: dependency: "direct main" description: name: ndk - sha256: e17cb3aad2936c62978891e0d3db4dc37f3ded7efbdf89e571c3927158da9135 + sha256: "07c284a14e7800513743a84c911a546dcfcdc0c0ce2def4895c4e4c2286f159e" url: "https://pub.dev" source: hosted - version: "0.2.2" + version: "0.2.4" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 43bec19..56e6524 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: mutex: ^3.1.0 easy_image_viewer: ^1.5.1 flutter_secure_storage: ^9.2.2 - ndk: ^0.2.2 + ndk: ^0.2.4 dependency_overrides: purplebase: From 7dad1b0bb9682ee8e047a6732332616bab26783a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 17:46:09 -0300 Subject: [PATCH 39/51] Fix NWC secret management, zap anonymously --- lib/models/file_metadata.dart | 8 --- lib/models/user.dart | 6 +- lib/navigation/app_initializer.dart | 14 ++++ lib/screens/app_detail_screen.dart | 8 +-- lib/screens/settings_screen.dart | 64 ++++++++++++------ lib/utils/extensions.dart | 6 ++ lib/utils/nwc.dart | 24 +++---- lib/widgets/latest_releases_container.dart | 7 +- lib/widgets/sign_in_container.dart | 27 ++++---- lib/widgets/wot_container.dart | 7 +- lib/widgets/zap_button.dart | 74 +++++++++++++-------- lib/widgets/zap_receipts.dart | 77 ++++++++++++++++------ 12 files changed, 204 insertions(+), 118 deletions(-) diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index 2879562..6e1d9ed 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -16,14 +16,6 @@ class FileMetadata extends base.FileMetadata with DataModelMixin { @override Object? get id => event.id; - // FileMetadata( - // {super.createdAt, - // super.content, - // super.tags, - // required this.author, - // required this.release, - // required this.signer}); - FileMetadata.fromJson(super.map) : author = belongsTo(map['author']), release = belongsTo(map['release']), diff --git a/lib/models/user.dart b/lib/models/user.dart index 6266cf5..29d2c9b 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -19,7 +19,7 @@ class User extends base.User with DataModelMixin { Object? get id => event.id; Future zap(int amountInSats, - {required base.Event event, String? comment}) async { + {required base.Event event, String? comment, base.Signer? signer}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; // First ensure connection is available @@ -44,8 +44,8 @@ class User extends base.User with DataModelMixin { ..amount = amountInMillisats ..comment = comment; - final zapRequest = - await partialZapRequest.signWith(amberSigner, withPubkey: pubkey); + final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner, + withPubkey: pubkey); // Now we fetch the invoice final lnResponse = await author.fetchLightningAddress(); diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index 1af7eb4..e499efe 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -6,12 +6,14 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/local_app.dart'; +import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/widgets/app_curation_container.dart'; AppLifecycleListener? _lifecycleListener; SharedPreferences? sharedPreferences; +User? anonUser; final amberSigner = AmberSigner(); final appInitializer = FutureProvider((ref) async { @@ -47,6 +49,18 @@ final appInitializer = FutureProvider((ref) async { // Initialize signer await amberSigner.initialize(); + // Set up anon user (pubkey derives from pkSigner secret key) + final anonPubkey = + 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b'; + anonUser ??= User.fromJson({ + 'id': anonPubkey, + 'kind': 0, + 'pubkey': anonPubkey, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'content': '{}', + 'tags': [], + }).init().saveLocal(); + // App-wide listeners // Register app lifecycle listener diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 978d0fb..168c231 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -156,12 +156,10 @@ class AppDetailScreen extends HookConsumerWidget { padding: const EdgeInsets.only(right: 14), child: SignerAndDeveloperRow(app: app), ), - Gap(20), - if (app.latestMetadata != null) - ZapReceipts(fileMetadata: app.latestMetadata!), - Gap(20), + Gap(10), ZapButton(app: app), - Gap(20), + ZapReceipts(app: app), + Gap(10), Container( padding: EdgeInsets.all(10), decoration: BoxDecoration( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1c25356..42e1ead 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,7 +10,6 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; import 'package:path_provider/path_provider.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.data.dart'; @@ -70,8 +69,7 @@ class SettingsScreen extends HookConsumerWidget { user.settings.value!.signInMethod == SignInMethod.nip55 && amberSigner.isAvailable) AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, height: 14, child: CircularProgressIndicator()), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { if (feedbackController.text.trim().isNotEmpty) { final signedDirectMessage = await amberSigner.sign( @@ -110,13 +108,19 @@ class SettingsScreen extends HookConsumerWidget { }, child: Text('Send as ${user.nameOrNpub}'), ), - Gap(40), + Gap(20), Divider(), - Gap(40), - if (user != null) NostrWalletConnectContainer(), - Gap(40), + Gap(20), + Text( + 'Wallet', + style: context.theme.textTheme.headlineLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + Gap(20), + NwcContainer(), + Gap(20), Divider(), - Gap(40), + Gap(20), Text( 'Tools', style: context.theme.textTheme.headlineLarge! @@ -218,9 +222,20 @@ class SettingsScreen extends HookConsumerWidget { } } -class NostrWalletConnectContainer extends HookConsumerWidget { +class SmallCircularProgressIndicator extends StatelessWidget { + const SmallCircularProgressIndicator({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox(width: 14, height: 14, child: CircularProgressIndicator()); + } +} + +class NwcContainer extends HookConsumerWidget { final bool dialogMode; - const NostrWalletConnectContainer({ + const NwcContainer({ super.key, this.dialogMode = false, }); @@ -231,14 +246,25 @@ class NostrWalletConnectContainer extends HookConsumerWidget { final controller = useTextEditingController(text: ''); final isTextFieldEmpty = useState(true); + if (nwcConnection.isLoading) { + return Padding( + padding: const EdgeInsets.all(5), + child: SmallCircularProgressIndicator(), + ); + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Gap(20), - Text( - "Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"), - if (nwcConnection.value != null && !dialogMode) + if (!nwcConnection.hasError) + Text( + "Nostr Wallet Connect ${nwcConnection.hasValue ? 'connected' : 'URI'}"), + if (nwcConnection.hasError) + Text('Error connecting: ${nwcConnection.error}'), + if (nwcConnection.hasValue && + nwcConnection.value != null && + !dialogMode) ElevatedButton( onPressed: () async { await ref.read(nwcConnectionProvider.notifier).disconnectWallet(); @@ -248,9 +274,11 @@ class NostrWalletConnectContainer extends HookConsumerWidget { backgroundColor: Colors.transparent), child: Text('Disconnect'), ), - if (nwcConnection.value == null) + if ((nwcConnection.hasValue && nwcConnection.value == null) || + nwcConnection.hasError) TextField( autocorrect: false, + keyboardType: TextInputType.none, controller: controller, onChanged: (value) { isTextFieldEmpty.value = value.isEmpty; @@ -259,11 +287,7 @@ class NostrWalletConnectContainer extends HookConsumerWidget { hintText: 'nostr+walletconnect://...', suffixIcon: AsyncButtonBuilder( disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { final nwcUri = controller.text.trim(); await ref diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 93e91dc..b13fc6f 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -135,4 +135,10 @@ extension StringWidget on String { } } +extension TextExt on Text { + Widget get bold { + return Text(data!, style: TextStyle(fontWeight: FontWeight.bold)); + } +} + const kZapstoreAppIdentifier = 'dev.zapstore.app'; diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 39637d8..0d35706 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -44,18 +44,18 @@ class NwcConnectionNotifier extends StateNotifier> { // Write secret to storage and connect await _storage.write(key: kNwcSecretKey, value: nwcSecret); - final connection = await ndk.nwc.connect( - nwcSecret, - doGetInfoMethod: false, - onError: (error) { - state = - AsyncValue.error(error ?? 'Could not connect', StackTrace.current); - }, - ); - if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - state = AsyncValue.data(connection); - } else { - state = AsyncError('No permission to zap', StackTrace.current); + try { + final connection = await ndk.nwc.connect( + nwcSecret, + doGetInfoMethod: false, + ); + if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { + state = AsyncValue.data(connection); + } else { + state = AsyncError('No permission to zap', StackTrace.current); + } + } catch (e, stack) { + state = AsyncValue.error(e, stack); } } diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 4252cf5..da428fc 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -6,6 +6,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/notifier.dart'; import 'package:zapstore/widgets/app_card.dart'; @@ -51,11 +52,7 @@ class LatestReleasesContainer extends HookConsumerWidget { for (final app in state.value!) AppCard(model: app), if (state.hasValue) AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { return ref .read(latestReleasesAppProvider.notifier) diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index ef90a43..438d5d8 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -9,13 +9,21 @@ import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/app_initializer.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class SignInButton extends ConsumerWidget { final bool minimal; final String label; - SignInButton({super.key, this.minimal = false, this.label = 'Sign in'}); + final bool publicKeyAllowed; + final String? signedOutText; + SignInButton( + {super.key, + this.minimal = false, + this.label = 'Sign in', + this.publicKeyAllowed = true, + this.signedOutText}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -28,6 +36,7 @@ class SignInButton extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (user == null && signedOutText != null) Text(signedOutText!), Row( children: [ if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), @@ -67,7 +76,9 @@ class SignInButton extends ConsumerWidget { label, style: TextStyle(fontWeight: FontWeight.bold), ), - content: SignInDialogBox(), + content: SignInDialogBox( + publicKeyAllowed: publicKeyAllowed, + ), ), ); } else { @@ -102,11 +113,7 @@ class SignInDialogBox extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), builder: (context, child, callback, buttonState) { return Padding( padding: const EdgeInsets.only(bottom: 2), @@ -183,11 +190,7 @@ class SignInDialogBox extends HookConsumerWidget { Gap(10), AsyncButtonBuilder( disabled: isTextFieldEmpty.value, - loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + loadingWidget: SmallCircularProgressIndicator(), onPressed: () async { try { final input = controller.text.trim(); diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index bd0e737..52a516a 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -6,6 +6,7 @@ import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; class WebOfTrustContainer extends HookConsumerWidget { @@ -55,11 +56,7 @@ class WebOfTrustContainer extends HookConsumerWidget { children: [ Text('Loading web of trust...'), Gap(10), - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + SmallCircularProgressIndicator(), ], ), ), diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index f5f7067..3177c29 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/signers.dart'; import 'package:zapstore/widgets/sign_in_container.dart'; import '../utils/nwc.dart'; @@ -32,32 +34,29 @@ class ZapButton extends HookConsumerWidget { return app.developer.value?.lud16 != null ? ElevatedButton( onPressed: () async { - final loggedInUser = user ?? - await showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text( - 'Sign in to zap', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: SignInDialogBox(publicKeyAllowed: false), - ), - ); + // final loggedInUser = user ?? + // await showDialog( + // context: context, + // builder: (_) => AlertDialog( + // title: Text( + // 'Sign in to zap', + // style: TextStyle(fontWeight: FontWeight.bold), + // ), + // content: SignInDialogBox(publicKeyAllowed: false), + // ), + // ); - if (loggedInUser == null) { - return; - } + // if (loggedInUser == null) { + // return; + // } if (nwcConnection.value == null) { await showDialog( // ignore: use_build_context_synchronously context: context, builder: (_) => AlertDialog( - title: Text( - 'Connect your wallet to zap', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: NostrWalletConnectContainer(dialogMode: true), + title: Text('Connect your wallet').bold, + content: NwcContainer(dialogMode: true), ), ); } @@ -71,10 +70,17 @@ class ZapButton extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text('Choose zap amount'), + title: Text('Choose zap amount').bold, content: Column( mainAxisSize: MainAxisSize.min, children: [ + if (user == null) + SignInButton( + publicKeyAllowed: false, + minimal: true, + signedOutText: + '⚠️ If you do not sign in, you will be zapping anonymously', + ), TextField( controller: amountController, keyboardType: TextInputType.number, @@ -83,24 +89,22 @@ class ZapButton extends HookConsumerWidget { ), SizedBox(height: 20), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ElevatedButton( onPressed: () => Navigator.of(context).pop(21), - child: Text('⚡ 21'), + child: Text('⚡️ 21'), ), - Gap(10), ElevatedButton( onPressed: () => Navigator.of(context).pop(210), - child: Text('⚡ 210'), + child: Text('⚡️ 210'), ), - Gap(10), ElevatedButton( onPressed: () => Navigator.of(context).pop(2100), - child: Text('⚡ 2100'), + child: Text('⚡️ 2100'), ), ], ), @@ -115,7 +119,7 @@ class ZapButton extends HookConsumerWidget { }, ), TextButton( - child: Text('Zap'), + child: Text('Zap').bold, onPressed: () { Navigator.of(context).pop( int.tryParse(amountController.text) ?? 0); @@ -132,7 +136,12 @@ class ZapButton extends HookConsumerWidget { // ignore: use_build_context_synchronously context.showInfo('Zap sent!', description: '$amount sats on their way'); - await loggedInUser.zap(amount, event: app.latestMetadata!); + if (user != null) { + await user.zap(amount, event: app.latestMetadata!); + } else { + await anonUser!.zap(amount, + event: app.latestMetadata!, signer: pkSigner); + } } catch (e) { // ignore: use_build_context_synchronously context.showError( @@ -140,7 +149,14 @@ class ZapButton extends HookConsumerWidget { } } }, - child: Text('Zap!'), + style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]), + child: Text( + '⚡️ Zap this release', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15), + ), ) : Container(); } diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 7f4b5ad..3ce5002 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -1,26 +1,35 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; +import 'package:ndk/domain_layer/entities/filter.dart'; import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:zapstore/main.data.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/zap_receipt.dart'; +import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; class ZapReceipts extends HookConsumerWidget { ZapReceipts({ super.key, - required this.fileMetadata, + required this.app, }); - final FileMetadata fileMetadata; + final App app; @override Widget build(BuildContext context, WidgetRef ref) { + final fileMetadata = app.latestMetadata; + if (fileMetadata == null) { + return Container(); + } + final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata)); if (zapReceipts.isLoading) { @@ -38,33 +47,63 @@ class ZapReceipts extends HookConsumerWidget { final senderIds = receipts.map((r) => r.senderPubkey).toSet(); final senders = ref.users.findManyLocalByIds(senderIds); - return UsersRichText( - preSpan: TextSpan(text: '⚡ $totalAmountInSats sats by'), - trailingText: ' and others', - users: senders, + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: UsersRichText( + preSpan: TextSpan( + text: '⚡ $totalAmountInSats sats (${receipts.length} zaps) by'), + trailingText: ' and others', + users: senders, + ), ); } } class ZapReceiptsNotifier extends StateNotifier>> { Ref ref; - FileMetadata fileMetadata; + final FileMetadata fileMetadata; StreamSubscription? sub; - ZapReceiptsNotifier(this.ref, this.fileMetadata) - : super(AsyncData(ref.zapReceipts.zapReceiptAdapter - .findByRecipient( - pubkey: fileMetadata.author.id!.toString(), - eventId: fileMetadata.id!.toString()) - .toSet())) { + ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) { final adapter = ref.users.nostrAdapter; - - final recipient = + final developerPubkey = fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey; - print('querying for $recipient and id ${fileMetadata.event.id}'); - final receiptsResponse = adapter.socialRelays.ndk!.zaps - .subscribeToZapReceipts( - pubKey: recipient, eventId: fileMetadata.event.id); + + // If it is a Zapstore-signed release from the indexer, return + if (developerPubkey == kZapstorePubkey && + fileMetadata.release.value!.app.value!.identifier != + kZapstoreAppIdentifier) { + state = AsyncData({}); + return; + } + + final localReceipts = ref.zapReceipts.zapReceiptAdapter + .findByRecipient( + pubkey: developerPubkey, eventId: fileMetadata.id!.toString()) + .toSet(); + state = AsyncData(localReceipts); + + print('querying for $developerPubkey and id ${fileMetadata.event.id}'); + + final latestReceiptTimestamp = localReceipts + .sortedBy((z) => z.event.createdAt) + .lastOrNull + ?.event + .createdAt + .millisecondsSinceEpoch; + + final receiptsResponse = adapter.socialRelays.ndk!.requests.subscription( + filters: [ + Filter( + kinds: [9735], + since: latestReceiptTimestamp != null + ? latestReceiptTimestamp ~/ 1000 + 1 + : null, + eTags: [fileMetadata.event.id], + pTags: [developerPubkey], + ) + ], + ); sub = receiptsResponse.stream.listen((data) { final model = ZapReceipt.fromJson(data.toJson()).init().saveLocal(); From e65d4e35d73e29d56a661ad50f72b5f9114c80c6 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 19:12:38 -0300 Subject: [PATCH 40/51] More NWC container fixes --- lib/screens/settings_screen.dart | 132 +++++++++-------- lib/utils/nwc.dart | 6 + lib/widgets/zap_button.dart | 234 ++++++++++++++----------------- 3 files changed, 177 insertions(+), 195 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 42e1ead..7cf246f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -112,7 +112,7 @@ class SettingsScreen extends HookConsumerWidget { Divider(), Gap(20), Text( - 'Wallet', + 'Nostr Wallet Connect', style: context.theme.textTheme.headlineLarge! .copyWith(fontWeight: FontWeight.bold), ), @@ -246,76 +246,72 @@ class NwcContainer extends HookConsumerWidget { final controller = useTextEditingController(text: ''); final isTextFieldEmpty = useState(true); - if (nwcConnection.isLoading) { - return Padding( - padding: const EdgeInsets.all(5), - child: SmallCircularProgressIndicator(), - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!nwcConnection.hasError) - Text( - "Nostr Wallet Connect ${nwcConnection.hasValue ? 'connected' : 'URI'}"), - if (nwcConnection.hasError) - Text('Error connecting: ${nwcConnection.error}'), - if (nwcConnection.hasValue && - nwcConnection.value != null && - !dialogMode) - ElevatedButton( - onPressed: () async { - await ref.read(nwcConnectionProvider.notifier).disconnectWallet(); - }, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: Text('Disconnect'), - ), - if ((nwcConnection.hasValue && nwcConnection.value == null) || - nwcConnection.hasError) - TextField( - autocorrect: false, - keyboardType: TextInputType.none, - controller: controller, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - decoration: InputDecoration( - hintText: 'nostr+walletconnect://...', - suffixIcon: AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SmallCircularProgressIndicator(), - onPressed: () async { - final nwcUri = controller.text.trim(); - await ref - .read(nwcConnectionProvider.notifier) - .connectWallet(nwcUri); - if (dialogMode && context.mounted) { - Navigator.of(context).pop(); - } - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, + return Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!nwcConnection.hasValue && nwcConnection.isLoading) + SmallCircularProgressIndicator(), + if (nwcConnection.isPresent) Text('Wallet connected'), + if (nwcConnection.hasError) + Text('Error connecting: ${nwcConnection.error}'), + if (!nwcConnection.isPresent && !nwcConnection.isLoading) + TextField( + autocorrect: false, + keyboardType: TextInputType.none, + controller: controller, + onChanged: (value) { + isTextFieldEmpty.value = value.isEmpty; + }, + decoration: InputDecoration( + hintText: 'nostr+walletconnect://...', + suffixIcon: AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SmallCircularProgressIndicator(), + onPressed: () async { + final nwcUri = controller.text.trim(); + await ref + .read(nwcConnectionProvider.notifier) + .connectWallet(nwcUri); + if (dialogMode && context.mounted) { + Navigator.of(context).pop(); + } + }, + builder: (context, child, callback, buttonState) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + child: ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: child, + ), ), - ), - ); - }, - child: Text('Connect'), + ); + }, + child: Text('Connect'), + ), ), ), - ), - ], + if (nwcConnection.isPresent && !dialogMode) + ElevatedButton( + onPressed: () async { + controller.clear(); + await ref + .read(nwcConnectionProvider.notifier) + .disconnectWallet(); + }, + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent), + child: Text('Disconnect'), + ), + ], + ), ); } } diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart index 0d35706..a6cc54a 100644 --- a/lib/utils/nwc.dart +++ b/lib/utils/nwc.dart @@ -68,3 +68,9 @@ class NwcConnectionNotifier extends StateNotifier> { final nwcConnectionProvider = StateNotifierProvider>( (ref) => NwcConnectionNotifier(ref)); + +extension NwcExt on AsyncValue { + bool get isPresent { + return hasValue && value != null && !hasError; + } +} diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 3177c29..b09d369 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; @@ -31,133 +30,114 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); - return app.developer.value?.lud16 != null - ? ElevatedButton( - onPressed: () async { - // final loggedInUser = user ?? - // await showDialog( - // context: context, - // builder: (_) => AlertDialog( - // title: Text( - // 'Sign in to zap', - // style: TextStyle(fontWeight: FontWeight.bold), - // ), - // content: SignInDialogBox(publicKeyAllowed: false), - // ), - // ); + if (app.developer.value?.lud16 == null) { + return Container(); + } - // if (loggedInUser == null) { - // return; - // } - - if (nwcConnection.value == null) { - await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (_) => AlertDialog( - title: Text('Connect your wallet').bold, - content: NwcContainer(dialogMode: true), - ), - ); - } - - if (ref.read(nwcConnectionProvider).value == null) { - return; - } - - final amount = await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (context) { - return AlertDialog( - title: Text('Choose zap amount').bold, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (user == null) - SignInButton( - publicKeyAllowed: false, - minimal: true, - signedOutText: - '⚠️ If you do not sign in, you will be zapping anonymously', - ), - TextField( - controller: amountController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: "Enter amount in sats"), - ), - SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - onPressed: () => - Navigator.of(context).pop(21), - child: Text('⚡️ 21'), - ), - ElevatedButton( - onPressed: () => - Navigator.of(context).pop(210), - child: Text('⚡️ 210'), - ), - ElevatedButton( - onPressed: () => - Navigator.of(context).pop(2100), - child: Text('⚡️ 2100'), - ), - ], - ), - ], - ), - actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () { - Navigator.of(context) - .pop(); // Dismiss the dialog without returning an amount - }, - ), - TextButton( - child: Text('Zap').bold, - onPressed: () { - Navigator.of(context).pop( - int.tryParse(amountController.text) ?? 0); - }, - ), - ], - ); - }, - ) ?? - 0; - - if (amount > 0) { - try { - // ignore: use_build_context_synchronously - context.showInfo('Zap sent!', - description: '$amount sats on their way'); - if (user != null) { - await user.zap(amount, event: app.latestMetadata!); - } else { - await anonUser!.zap(amount, - event: app.latestMetadata!, signer: pkSigner); - } - } catch (e) { - // ignore: use_build_context_synchronously - context.showError( - title: 'Unable to zap', description: e.toString()); - } - } - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]), - child: Text( - '⚡️ Zap this release', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15), + return ElevatedButton( + onPressed: () async { + if (!nwcConnection.isPresent) { + await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (_) => AlertDialog( + title: Text('Connect your wallet').bold, + content: NwcContainer(dialogMode: true), ), - ) - : Container(); + ); + } + + if (!ref.read(nwcConnectionProvider).isPresent) { + return; + } + + final amount = await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (context) { + return AlertDialog( + title: Text('Choose zap amount').bold, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (user == null) + SignInButton( + publicKeyAllowed: false, + minimal: true, + signedOutText: + '⚠️ If you do not sign in, you will be zapping anonymously', + ), + TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: + InputDecoration(hintText: "Enter amount in sats"), + ), + SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(21), + child: Text('⚡️ 21'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(210), + child: Text('⚡️ 210'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(2100), + child: Text('⚡️ 2100'), + ), + ], + ), + ], + ), + actions: [ + TextButton( + child: Text('Cancel'), + onPressed: () { + Navigator.of(context) + .pop(); // Dismiss the dialog without returning an amount + }, + ), + TextButton( + child: Text('Zap').bold, + onPressed: () { + Navigator.of(context) + .pop(int.tryParse(amountController.text) ?? 0); + }, + ), + ], + ); + }, + ); + + if (amount != null && amount > 0) { + try { + // ignore: use_build_context_synchronously + context.showInfo('Zap sent!', + description: + '$amount sats on their way. Your zap should show up in a few seconds.'); + if (user != null) { + await user.zap(amount, event: app.latestMetadata!); + } else { + await anonUser! + .zap(amount, event: app.latestMetadata!, signer: pkSigner); + } + } catch (e) { + // ignore: use_build_context_synchronously + context.showError( + title: 'Unable to zap', description: e.toString()); + } + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]), + child: Text( + '⚡️ Zap this release', + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), + ), + ); } } From 1289e750704bd19c26c3fa53dca17af225641e8f Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 19:41:21 -0300 Subject: [PATCH 41/51] Fix zaps text, only allow zaps when NWC loaded --- lib/widgets/zap_button.dart | 190 +++++++++++++++++----------------- lib/widgets/zap_receipts.dart | 4 +- 2 files changed, 98 insertions(+), 96 deletions(-) diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index b09d369..f1e9f34 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -35,104 +35,108 @@ class ZapButton extends HookConsumerWidget { } return ElevatedButton( - onPressed: () async { - if (!nwcConnection.isPresent) { - await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (_) => AlertDialog( - title: Text('Connect your wallet').bold, - content: NwcContainer(dialogMode: true), - ), - ); - } - - if (!ref.read(nwcConnectionProvider).isPresent) { - return; - } - - final amount = await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (context) { - return AlertDialog( - title: Text('Choose zap amount').bold, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (user == null) - SignInButton( - publicKeyAllowed: false, - minimal: true, - signedOutText: - '⚠️ If you do not sign in, you will be zapping anonymously', - ), - TextField( - controller: amountController, - keyboardType: TextInputType.number, - decoration: - InputDecoration(hintText: "Enter amount in sats"), + onPressed: nwcConnection.isLoading + ? null + : () async { + if (!nwcConnection.isPresent) { + await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (_) => AlertDialog( + title: Text('Connect your wallet').bold, + content: NwcContainer(dialogMode: true), ), - SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - onPressed: () => Navigator.of(context).pop(21), - child: Text('⚡️ 21'), + ); + if (!ref.read(nwcConnectionProvider).isPresent) { + return; + } + } + + final amount = await showDialog( + // ignore: use_build_context_synchronously + context: context, + builder: (context) { + return AlertDialog( + title: Text('Choose zap amount').bold, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (user == null) + SignInButton( + publicKeyAllowed: false, + minimal: true, + signedOutText: + '⚠️ If you do not sign in, you will be zapping anonymously', + ), + TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: + InputDecoration(hintText: "Enter amount in sats"), + ), + SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(21), + child: Text('⚡️ 21'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(210), + child: Text('⚡️ 210'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(2100), + child: Text('⚡️ 2100'), + ), + ], + ), + ], + ), + actions: [ + TextButton( + child: Text('Cancel'), + onPressed: () { + Navigator.of(context) + .pop(); // Dismiss the dialog without returning an amount + }, ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(210), - child: Text('⚡️ 210'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(2100), - child: Text('⚡️ 2100'), + TextButton( + child: Text('Zap').bold, + onPressed: () { + Navigator.of(context) + .pop(int.tryParse(amountController.text) ?? 0); + }, ), ], - ), - ], - ), - actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () { - Navigator.of(context) - .pop(); // Dismiss the dialog without returning an amount - }, - ), - TextButton( - child: Text('Zap').bold, - onPressed: () { - Navigator.of(context) - .pop(int.tryParse(amountController.text) ?? 0); - }, - ), - ], - ); - }, - ); + ); + }, + ); - if (amount != null && amount > 0) { - try { - // ignore: use_build_context_synchronously - context.showInfo('Zap sent!', - description: - '$amount sats on their way. Your zap should show up in a few seconds.'); - if (user != null) { - await user.zap(amount, event: app.latestMetadata!); - } else { - await anonUser! - .zap(amount, event: app.latestMetadata!, signer: pkSigner); - } - } catch (e) { - // ignore: use_build_context_synchronously - context.showError( - title: 'Unable to zap', description: e.toString()); - } - } - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]), + if (amount != null && amount > 0) { + try { + // ignore: use_build_context_synchronously + context.showInfo('Zap sent!', + description: + '$amount sats on their way. Your zap should show up in a few seconds.'); + if (user != null) { + await user.zap(amount, event: app.latestMetadata!); + } else { + await anonUser!.zap(amount, + event: app.latestMetadata!, signer: pkSigner); + } + } catch (e) { + // ignore: use_build_context_synchronously + context.showError( + title: 'Unable to zap', description: e.toString()); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[800], + disabledBackgroundColor: Colors.grey[500], + ), child: Text( '⚡️ Zap this release', style: TextStyle( diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 3ce5002..6a24cec 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -50,9 +50,7 @@ class ZapReceipts extends HookConsumerWidget { return Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), child: UsersRichText( - preSpan: TextSpan( - text: '⚡ $totalAmountInSats sats (${receipts.length} zaps) by'), - trailingText: ' and others', + preSpan: TextSpan(text: '⚡ $totalAmountInSats sats zapped from '), users: senders, ), ); From ae137ca51d1e464ca23e5361ab87b93d3a06f30e Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 19:59:00 -0300 Subject: [PATCH 42/51] Shorten extension, closes #199 --- lib/models/user.dart | 4 ++-- lib/screens/app_detail_screen.dart | 5 +++-- lib/utils/extensions.dart | 10 +++++++++- lib/widgets/author_container.dart | 2 +- lib/widgets/error_container.dart | 2 +- lib/widgets/sign_in_container.dart | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index 29d2c9b..ed1e22d 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -66,7 +66,7 @@ class User extends base.User with DataModelMixin { final commentLength = lnResponse['commentAllowed']; comment = - commentLength != null ? comment?.substringMax(commentLength) : comment; + commentLength != null ? comment?.safeSubstring(commentLength) : comment; var callbackUri = Uri.parse(lnResponse['callback']!); callbackUri = callbackUri.replace( @@ -114,7 +114,7 @@ class User extends base.User with DataModelMixin { @DataRelationship(inverse: 'user') final BelongsTo settings; - String get nameOrNpub => name ?? npub.substringMax(18); + String get nameOrNpub => name ?? npub.shorten; } mixin UserAdapter on NostrAdapter { diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 168c231..2e43d8e 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -252,7 +252,7 @@ class AppDetailScreen extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - '${app.latestMetadata!.hash!.substring(0, 6)}...${app.latestMetadata!.hash!.substring(58, 64)}', + app.latestMetadata!.hash!.shorten, maxLines: 1, ), Gap(6), @@ -285,7 +285,8 @@ class AppDetailScreen extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - '${app.latestMetadata!.apkSignatureHash!.substring(0, 6)}...${app.latestMetadata!.apkSignatureHash!.substring(58, 64)}', + app.latestMetadata! + .apkSignatureHash!.shorten, maxLines: 1, ), Gap(6), diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index b13fc6f..ab5185f 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -126,10 +126,18 @@ extension StringWidget on String { }); } - String substringMax(int size) { + String safeSubstring(int size) { return substring(0, min(length, size)) + (size < length ? '...' : ''); } + String get shorten { + // npub1wf4puf...43dgh9 + if (length < 18) return this; + final leading = substring(0, 9); + final trailing = substring(length - 6, length - 1); + return '$leading...$trailing'; + } + String removeParenthesis() { return replaceAll(RegExp(r'\([^()]*\)'), ''); } diff --git a/lib/widgets/author_container.dart b/lib/widgets/author_container.dart index 6333d86..59b1832 100644 --- a/lib/widgets/author_container.dart +++ b/lib/widgets/author_container.dart @@ -44,7 +44,7 @@ class AuthorContainer extends StatelessWidget { children: [ Text(beforeText), Text( - user.nameOrNpub.substringMax(24), + user.nameOrNpub, style: TextStyle(fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart index cd13cc3..f877ecb 100644 --- a/lib/widgets/error_container.dart +++ b/lib/widgets/error_container.dart @@ -50,7 +50,7 @@ class ErrorContainer extends HookConsumerWidget { ), Gap(6), Text( - stack?.toString().substringMax(80) ?? '', + stack?.toString().safeSubstring(80) ?? '', style: TextStyle( fontSize: 12, color: Colors.grey, diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 438d5d8..0d71bb1 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -212,7 +212,7 @@ class SignInDialogBox extends HookConsumerWidget { if (context.mounted) { context.showError( title: e.toString(), - description: stack.toString().substringMax(200)); + description: stack.toString().safeSubstring(200)); } } }, From 6ba273cc129a2f2f602387819af3c30c7a7a7b3c Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 3 Jan 2025 19:59:37 -0300 Subject: [PATCH 43/51] Zap receipts improvements --- lib/widgets/users_rich_text.dart | 26 ++++++++++++++++---------- lib/widgets/wot_container.dart | 5 ++--- lib/widgets/zap_receipts.dart | 5 ++++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart index 2f0e5b6..cefc469 100644 --- a/lib/widgets/users_rich_text.dart +++ b/lib/widgets/users_rich_text.dart @@ -5,12 +5,12 @@ import 'package:zapstore/widgets/rounded_image.dart'; class UsersRichText extends StatelessWidget { const UsersRichText({ super.key, - this.preSpan, + this.leadingText, this.trailingText, required this.users, }); - final TextSpan? preSpan; + final String? leadingText; final String? trailingText; final List users; @@ -19,26 +19,32 @@ class UsersRichText extends StatelessWidget { return RichText( text: TextSpan( children: [ - if (preSpan != null) preSpan!, + if (leadingText != null) + TextSpan(text: leadingText!, style: TextStyle(fontSize: 15)), for (final user in users) TextSpan( - style: TextStyle(height: 1.6), children: [ WidgetSpan( alignment: PlaceholderAlignment.middle, child: Wrap( + spacing: 1, + runSpacing: 10, crossAxisAlignment: WrapCrossAlignment.center, children: [ RoundedImage(url: user.avatarUrl, size: 20), Text( ' ${user.nameOrNpub}', - style: TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + ), + Text( + users.indexOf(user) == users.length - 1 + ? '' + : (users.indexOf(user) == users.length - 2 + ? ' and ' + : ', '), + style: TextStyle(fontSize: 15), ), - Text(users.indexOf(user) == users.length - 1 - ? '' - : (users.indexOf(user) == users.length - 2 - ? ' and ' - : ', ')), ], ), ), diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index 52a516a..2b01c4c 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -38,9 +38,8 @@ class WebOfTrustContainer extends HookConsumerWidget { } return UsersRichText( - preSpan: hasUser && fromNpub != kFranzapPubkey.npub - ? TextSpan(text: 'You, ') - : null, + leadingText: + hasUser && fromNpub != kFranzapPubkey.npub ? 'You, ' : null, trailingText: ' and others follow this signer on nostr.', users: trustedUsersWithoutLoggedInUser, ); diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 6a24cec..6243824 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -25,6 +25,9 @@ class ZapReceipts extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // NOTE: These watchers will become more efficient + ref.users.watchAll(); + final fileMetadata = app.latestMetadata; if (fileMetadata == null) { return Container(); @@ -50,7 +53,7 @@ class ZapReceipts extends HookConsumerWidget { return Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), child: UsersRichText( - preSpan: TextSpan(text: '⚡ $totalAmountInSats sats zapped from '), + leadingText: '⚡ $totalAmountInSats sats zapped from ', users: senders, ), ); From dc9a68b4393f62a3ee751c8dfc3af9dca9584f71 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 13:10:30 -0300 Subject: [PATCH 44/51] Add User.fromPubkey constructor --- lib/models/user.dart | 19 ++++++++++++------- lib/navigation/app_initializer.dart | 14 ++++---------- lib/widgets/sign_in_container.dart | 12 +++--------- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index ed1e22d..407b234 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -105,6 +105,16 @@ class User extends base.User with DataModelMixin { settings = belongsTo(map['settings']), super.fromJson(); + User.fromPubkey(String pubkey) + : this.fromJson({ + 'id': pubkey, + 'kind': 0, + 'pubkey': pubkey.hexKey, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'content': '{}', + 'tags': [], + }); + Map toJson() => super.toMap(); @DataRelationship(inverse: 'followers') @@ -173,13 +183,8 @@ mixin UserAdapter on NostrAdapter { if (publicKey.startsWith('npub')) { publicKey = publicKey.hexKey; - } else { - // If it's not an npub we treat the string as NIP-05 - if (!publicKey.contains('@')) { - // If it does not have a @, we treat the string as a domain name - publicKey = '_@$publicKey'; - } - + } else if (publicKey.contains('@')) { + // If it's not an npub and it has a @ instead, we treat the string as NIP-05 final [username, domain] = publicKey.split('@'); publicKey = await sendRequest( Uri.parse('https://$domain/.well-known/nostr.json?name=$username'), diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index e499efe..875abad 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -50,16 +50,10 @@ final appInitializer = FutureProvider((ref) async { await amberSigner.initialize(); // Set up anon user (pubkey derives from pkSigner secret key) - final anonPubkey = - 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b'; - anonUser ??= User.fromJson({ - 'id': anonPubkey, - 'kind': 0, - 'pubkey': anonPubkey, - 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, - 'content': '{}', - 'tags': [], - }).init().saveLocal(); + anonUser ??= User.fromPubkey( + 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b') + .init() + .saveLocal(); // App-wide listeners diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 0d71bb1..36ea33e 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -147,15 +147,9 @@ class SignInDialogBox extends HookConsumerWidget { // If user was not found on relays, we create a // local user to represent this new npub - user ??= User.fromJson({ - 'id': signedInNpub.hexKey, - 'kind': 0, - 'pubkey': signedInNpub.hexKey, - 'created_at': - DateTime.now().millisecondsSinceEpoch ~/ 1000, - 'content': '{}', - 'tags': [], - }).init().saveLocal(); + user ??= User.fromPubkey(signedInNpub.hexKey) + .init() + .saveLocal(); final settings = ref.settings.findOneLocalById('_')!; settings.signInMethod = SignInMethod.nip55; From 9d53ea6331f4f211a0eea1c106b597ee35c48b73 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 13:11:11 -0300 Subject: [PATCH 45/51] Fix zap receipt query --- lib/models/zap_receipt.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart index 0a51cb0..746d777 100644 --- a/lib/models/zap_receipt.dart +++ b/lib/models/zap_receipt.dart @@ -35,10 +35,8 @@ mixin ZapReceiptAdapter on Adapter { List findByRecipient( {required String pubkey, required String eventId}) { - // NOTE: This query is technically incorrect using IN, but the probability of clashing p/e IDs is extremely low - // This will be fixed with the new purplebase final result = db.select( - "SELECT z.key, data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') in ('p', 'e') and json_extract(value, '\$[1]') in (?, ?)", + "SELECT z.key, value from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'p' AND json_extract(value, '\$[1]') = ?AND json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?", [pubkey, eventId]); return deserializeFromResult(result); } From df747a64abdd93b645b28ff9d83ecd8d8d2868af Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 13:13:01 -0300 Subject: [PATCH 46/51] Zap button fixes, allow comments --- lib/widgets/author_container.dart | 1 - lib/widgets/zap_button.dart | 59 +++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/lib/widgets/author_container.dart b/lib/widgets/author_container.dart index 59b1832..bf5909d 100644 --- a/lib/widgets/author_container.dart +++ b/lib/widgets/author_container.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class AuthorContainer extends StatelessWidget { diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index f1e9f34..692a70c 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; @@ -29,6 +30,7 @@ class ZapButton extends HookConsumerWidget { .value; final amountController = TextEditingController(); + final commentController = TextEditingController(); if (app.developer.value?.lud16 == null) { return Container(); @@ -52,7 +54,7 @@ class ZapButton extends HookConsumerWidget { } } - final amount = await showDialog( + final valueRecord = await showDialog<(int, String)>( // ignore: use_build_context_synchronously context: context, builder: (context) { @@ -72,41 +74,48 @@ class ZapButton extends HookConsumerWidget { controller: amountController, keyboardType: TextInputType.number, decoration: - InputDecoration(hintText: "Enter amount in sats"), + InputDecoration(hintText: 'Enter amount in sats'), ), - SizedBox(height: 20), + Gap(20), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ElevatedButton( - onPressed: () => Navigator.of(context).pop(21), - child: Text('⚡️ 21'), + onPressed: () => amountController.text = '21', + child: Text('⚡️21'), ), + Gap(3), ElevatedButton( - onPressed: () => Navigator.of(context).pop(210), - child: Text('⚡️ 210'), + onPressed: () => amountController.text = '210', + child: Text('⚡️210'), ), + Gap(3), ElevatedButton( - onPressed: () => Navigator.of(context).pop(2100), - child: Text('⚡️ 2100'), + onPressed: () => amountController.text = '2100', + child: Text('⚡️2100'), ), ], ), + TextField( + controller: commentController, + decoration: + InputDecoration(hintText: 'Leave a comment'), + ), ], ), actions: [ TextButton( child: Text('Cancel'), - onPressed: () { - Navigator.of(context) - .pop(); // Dismiss the dialog without returning an amount - }, + onPressed: () => Navigator.of(context).pop(), ), TextButton( child: Text('Zap').bold, onPressed: () { - Navigator.of(context) - .pop(int.tryParse(amountController.text) ?? 0); + final record = ( + int.tryParse(amountController.text) ?? 0, + commentController.text + ); + Navigator.of(context).pop(record); }, ), ], @@ -114,17 +123,29 @@ class ZapButton extends HookConsumerWidget { }, ); - if (amount != null && amount > 0) { + if (valueRecord == null) { + return; + } + final (amount, comment) = valueRecord; + + if (amount > 0) { try { // ignore: use_build_context_synchronously context.showInfo('Zap sent!', description: '$amount sats on their way. Your zap should show up in a few seconds.'); - if (user != null) { - await user.zap(amount, event: app.latestMetadata!); + + // Reload user as we are in a callback and it could have changed + final loggedInUser = + ref.settings.findOneLocalById('_')!.user.value; + if (loggedInUser != null) { + await loggedInUser.zap(amount, + event: app.latestMetadata!, comment: comment); } else { await anonUser!.zap(amount, - event: app.latestMetadata!, signer: pkSigner); + event: app.latestMetadata!, + signer: pkSigner, + comment: comment); } } catch (e) { // ignore: use_build_context_synchronously From a15509f246ea924cdb1cbfcd4c1402b1dda04d39 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 13:42:24 -0300 Subject: [PATCH 47/51] Buffer and load zappers --- lib/models/nostr_adapter.dart | 7 ++++++ lib/utils/extensions.dart | 31 ++++++++++++++++++++++++++ lib/widgets/zap_receipts.dart | 42 +++++++++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index 8057da3..729dbe2 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -105,6 +105,13 @@ mixin NostrAdapter> on Adapter { return r.isNotEmpty; } + Iterable existingIds(Iterable ids) { + final r = db.select( + 'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})', + ids.toList()); + return r.map((e) => e['id']); + } + @override Future> findAll( {bool? remote, diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index ab5185f..0049e5d 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:dart_emoji/dart_emoji.dart'; @@ -150,3 +151,33 @@ extension TextExt on Text { } const kZapstoreAppIdentifier = 'dev.zapstore.app'; + +// stream utils + +Stream> bufferByTime(Stream source, Duration duration) { + final controller = StreamController>(); + + final buffer = []; + + Timer? timer; + + // Listen to the source stream + source.listen((data) { + buffer.add(data); + + // If there's no active timer, start one + timer ??= Timer(duration, () { + controller.add(List.from(buffer)); // Emit buffered data + buffer.clear(); // Clear the buffer + timer = null; // Reset the timer + }); + }, onDone: () { + // Emit remaining items in the buffer when the source is done + if (buffer.isNotEmpty) { + controller.add(List.from(buffer)); + } + controller.close(); // Close the controller + }); + + return controller.stream; // Return the buffered stream +} diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index 6243824..cbb9243 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -63,7 +63,7 @@ class ZapReceipts extends HookConsumerWidget { class ZapReceiptsNotifier extends StateNotifier>> { Ref ref; final FileMetadata fileMetadata; - StreamSubscription? sub; + StreamSubscription>? sub; ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) { final adapter = ref.users.nostrAdapter; @@ -78,15 +78,15 @@ class ZapReceiptsNotifier extends StateNotifier>> { return; } - final localReceipts = ref.zapReceipts.zapReceiptAdapter + final localZapReceipts = ref.zapReceipts.zapReceiptAdapter .findByRecipient( pubkey: developerPubkey, eventId: fileMetadata.id!.toString()) .toSet(); - state = AsyncData(localReceipts); + _loadZappers(localZapReceipts); + state = AsyncData(localZapReceipts); - print('querying for $developerPubkey and id ${fileMetadata.event.id}'); - - final latestReceiptTimestamp = localReceipts + // NOTE: ideally this caching stuff should be handled by purplebase + final latestReceiptTimestamp = localZapReceipts .sortedBy((z) => z.event.createdAt) .lastOrNull ?.event @@ -106,12 +106,36 @@ class ZapReceiptsNotifier extends StateNotifier>> { ], ); - sub = receiptsResponse.stream.listen((data) { - final model = ZapReceipt.fromJson(data.toJson()).init().saveLocal(); - state = AsyncData({if (state.hasValue) ...state.value!, model}); + sub = bufferByTime(receiptsResponse.stream, Duration(seconds: 1)) + .listen((data) async { + final zapReceipts = + data.map((r) => ZapReceipt.fromJson(r.toJson()).init().saveLocal()); + await _loadZappers(zapReceipts); + state = AsyncData({if (state.hasValue) ...state.value!, ...zapReceipts}); }); } + // Load senders - this tedious work will be handled by purplebase at some point + Future _loadZappers(Iterable receipts) async { + // If we do not have the users locally, trigger a remote fetch + final receiptsPubkeys = receipts.map((r) => r.senderPubkey).toSet(); + final existingPubkeysLocally = + ref.users.nostrAdapter.existingIds(receiptsPubkeys).toSet(); + final missingPubkeysLocally = + receiptsPubkeys.difference(existingPubkeysLocally); + if (missingPubkeysLocally.isNotEmpty) { + final fetchedSenders = + await ref.users.findAll(params: {'authors': missingPubkeysLocally}); + final stillMissingPubkeys = missingPubkeysLocally + .toSet() + .difference(fetchedSenders.map((s) => s.id!.toString()).toSet()); + for (final pubkey in stillMissingPubkeys) { + // If relays do not have it, create a dummy local user + User.fromPubkey(pubkey).init().saveLocal(); + } + } + } + @override void dispose() { sub?.cancel(); From ea6b31f99438373f78b5b445ee9e4b77607c3206 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 14:33:27 -0300 Subject: [PATCH 48/51] Fix toasts and zap button UI --- lib/screens/settings_screen.dart | 7 ++-- lib/utils/extensions.dart | 44 +++++++++++++++---------- lib/widgets/install_button.dart | 12 +++---- lib/widgets/sign_in_container.dart | 22 +++++-------- lib/widgets/zap_button.dart | 53 +++++++++++++++++------------- 5 files changed, 74 insertions(+), 64 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7cf246f..e7bbcd3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -92,8 +92,8 @@ class SettingsScreen extends HookConsumerWidget { feedbackController.clear(); } catch (e, stack) { if (context.mounted) { - context.showError( - title: e.toString(), description: stack.toString()); + context.showError(e.toString(), + description: stack.toString()); } } } @@ -205,8 +205,7 @@ class SettingsScreen extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError( - title: 'Unable to send system info', + context.showError('Unable to send system info', description: e.toString()); } } diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 0049e5d..2337154 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -17,10 +17,7 @@ extension ContextX on BuildContext { icon: icon ?? Icon(Icons.info), style: ToastificationStyle.fillColored, alignment: Alignment.topCenter, - title: Text( - message, - style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), - ), + title: _ToastTitleWidget(message), description: description != null ? _ToastDescriptionWidget(description: description) : null, @@ -30,9 +27,8 @@ extension ContextX on BuildContext { ); } - void showError( - {required String title, - String? description, + void showError(String title, + {String? description, Icon? icon, List<(String, Future Function())> actions = const []}) { toastification.show( @@ -41,12 +37,7 @@ extension ContextX on BuildContext { style: ToastificationStyle.fillColored, alignment: Alignment.topCenter, icon: icon ?? Icon(Icons.error), - title: Text( - title, - style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 4, - ), + title: _ToastTitleWidget(title), showProgressBar: false, closeOnClick: true, description: description != null @@ -64,10 +55,7 @@ extension ContextX on BuildContext { type: ToastificationType.info, icon: Icon(Icons.info), style: ToastificationStyle.fillColored, - title: Text( - 'Zapstore has a new version available', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + title: _ToastTitleWidget('Zapstore has a new version available'), description: Text('Tap to go to update screen'), callbacks: ToastificationCallbacks( onTap: (item) { @@ -82,6 +70,21 @@ extension ContextX on BuildContext { } } +class _ToastTitleWidget extends StatelessWidget { + final String text; + const _ToastTitleWidget(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 4, + ); + } +} + class _ToastDescriptionWidget extends StatelessWidget { final String? description; final List<(String, Future Function())> actions; @@ -93,7 +96,12 @@ class _ToastDescriptionWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText(text: TextSpan(text: description)), + RichText( + text: TextSpan( + text: description, + style: TextStyle(fontSize: 16), + ), + ), for (final (text, fn) in actions) ElevatedButton( style: ElevatedButton.styleFrom( diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index db3f2a7..25dde47 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -51,8 +51,8 @@ class InstallButton extends HookConsumerWidget { if (settings.trustedUsers.contains(app.signer.value!)) { app.install().catchError((e) { if (context.mounted) { - context.showError( - title: 'Could not install', description: e.message); + context.showError('Could not install', + description: e.message); } }); } else { @@ -64,8 +64,8 @@ class InstallButton extends HookConsumerWidget { } else if (app.canUpdate) { app.install().catchError((e) { if (context.mounted) { - context.showError( - title: 'Could not install', description: e.message); + context.showError('Could not install', + description: e.message); } }); } else { @@ -74,8 +74,8 @@ class InstallButton extends HookConsumerWidget { }, ErrorInstallProgress(:final e, :final info, :final actions) => () { // show error and reset state to idle - context.showError( - title: e.message, description: info, actions: actions); + context.showError(e.message, + description: info, actions: actions); ref.read(installationProgressProvider(app.id!).notifier).state = IdleInstallProgress(); }, diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index 36ea33e..a362034 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -17,13 +17,12 @@ class SignInButton extends ConsumerWidget { final bool minimal; final String label; final bool publicKeyAllowed; - final String? signedOutText; - SignInButton( - {super.key, - this.minimal = false, - this.label = 'Sign in', - this.publicKeyAllowed = true, - this.signedOutText}); + SignInButton({ + super.key, + this.minimal = false, + this.label = 'Sign in', + this.publicKeyAllowed = true, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -36,7 +35,6 @@ class SignInButton extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (user == null && signedOutText != null) Text(signedOutText!), Row( children: [ if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), @@ -136,9 +134,8 @@ class SignInDialogBox extends HookConsumerWidget { if (signedInNpub == null) { if (context.mounted) { Navigator.of(context).pop(); - context.showError( - title: "Could not sign in", - description: "Signer did not respond"); + context.showError('Could not sign in', + description: 'Signer did not respond'); } return; } @@ -204,8 +201,7 @@ class SignInDialogBox extends HookConsumerWidget { } } on Exception catch (e, stack) { if (context.mounted) { - context.showError( - title: e.toString(), + context.showError(e.toString(), description: stack.toString().safeSubstring(200)); } } diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 692a70c..7c7fd7d 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -23,12 +23,6 @@ class ZapButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nwcConnection = ref.watch(nwcConnectionProvider); - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; - final amountController = TextEditingController(); final commentController = TextEditingController(); @@ -59,17 +53,33 @@ class ZapButton extends HookConsumerWidget { context: context, builder: (context) { return AlertDialog( - title: Text('Choose zap amount').bold, + title: Text('Zap this release').bold, content: Column( mainAxisSize: MainAxisSize.min, children: [ - if (user == null) - SignInButton( - publicKeyAllowed: false, - minimal: true, - signedOutText: - '⚠️ If you do not sign in, you will be zapping anonymously', - ), + Consumer( + builder: (context, ref, _) { + final user = ref.settings + .watchOne('_', alsoWatch: (_) => {_.user}) + .model! + .user + .value; + + if (user == null) { + return Column( + children: [ + Text( + '⚠️ If you do not sign in, you will be zapping anonymously'), + SignInButton( + publicKeyAllowed: false, + minimal: true, + ), + ], + ); + } + return Container(); + }, + ), TextField( controller: amountController, keyboardType: TextInputType.number, @@ -98,8 +108,8 @@ class ZapButton extends HookConsumerWidget { ), TextField( controller: commentController, - decoration: - InputDecoration(hintText: 'Leave a comment'), + decoration: InputDecoration( + hintText: 'Add a comment (optional)'), ), ], ), @@ -130,11 +140,6 @@ class ZapButton extends HookConsumerWidget { if (amount > 0) { try { - // ignore: use_build_context_synchronously - context.showInfo('Zap sent!', - description: - '$amount sats on their way. Your zap should show up in a few seconds.'); - // Reload user as we are in a callback and it could have changed final loggedInUser = ref.settings.findOneLocalById('_')!.user.value; @@ -147,10 +152,12 @@ class ZapButton extends HookConsumerWidget { signer: pkSigner, comment: comment); } + // ignore: use_build_context_synchronously + context.showInfo('$amount sat${amount > 1 ? 's' : ''} sent!', + description: 'Payment was successful'); } catch (e) { // ignore: use_build_context_synchronously - context.showError( - title: 'Unable to zap', description: e.toString()); + context.showError('Unable to zap', description: e.toString()); } } }, From 204754920c684594972a0a96929a9e6e5751f77d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 18:17:33 -0300 Subject: [PATCH 49/51] UsersRichText improvements, signedInUserProvider --- lib/models/user.dart | 8 +++ lib/screens/settings_screen.dart | 31 +++------ lib/widgets/install_alert_dialog.dart | 12 +--- lib/widgets/sign_in_container.dart | 6 +- lib/widgets/user_avatar.dart | 11 +-- lib/widgets/users_rich_text.dart | 98 ++++++++++++++++----------- lib/widgets/wot_container.dart | 19 ++---- lib/widgets/zap_button.dart | 33 +++++---- lib/widgets/zap_receipts.dart | 21 ++++-- 9 files changed, 125 insertions(+), 114 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index 407b234..fe076fb 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -247,6 +247,14 @@ mixin UserAdapter on NostrAdapter { } } +final signedInUserProvider = Provider((ref) { + return ref.settings + .watchOne('_', alsoWatch: (_) => {_.user}) + .model + ?.user + .value; +}); + const kZapstorePubkey = '78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e7bbcd3..a2aedf5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -29,11 +29,7 @@ class SettingsScreen extends HookConsumerWidget { final systemInfoState = ref.watch(systemInfoNotifierProvider); final feedbackController = useTextEditingController(); - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; + final signedInUser = ref.watch(signedInUserProvider); return SingleChildScrollView( child: Column( @@ -57,16 +53,17 @@ class SettingsScreen extends HookConsumerWidget { maxLines: 10, ), Gap(20), - if (user == null) SignInButton(minimal: true), - if (user != null && - user.settings.value!.signInMethod != SignInMethod.nip55 || + if (signedInUser == null) SignInButton(minimal: true), + if (signedInUser != null && + signedInUser.settings.value!.signInMethod != + SignInMethod.nip55 || !amberSigner.isAvailable) Text( 'To share feedback, you must be signed in with Amber. You can also message us on nostr!', style: TextStyle( color: Colors.red[300], fontWeight: FontWeight.bold)), - if (user != null && - user.settings.value!.signInMethod == SignInMethod.nip55 && + if (signedInUser != null && + signedInUser.settings.value!.signInMethod == SignInMethod.nip55 && amberSigner.isAvailable) AsyncButtonBuilder( loadingWidget: SmallCircularProgressIndicator(), @@ -76,7 +73,7 @@ class SettingsScreen extends HookConsumerWidget { PartialDirectMessage( content: feedbackController.text.trim(), receiver: kZapstorePubkey.npub), - withPubkey: user.pubkey); + withPubkey: signedInUser.pubkey); try { final response = await http.post( Uri.parse('https://relay.zapstore.dev/'), @@ -98,15 +95,9 @@ class SettingsScreen extends HookConsumerWidget { } } }, - builder: (context, child, callback, state) { - return switch (state) { - _ => ElevatedButton( - onPressed: callback, - child: child, - ), - }; - }, - child: Text('Send as ${user.nameOrNpub}'), + builder: (context, child, callback, state) => + ElevatedButton(onPressed: callback, child: child), + child: Text('Send as ${signedInUser.nameOrNpub}'), ), Gap(20), Divider(), diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index d585b1c..77e555a 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -3,9 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/widgets/sign_in_container.dart'; import 'package:zapstore/widgets/author_container.dart'; @@ -22,11 +20,7 @@ class InstallAlertDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final trustedSignerNotifier = useState(false); - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; + final signedInUser = ref.watch(signedInUserProvider); return AlertDialog( elevation: 10, @@ -50,10 +44,10 @@ class InstallAlertDialog extends HookConsumerWidget { Gap(20), if (app.signer.value != null) WebOfTrustContainer( - fromNpub: user?.npub ?? kFranzapPubkey.npub, + fromNpub: signedInUser?.npub ?? kFranzapPubkey.npub, toNpub: app.signer.value!.npub, ), - if (user == null) + if (signedInUser == null) SignInButton( label: 'Sign in to view your web of trust', minimal: true, diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index a362034..96051b9 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -26,11 +26,7 @@ class SignInButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model - ?.user - .value; + final user = ref.watch(signedInUserProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/user_avatar.dart b/lib/widgets/user_avatar.dart index db2797a..2445004 100644 --- a/lib/widgets/user_avatar.dart +++ b/lib/widgets/user_avatar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/models/user.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class UserAvatar extends HookConsumerWidget { @@ -9,11 +8,7 @@ class UserAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; - return RoundedImage(url: user?.avatarUrl, size: 46); + final signedInUser = ref.watch(signedInUserProvider); + return RoundedImage(url: signedInUser?.avatarUrl, size: 46); } } diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart index cefc469..b8a17a7 100644 --- a/lib/widgets/users_rich_text.dart +++ b/lib/widgets/users_rich_text.dart @@ -3,54 +3,74 @@ import 'package:zapstore/models/user.dart'; import 'package:zapstore/widgets/rounded_image.dart'; class UsersRichText extends StatelessWidget { - const UsersRichText({ - super.key, - this.leadingText, - this.trailingText, - required this.users, - }); - - final String? leadingText; + final TextSpan? leadingTextSpan; final String? trailingText; final List users; + final User? signedInUser; + final int? maxUsersToDisplay; + final double fontSize; + + const UsersRichText({ + super.key, + this.leadingTextSpan, + this.trailingText, + required this.users, + this.signedInUser, + this.maxUsersToDisplay, + this.fontSize = 16, + }); @override Widget build(BuildContext context) { + // Move signed in user to head of list + if (users.remove(signedInUser)) { + users.insert(0, signedInUser!); + } + final usersToDisplay = maxUsersToDisplay != null + ? users.take(maxUsersToDisplay!).toList() + : users; + return RichText( text: TextSpan( children: [ - if (leadingText != null) - TextSpan(text: leadingText!, style: TextStyle(fontSize: 15)), - for (final user in users) - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - spacing: 1, - runSpacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - RoundedImage(url: user.avatarUrl, size: 20), - Text( - ' ${user.nameOrNpub}', - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.bold), - ), - Text( - users.indexOf(user) == users.length - 1 - ? '' - : (users.indexOf(user) == users.length - 2 - ? ' and ' - : ', '), - style: TextStyle(fontSize: 15), - ), - ], + for (final user in usersToDisplay) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Wrap( + spacing: 0, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (usersToDisplay.indexOf(user) == 0 && + leadingTextSpan != null) + RichText(text: leadingTextSpan!), + if (user != signedInUser) + Padding( + padding: const EdgeInsets.only(left: 2, right: 2), + child: RoundedImage(url: user.avatarUrl, size: 20), + ), + Text( + user == signedInUser + ? (leadingTextSpan != null ? ' you' : 'You') + : ' ${user.nameOrNpub}', + style: TextStyle( + fontSize: fontSize, fontWeight: FontWeight.bold), ), - ), - if (users.indexOf(user) == users.length - 1) - TextSpan(text: trailingText, style: TextStyle(fontSize: 15)) - ], + Text( + usersToDisplay.indexOf(user) == usersToDisplay.length - 1 + ? '' + : (usersToDisplay.indexOf(user) == + usersToDisplay.length - 2 + ? usersToDisplay.length < users.length + ? ', ' + : ' and ' + : ', '), + style: TextStyle(fontSize: fontSize)), + if (usersToDisplay.indexOf(user) == usersToDisplay.length - 1) + Text( + '${usersToDisplay.length < users.length ? ' and ${users.length - usersToDisplay.length} others' : ''}${trailingText ?? ''}', + style: TextStyle(fontSize: fontSize)) + ], + ), ), ], ), diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index 2b01c4c..9476c75 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -1,10 +1,7 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart' as base; import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; @@ -23,25 +20,20 @@ class WebOfTrustContainer extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final data = ref.watch(followsWhoFollowProvider((from: fromNpub, to: toNpub))); + final signedInUser = ref.watch(signedInUserProvider); return switch (data) { AsyncData>(value: final trustedUsers) => Builder( builder: (context) { - final hasUser = - trustedUsers.firstWhereOrNull((u) => u.npub == fromNpub) != - null; - final trustedUsersWithoutLoggedInUser = - trustedUsers.where((u) => u.npub != fromNpub).toList(); - if (trustedUsersWithoutLoggedInUser.isEmpty) { + if (trustedUsers.isEmpty) { return Text( 'No trusted users between you and the signer. (This may be a service error)'); } return UsersRichText( - leadingText: - hasUser && fromNpub != kFranzapPubkey.npub ? 'You, ' : null, + users: trustedUsers, + signedInUser: signedInUser, trailingText: ' and others follow this signer on nostr.', - users: trustedUsersWithoutLoggedInUser, ); }, ), @@ -66,8 +58,7 @@ class WebOfTrustContainer extends HookConsumerWidget { final followsWhoFollowProvider = FutureProvider.autoDispose .family, ({String from, String to})>((ref, arg) async { - final _ = - ref.settings.watchOne('_', alsoWatch: (_) => {_.user}).model!.user.value; + final _ = ref.watch(signedInUserProvider); final users = await ref.users.userAdapter.getTrusted(arg.from, arg.to); return users.toSet().toList(); }); diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 7c7fd7d..53346cb 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,9 +1,10 @@ +import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/models/user.dart'; import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -30,7 +31,16 @@ class ZapButton extends HookConsumerWidget { return Container(); } - return ElevatedButton( + return AsyncButtonBuilder( + loadingWidget: Text('⚡️ Zapping...').bold, + builder: (context, child, callback, state) => ElevatedButton( + onPressed: callback, + style: ElevatedButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 60, 60, 54), + disabledBackgroundColor: Colors.grey[600], + ), + child: child, + ), onPressed: nwcConnection.isLoading ? null : () async { @@ -59,13 +69,10 @@ class ZapButton extends HookConsumerWidget { children: [ Consumer( builder: (context, ref, _) { - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; + final signedInUser = + ref.watch(signedInUserProvider); - if (user == null) { + if (signedInUser == null) { return Column( children: [ Text( @@ -141,10 +148,10 @@ class ZapButton extends HookConsumerWidget { if (amount > 0) { try { // Reload user as we are in a callback and it could have changed - final loggedInUser = + final signedInUser = ref.settings.findOneLocalById('_')!.user.value; - if (loggedInUser != null) { - await loggedInUser.zap(amount, + if (signedInUser != null) { + await signedInUser.zap(amount, event: app.latestMetadata!, comment: comment); } else { await anonUser!.zap(amount, @@ -161,10 +168,6 @@ class ZapButton extends HookConsumerWidget { } } }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[800], - disabledBackgroundColor: Colors.grey[500], - ), child: Text( '⚡️ Zap this release', style: TextStyle( diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index cbb9243..f22e8ce 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -25,6 +25,8 @@ class ZapReceipts extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final signedInUser = ref.watch(signedInUserProvider); + // NOTE: These watchers will become more efficient ref.users.watchAll(); @@ -47,14 +49,25 @@ class ZapReceipts extends HookConsumerWidget { final receipts = zapReceipts.value!; final totalAmountInSats = receipts.fold(0, (acc, e) => acc + e.amount); - final senderIds = receipts.map((r) => r.senderPubkey).toSet(); - final senders = ref.users.findManyLocalByIds(senderIds); + final zapperIds = receipts.map((r) => r.senderPubkey).toSet(); + final zappers = ref.users.findManyLocalByIds(zapperIds); return Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), child: UsersRichText( - leadingText: '⚡ $totalAmountInSats sats zapped from ', - users: senders, + leadingTextSpan: TextSpan( + style: TextStyle(fontSize: 16), + children: [ + TextSpan(text: '⚡'), + TextSpan( + text: ' $totalAmountInSats sats ', + style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: 'zapped by'), + ], + ), + users: zappers, + signedInUser: signedInUser, + maxUsersToDisplay: 5, ), ); } From 7872cc2f9b9108a6e4f0a02796ea95316e1102b9 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 22:13:48 -0300 Subject: [PATCH 50/51] Remove zap.store migration --- lib/models/app.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/models/app.dart b/lib/models/app.dart index a20c90c..482de7d 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -152,12 +152,7 @@ class App extends base.App with DataModelMixin { await file.delete(); } - var url = latestMetadata!.urls.first; - // TODO: Remove in 0.2.x - if (url.startsWith('https://cdn.zap.store')) { - url = url.replaceFirst( - 'https://cdn.zap.store', 'https://cdn.zapstore.dev'); - } + final url = latestMetadata!.urls.first; final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); From 8013e1cf29c746f4429b81e0b7a60340eed877c2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 5 Jan 2025 22:40:36 -0300 Subject: [PATCH 51/51] Rewrite UsersRichText --- lib/models/app.dart | 1 - lib/widgets/users_rich_text.dart | 105 ++++++++++++++++++------------- lib/widgets/wot_container.dart | 1 + 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/lib/models/app.dart b/lib/models/app.dart index 482de7d..f044117 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -153,7 +153,6 @@ class App extends base.App with DataModelMixin { } final url = latestMetadata!.urls.first; - final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart index b8a17a7..8e82e89 100644 --- a/lib/widgets/users_rich_text.dart +++ b/lib/widgets/users_rich_text.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/widgets/rounded_image.dart'; @@ -9,6 +10,7 @@ class UsersRichText extends StatelessWidget { final User? signedInUser; final int? maxUsersToDisplay; final double fontSize; + final bool onlyUseCommaSeparator; const UsersRichText({ super.key, @@ -17,7 +19,8 @@ class UsersRichText extends StatelessWidget { required this.users, this.signedInUser, this.maxUsersToDisplay, - this.fontSize = 16, + this.fontSize = 15, + this.onlyUseCommaSeparator = false, }); @override @@ -30,50 +33,66 @@ class UsersRichText extends StatelessWidget { ? users.take(maxUsersToDisplay!).toList() : users; - return RichText( - text: TextSpan( - children: [ - for (final user in usersToDisplay) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - spacing: 0, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (usersToDisplay.indexOf(user) == 0 && - leadingTextSpan != null) - RichText(text: leadingTextSpan!), - if (user != signedInUser) - Padding( - padding: const EdgeInsets.only(left: 2, right: 2), - child: RoundedImage(url: user.avatarUrl, size: 20), - ), - Text( - user == signedInUser - ? (leadingTextSpan != null ? ' you' : 'You') - : ' ${user.nameOrNpub}', + final spans = []; + + if (leadingTextSpan != null) { + spans.add(leadingTextSpan!); + } + + usersToDisplay.forEachIndexed((i, user) { + if (user == signedInUser) { + spans.add(TextSpan( + text: leadingTextSpan != null ? ' you' : 'You', + style: TextStyle(fontWeight: FontWeight.bold))); + } else { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + // Add a wrap in order to treat it as a non-breaking block + child: Wrap( + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: RoundedImage(url: user.avatarUrl, size: 20), + ), + Text(user.nameOrNpub, style: TextStyle( - fontSize: fontSize, fontWeight: FontWeight.bold), - ), - Text( - usersToDisplay.indexOf(user) == usersToDisplay.length - 1 - ? '' - : (usersToDisplay.indexOf(user) == - usersToDisplay.length - 2 - ? usersToDisplay.length < users.length - ? ', ' - : ' and ' - : ', '), - style: TextStyle(fontSize: fontSize)), - if (usersToDisplay.indexOf(user) == usersToDisplay.length - 1) - Text( - '${usersToDisplay.length < users.length ? ' and ${users.length - usersToDisplay.length} others' : ''}${trailingText ?? ''}', - style: TextStyle(fontSize: fontSize)) - ], - ), + fontSize: fontSize, fontWeight: FontWeight.bold)), + ], ), - ], - ), + ), + ); + } + + // Before the last span + if (i < usersToDisplay.length - 2) { + spans.add(TextSpan(text: ', ')); + } + + if (i == usersToDisplay.length - 2) { + // If onlyUseCommaSeparator, or users length goes over max to display, + // then add a comma because we'll later add "and others" + if (onlyUseCommaSeparator || usersToDisplay.length < users.length) { + spans.add(TextSpan(text: ', ')); + } else { + spans.add(TextSpan(text: ' and ')); + } + } + + // Last span + if (i == usersToDisplay.length - 1) { + if (usersToDisplay.length < users.length) { + final remainingUsersLength = users.length - usersToDisplay.length; + spans.add(TextSpan(text: ' and $remainingUsersLength others')); + } + if (trailingText != null) { + spans.add(TextSpan(text: trailingText)); + } + } + }); + + return Text.rich( + TextSpan(style: TextStyle(fontSize: fontSize), children: spans), ); } } diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart index 9476c75..fe2e117 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -33,6 +33,7 @@ class WebOfTrustContainer extends HookConsumerWidget { return UsersRichText( users: trustedUsers, signedInUser: signedInUser, + onlyUseCommaSeparator: true, trailingText: ' and others follow this signer on nostr.', ); },