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/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e542ef2..96aba4c 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"> >>{ 'appCurationSets': appCurationSetsAdapterProvider, @@ -19,7 +20,8 @@ final adapterProvidersMap = >>{ 'localApps': localAppsAdapterProvider, 'releases': releasesAdapterProvider, 'settings': settingsAdapterProvider, - 'users': usersAdapterProvider + 'users': usersAdapterProvider, + 'zapReceipts': zapReceiptsAdapterProvider }; extension AdapterWidgetRefX on WidgetRef { @@ -35,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 { @@ -50,4 +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 e063b6b..f044117 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -12,7 +12,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'; @@ -28,7 +28,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; @@ -149,13 +152,7 @@ class App extends BaseApp 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); @@ -266,14 +263,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 { @@ -283,8 +280,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}, @@ -348,10 +346,16 @@ 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 = 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); @@ -387,7 +391,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..6e1d9ed 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,13 @@ 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.fromJson(super.map) : author = belongsTo(map['author']), @@ -30,9 +25,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 fdb61d4..729dbe2 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'}; +const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net', 'ndk'}; 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); @@ -98,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, @@ -144,7 +158,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/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..fe076fb 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,33 +1,130 @@ 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/navigation/app_initializer.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.createdAt, - super.tags, - required this.followers, - required this.following}); +class User extends base.User with DataModelMixin { + @override + Object? get id => event.id; + + Future zap(int amountInSats, + {required base.Event event, String? comment, base.Signer? signer}) 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; + final author = adapter.ref.users.findOneLocalById(authorPubkey); + + if (author?.lud16 == null) { + throw 'Recipient has no Lightning address'; + } + + final amountInMillisats = amountInSats * 1000; + final partialZapRequest = base.PartialZapRequest(); + partialZapRequest + ..addLinkedEvent(event) + ..addLinkedUser(author!) + ..relays = kSocialRelays.where((r) => r != 'ndk') + ..amount = amountInMillisats + ..comment = comment; + + final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner, + withPubkey: pubkey); + + // Now we fetch the invoice + final lnResponse = await author.fetchLightningAddress(); + + final recipientAllowsNostr = lnResponse['allowsNostr']; + final recipientPubkey = lnResponse['nostrPubkey']; + // All amounts are in millisats + final recipientMin = lnResponse['minSendable']; + final recipientMax = lnResponse['maxSendable']; + + if (recipientAllowsNostr != true && recipientPubkey != null) { + throw 'Recipient does not allow nostr'; + } + if ((recipientMin != null && amountInMillisats < recipientMin) || + (recipientMax != null && amountInMillisats > recipientMax)) { + throw 'Amount not between min and max sendable'; + } + + final commentLength = lnResponse['commentAllowed']; + comment = + commentLength != null ? comment?.safeSubstring(commentLength) : comment; + + var callbackUri = Uri.parse(lnResponse['callback']!); + callbackUri = callbackUri.replace( + queryParameters: { + ...callbackUri.queryParameters, + 'amount': amountInMillisats.toString(), + if (comment != null) 'comment': comment, + 'nostr': jsonEncode(zapRequest.toMap()), + }, + ); + + final invoiceResponse = await http.get(callbackUri); + final invoiceMap = jsonDecode(invoiceResponse.body); + + final invoice = invoiceMap['pr']; + if (invoice == null) { + throw 'No invoice'; + } + + // Pay the invoice via NWC (errors are thrown) + await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, + invoice: invoice, timeout: Duration(seconds: 20)); + } + + Future> fetchLightningAddress() 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']), following = hasMany(map['following']), + 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') 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.shorten; } mixin UserAdapter on NostrAdapter { @@ -48,7 +145,7 @@ mixin UserAdapter on NostrAdapter { return []; } - final request = RelayRequest( + final request = base.RelayRequest( kinds: {0}, // 3 authors: {...authors}, since: queriedAtMap[authors.join()], @@ -86,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'), @@ -109,14 +201,14 @@ mixin UserAdapter on NostrAdapter { ); } - final result = await socialRelays.queryRaw(RelayRequest( + final result = await socialRelays.queryRaw(base.RelayRequest( kinds: {0}, // 3 tags: params ?? {}, authors: {publicKey}, )); final data = await deserializeAsync(result, save: true); - return data.models.firstWhere((e) { + return data.models.firstWhereOrNull((e) { return e.id == publicKey; }); } @@ -155,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/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/models/zap_receipt.dart b/lib/models/zap_receipt.dart new file mode 100644 index 0000000..746d777 --- /dev/null +++ b/lib/models/zap_receipt.dart @@ -0,0 +1,43 @@ +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, ZapReceiptAdapter]) +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); + } + + List findByRecipient( + {required String pubkey, required String eventId}) { + final result = db.select( + "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); + } +} diff --git a/lib/models/zap_receipt.g.dart b/lib/models/zap_receipt.g.dart new file mode 100644 index 0000000..a106043 --- /dev/null +++ b/lib/models/zap_receipt.g.dart @@ -0,0 +1,72 @@ +// 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, 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 + 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/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index 98fa70f..875abad 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -6,11 +6,15 @@ 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 { sharedPreferences = await SharedPreferences.getInstance(); @@ -42,6 +46,15 @@ final appInitializer = FutureProvider((ref) async { // Preload zapstore's nostr curation set ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch(); + // Initialize signer + await amberSigner.initialize(); + + // Set up anon user (pubkey derives from pkSigner secret key) + anonUser ??= User.fromPubkey( + 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b') + .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 b049fec..2e43d8e 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -16,8 +16,10 @@ 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'; class AppDetailScreen extends HookConsumerWidget { final App model; @@ -33,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: (_) => { @@ -57,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( @@ -147,14 +149,17 @@ class AppDetailScreen extends HookConsumerWidget { ), ), selectable: false, - data: app.content.parseEmojis(), + data: app.event.content.parseEmojis(), ), Gap(10), Padding( padding: const EdgeInsets.only(right: 14), child: SignerAndDeveloperRow(app: app), ), - Gap(20), + Gap(10), + ZapButton(app: app), + ZapReceipts(app: app), + Gap(10), Container( padding: EdgeInsets.all(10), decoration: BoxDecoration( @@ -218,7 +223,7 @@ class AppDetailScreen extends HookConsumerWidget { Gap(10), Flexible( child: AutoSizeText( - app.identifier!, + app.identifier, minFontSize: 12, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -247,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), @@ -280,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/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 952cdaa..a2aedf5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,15 +9,17 @@ 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: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/system_info.dart'; -import 'package:zapstore/widgets/app_drawer.dart'; import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.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/utils/nwc.dart'; +import 'package:zapstore/utils/system_info.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; class SettingsScreen extends HookConsumerWidget { const SettingsScreen({super.key}); @@ -26,12 +28,8 @@ class SettingsScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final systemInfoState = ref.watch(systemInfoNotifierProvider); - final controller = useTextEditingController(); - final user = ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model! - .user - .value; + final feedbackController = useTextEditingController(); + final signedInUser = ref.watch(signedInUserProvider); return SingleChildScrollView( child: Column( @@ -51,51 +49,69 @@ class SettingsScreen extends HookConsumerWidget { Text('Comments, suggestions and error reports welcome here.'), Gap(20), TextField( - controller: controller, + controller: feedbackController, maxLines: 10, ), Gap(20), - if (user == null) LoginContainer(minimal: true), - if (user != null) + 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 (signedInUser != null && + signedInUser.settings.value!.signInMethod == SignInMethod.nip55 && + amberSigner.isAvailable) AsyncButtonBuilder( - loadingWidget: SizedBox( - width: 14, height: 14, child: CircularProgressIndicator()), + loadingWidget: SmallCircularProgressIndicator(), 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); + if (feedbackController.text.trim().isNotEmpty) { + final signedDirectMessage = await amberSigner.sign( + PartialDirectMessage( + content: feedbackController.text.trim(), + receiver: kZapstorePubkey.npub), + withPubkey: signedInUser.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'); } - controller.clear(); + feedbackController.clear(); } catch (e, stack) { if (context.mounted) { - context.showError( - title: (e as dynamic).message ?? e.toString(), + context.showError(e.toString(), description: stack.toString()); } } } }, - 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(40), + Gap(20), Divider(), - Gap(40), + Gap(20), + Text( + 'Nostr Wallet Connect', + style: context.theme.textTheme.headlineLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + Gap(20), + NwcContainer(), + Gap(20), + Divider(), + Gap(20), Text( 'Tools', style: context.theme.textTheme.headlineLarge! @@ -180,8 +196,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()); } } @@ -196,3 +211,97 @@ class SettingsScreen 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 NwcContainer({ + 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 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'), + ), + ), + ), + 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/extensions.dart b/lib/utils/extensions.dart index b2f30f6..2337154 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'; @@ -16,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, @@ -29,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( @@ -40,11 +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, - ), + title: _ToastTitleWidget(title), showProgressBar: false, closeOnClick: true, description: description != null @@ -62,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) { @@ -80,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; @@ -91,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( @@ -108,12 +118,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(); @@ -125,8 +135,16 @@ extension StringWidget on String { }); } - String substringMax(int size) { - return substring(0, min(length, 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() { @@ -134,6 +152,40 @@ extension StringWidget on String { } } -const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58"; +extension TextExt on Text { + Widget get bold { + return Text(data!, style: TextStyle(fontWeight: FontWeight.bold)); + } +} 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/utils/nwc.dart b/lib/utils/nwc.dart new file mode 100644 index 0000000..a6cc54a --- /dev/null +++ b/lib/utils/nwc.dart @@ -0,0 +1,76 @@ +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(this.ref) : super(AsyncLoading()) { + ndk = ref.users.nostrAdapter.socialRelays.ndk!; + // Attempt to load from local storage on initialization + _storage + .read(key: kNwcSecretKey) + .then(connectWallet) + .catchError((e, stack) { + state = AsyncError(e, stack); + }); + } + + Future connectWallet([String? nwcSecret]) async { + if (nwcSecret == null) { + // We get here when storage has no secret, do nothing + state = AsyncData(null); + return; + } + + state = AsyncValue.loading(); + + // 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 ndk.nwc.disconnect(state.value!); + } + + // Write secret to storage and connect + await _storage.write(key: kNwcSecretKey, value: nwcSecret); + + 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); + } + } + + Future disconnectWallet() async { + await _storage.delete(key: kNwcSecretKey); + state = AsyncData(null); + } +} + +final nwcConnectionProvider = + StateNotifierProvider>( + (ref) => NwcConnectionNotifier(ref)); + +extension NwcExt on AsyncValue { + bool get isPresent { + return hasValue && value != null && !hasError; + } +} diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart new file mode 100644 index 0000000..e7e6cc1 --- /dev/null +++ b/lib/utils/signers.dart @@ -0,0 +1,58 @@ +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>(PartialEvent partialEvent, + {String? withPubkey}) async { + if (!isAvailable) { + throw Exception("Cannot sign, missing Amber"); + } + + if (partialEvent is DirectMessage) { + final signedMessage = await _signerPlugin.nip04Encrypt( + partialEvent.event.content, + "", + withPubkey!.npub, + (partialEvent as DirectMessage).receiver.hexKey); + final encryptedContent = signedMessage['result']; + partialEvent.event.content = encryptedContent; + } + + // Remove all null fields (Amber otherwise crashes) + final map = { + for (final e in partialEvent.toMap().entries) + if (e.value != null) e.key: e.value + }; + final signedMessage = + await _signerPlugin.signEvent(jsonEncode(map), "", withPubkey!); + final signedEvent = jsonDecode(signedMessage['event']); + return Event.getConstructor()!.call(signedEvent); + } +} + +final pkSigner = Bip340PrivateKeySigner( + 'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58'); 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/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/author_container.dart b/lib/widgets/author_container.dart index 6333d86..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 { @@ -44,7 +43,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/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..f877ecb 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'; @@ -49,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, @@ -148,7 +149,8 @@ Future _sendErrorReport( 'info': systemInfo.androidInfo.toString() }; - final event = AppFeedback(content: jsonEncode(map)).sign(kI); + 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 4b4692e..77e555a 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -2,12 +2,10 @@ 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:zapstore/main.data.dart'; +import 'package:purplebase/purplebase.dart' as base; 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'; @@ -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,13 +44,13 @@ 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) - LoginContainer( + if (signedInUser == null) + 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/install_button.dart b/lib/widgets/install_button.dart index 2f81bdf..25dde47 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() => () { @@ -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/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 6cffdeb..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) @@ -125,7 +122,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 new file mode 100644 index 0000000..96051b9 --- /dev/null +++ b/lib/widgets/sign_in_container.dart @@ -0,0 +1,228 @@ +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' as base; +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; + final bool publicKeyAllowed; + SignInButton({ + super.key, + this.minimal = false, + this.label = 'Sign in', + this.publicKeyAllowed = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(signedInUserProvider); + + 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: SignInDialogBox( + publicKeyAllowed: publicKeyAllowed, + ), + ), + ); + } else { + ref.settings.findOneLocalById('_')!.user.value = null; + } + }, + child: Text(user == null ? label : 'Sign out'), + ), + ], + ); + } +} + +class SignInDialogBox extends HookConsumerWidget { + final bool publicKeyAllowed; + const SignInDialogBox({super.key, this.publicKeyAllowed = true}); + + @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: SmallCircularProgressIndicator(), + 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('Could not sign in', + description: 'Signer did not respond'); + } + return; + } + + 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.fromPubkey(signedInNpub.hexKey) + .init() + .saveLocal(); + + final settings = ref.settings.findOneLocalById('_')!; + settings.signInMethod = SignInMethod.nip55; + settings.user.value = user; + settings.saveLocal(); + + if (context.mounted) { + Navigator.of(context).pop(user); + } + }, + child: Text('Sign in with Amber'), + ), + ], + ), + if (amberSigner.isAvailable && publicKeyAllowed) + Padding( + padding: const EdgeInsets.all(10), + child: Text('or'), + ), + 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; + }, + ), + Gap(10), + AsyncButtonBuilder( + disabled: isTextFieldEmpty.value, + loadingWidget: SmallCircularProgressIndicator(), + 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(e.toString(), + description: stack.toString().safeSubstring(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/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 new file mode 100644 index 0000000..8e82e89 --- /dev/null +++ b/lib/widgets/users_rich_text.dart @@ -0,0 +1,98 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class UsersRichText extends StatelessWidget { + final TextSpan? leadingTextSpan; + final String? trailingText; + final List users; + final User? signedInUser; + final int? maxUsersToDisplay; + final double fontSize; + final bool onlyUseCommaSeparator; + + const UsersRichText({ + super.key, + this.leadingTextSpan, + this.trailingText, + required this.users, + this.signedInUser, + this.maxUsersToDisplay, + this.fontSize = 15, + this.onlyUseCommaSeparator = false, + }); + + @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; + + 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)), + ], + ), + ), + ); + } + + // 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 15fdf09..fe2e117 100644 --- a/lib/widgets/wot_container.dart +++ b/lib/widgets/wot_container.dart @@ -1,12 +1,10 @@ -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: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/screens/settings_screen.dart'; +import 'package:zapstore/widgets/users_rich_text.dart'; class WebOfTrustContainer extends HookConsumerWidget { const WebOfTrustContainer({ @@ -22,26 +20,21 @@ 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( - preSpan: hasUser && fromNpub != kFranzapPubkey.npub - ? TextSpan(text: 'You, ') - : null, + users: trustedUsers, + signedInUser: signedInUser, + onlyUseCommaSeparator: true, trailingText: ' and others follow this signer on nostr.', - users: trustedUsersWithoutLoggedInUser, ); }, ), @@ -55,11 +48,7 @@ class WebOfTrustContainer extends HookConsumerWidget { children: [ Text('Loading web of trust...'), Gap(10), - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), + SmallCircularProgressIndicator(), ], ), ), @@ -68,60 +57,9 @@ 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 _ = - 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 new file mode 100644 index 0000000..53346cb --- /dev/null +++ b/lib/widgets/zap_button.dart @@ -0,0 +1,178 @@ +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/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/utils/signers.dart'; +import 'package:zapstore/widgets/sign_in_container.dart'; + +import '../utils/nwc.dart'; + +class ZapButton extends HookConsumerWidget { + ZapButton({ + super.key, + required this.app, + }); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nwcConnection = ref.watch(nwcConnectionProvider); + final amountController = TextEditingController(); + final commentController = TextEditingController(); + + if (app.developer.value?.lud16 == null) { + return Container(); + } + + 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 { + 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 valueRecord = await showDialog<(int, String)>( + // ignore: use_build_context_synchronously + context: context, + builder: (context) { + return AlertDialog( + title: Text('Zap this release').bold, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Consumer( + builder: (context, ref, _) { + final signedInUser = + ref.watch(signedInUserProvider); + + if (signedInUser == 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, + decoration: + InputDecoration(hintText: 'Enter amount in sats'), + ), + Gap(20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () => amountController.text = '21', + child: Text('⚡️21'), + ), + Gap(3), + ElevatedButton( + onPressed: () => amountController.text = '210', + child: Text('⚡️210'), + ), + Gap(3), + ElevatedButton( + onPressed: () => amountController.text = '2100', + child: Text('⚡️2100'), + ), + ], + ), + TextField( + controller: commentController, + decoration: InputDecoration( + hintText: 'Add a comment (optional)'), + ), + ], + ), + actions: [ + TextButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text('Zap').bold, + onPressed: () { + final record = ( + int.tryParse(amountController.text) ?? 0, + commentController.text + ); + Navigator.of(context).pop(record); + }, + ), + ], + ); + }, + ); + + if (valueRecord == null) { + return; + } + final (amount, comment) = valueRecord; + + if (amount > 0) { + try { + // Reload user as we are in a callback and it could have changed + final signedInUser = + ref.settings.findOneLocalById('_')!.user.value; + if (signedInUser != null) { + await signedInUser.zap(amount, + event: app.latestMetadata!, comment: comment); + } else { + await anonUser!.zap(amount, + event: app.latestMetadata!, + 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('Unable to zap', description: e.toString()); + } + } + }, + child: Text( + '⚡️ Zap this release', + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), + ), + ); + } +} diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart new file mode 100644 index 0000000..f22e8ce --- /dev/null +++ b/lib/widgets/zap_receipts.dart @@ -0,0 +1,161 @@ +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.app, + }); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInUser = ref.watch(signedInUserProvider); + + // NOTE: These watchers will become more efficient + ref.users.watchAll(); + + final fileMetadata = app.latestMetadata; + if (fileMetadata == null) { + return Container(); + } + + 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 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( + 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, + ), + ); + } +} + +class ZapReceiptsNotifier extends StateNotifier>> { + Ref ref; + final FileMetadata fileMetadata; + StreamSubscription>? sub; + + ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) { + final adapter = ref.users.nostrAdapter; + final developerPubkey = + fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey; + + // 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 localZapReceipts = ref.zapReceipts.zapReceiptAdapter + .findByRecipient( + pubkey: developerPubkey, eventId: fileMetadata.id!.toString()) + .toSet(); + _loadZappers(localZapReceipts); + state = AsyncData(localZapReceipts); + + // NOTE: ideally this caching stuff should be handled by purplebase + final latestReceiptTimestamp = localZapReceipts + .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 = 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(); + super.dispose(); + } +} + +final zapReceiptsNotifier = StateNotifierProvider.autoDispose + .family>, FileMetadata>( + ZapReceiptsNotifier.new); 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 7312b74..47c6921 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: @@ -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: @@ -310,14 +318,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: @@ -342,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: @@ -452,6 +460,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 @@ -526,6 +582,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: @@ -558,14 +622,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: @@ -626,10 +682,10 @@ packages: 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 +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: @@ -742,6 +806,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + ndk: + dependency: "direct main" + description: + name: ndk + sha256: "07c284a14e7800513743a84c911a546dcfcdc0c0ce2def4895c4e4c2286f159e" + url: "https://pub.dev" + source: hosted + version: "0.2.4" octo_image: dependency: transitive description: @@ -945,11 +1017,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 @@ -959,14 +1029,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: @@ -1063,6 +1125,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 2fffde0..56e6524 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: intl: ^0.18.1 device_info_plus: ^11.2.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 @@ -51,17 +52,23 @@ 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.4 dependency_overrides: purplebase: - # path: ../purplebase - git: - url: https://github.com/purplebase/purplebase - ref: 7f0eefd1fd1bcbc947123dc377183dbae58ea97a + path: ../purplebase + # git: + # url: https://github.com/purplebase/purplebase + # ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc 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: