mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-06 04:58:23 +00:00
Merge branch 'zaps'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
android:label="Zapstore"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
|
||||
+7
-1
@@ -11,6 +11,7 @@ import 'package:zapstore/models/local_app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/models/zap_receipt.dart';
|
||||
|
||||
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
||||
'appCurationSets': appCurationSetsAdapterProvider,
|
||||
@@ -19,7 +20,8 @@ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
||||
'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<Settings> get settings =>
|
||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
||||
Adapter<ZapReceipt> get zapReceipts =>
|
||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
||||
}
|
||||
|
||||
extension AdapterRefX on Ref {
|
||||
@@ -50,4 +54,6 @@ extension AdapterRefX on Ref {
|
||||
Adapter<Settings> get settings =>
|
||||
watch(settingsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
|
||||
Adapter<ZapReceipt> get zapReceipts =>
|
||||
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
|
||||
}
|
||||
|
||||
+23
-19
@@ -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<App> {
|
||||
class App extends base.App with DataModelMixin<App> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
final HasMany<Release> releases;
|
||||
final BelongsTo<User> signer;
|
||||
final BelongsTo<User> developer;
|
||||
@@ -149,13 +152,7 @@ class App extends BaseApp with DataModelMixin<App> {
|
||||
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<App> {
|
||||
// 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<App> {
|
||||
|
||||
// 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<App> {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
|
||||
for (final Map<String, dynamic> 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<bool> _isHashMismatch(String path, String hash) async {
|
||||
|
||||
extension AppExt on Iterable<App> {
|
||||
List<App> get sortedByLatest =>
|
||||
sorted((a, b) => b.createdAt!.compareTo(a.createdAt!));
|
||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
}
|
||||
|
||||
// class
|
||||
|
||||
@@ -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<AppCurationSet> {
|
||||
@override
|
||||
Object get id => event.id;
|
||||
|
||||
final HasMany<App> apps;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
@@ -21,7 +24,7 @@ class AppCurationSet extends BaseAppCurationSet
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
String get name => content.isNotEmpty ? content : identifier!;
|
||||
String get name => event.content.isNotEmpty ? event.content : identifier;
|
||||
}
|
||||
|
||||
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
@@ -45,8 +48,8 @@ mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final Map<String, dynamic> 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);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
|
||||
class AppFeedback extends BaseEvent<AppFeedback> {
|
||||
@override
|
||||
int get kind => 1011;
|
||||
|
||||
AppFeedback({
|
||||
required String content,
|
||||
}) : super(content: content);
|
||||
class AppFeedback extends RegularEvent<AppFeedback> {
|
||||
AppFeedback.fromJson(super.map) : super.fromJson();
|
||||
}
|
||||
|
||||
class PartialAppFeedback extends RegularPartialEvent<AppFeedback> {
|
||||
PartialAppFeedback({
|
||||
required String content,
|
||||
}) {
|
||||
event.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FileMetadata> {
|
||||
class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
|
||||
final BelongsTo<User> author;
|
||||
final BelongsTo<Release> release;
|
||||
final BelongsTo<User> 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<FileMetadata> {
|
||||
Map<String, dynamic> 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<FileMetadata> {
|
||||
|
||||
@@ -144,7 +144,7 @@ mixin LocalAppAdapter on Adapter<LocalApp> {
|
||||
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) {
|
||||
|
||||
@@ -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<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
RelayMessageNotifier get relay =>
|
||||
@@ -52,7 +52,10 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
}
|
||||
|
||||
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<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
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<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
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<T>);
|
||||
@@ -98,6 +105,13 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
return r.isNotEmpty;
|
||||
}
|
||||
|
||||
Iterable<String> existingIds(Iterable<Object> 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<List<T>> findAll(
|
||||
{bool? remote,
|
||||
@@ -144,7 +158,7 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
|
||||
Future<void> loadArtifactsAndUsers(Iterable<Release> 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 = {
|
||||
|
||||
+16
-13
@@ -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<Release> {
|
||||
class Release extends base.Release with DataModelMixin<Release> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
final HasMany<FileMetadata> artifacts;
|
||||
final BelongsTo<App> app;
|
||||
final BelongsTo<User> 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<Release> {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
|
||||
for (final Map<String, dynamic> 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<Release> {
|
||||
|
||||
extension ReleaseExt on Iterable<Release> {
|
||||
List<Release> get sortedByLatest =>
|
||||
sorted((a, b) => b.createdAt!.compareTo(a.createdAt!));
|
||||
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ class Settings extends DataModel<Settings> {
|
||||
String get id => '_';
|
||||
final BelongsTo<User> user = BelongsTo();
|
||||
final HasMany<User> trustedUsers = HasMany();
|
||||
SignInMethod? signInMethod;
|
||||
}
|
||||
|
||||
enum SignInMethod {
|
||||
pubkey,
|
||||
nip55,
|
||||
}
|
||||
|
||||
mixin SettingsAdapter on Adapter<Settings> {
|
||||
|
||||
@@ -12,12 +12,14 @@ mixin _$SettingsAdapter on Adapter<Settings> {
|
||||
static final Map<String, RelationshipMeta> _kSettingsRelationshipMetas = {
|
||||
'user': RelationshipMeta<User>(
|
||||
name: 'user',
|
||||
inverseName: 'settings',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as Settings).user,
|
||||
),
|
||||
'trustedUsers': RelationshipMeta<User>(
|
||||
name: 'trustedUsers',
|
||||
inverseName: 'settings',
|
||||
type: 'users',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as Settings).trustedUsers,
|
||||
@@ -73,6 +75,15 @@ extension SettingsRelationshipGraphNodeX on RelationshipGraphNode<Settings> {
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings();
|
||||
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
|
||||
..signInMethod =
|
||||
$enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']);
|
||||
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{};
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||
'signInMethod': _$SignInMethodEnumMap[instance.signInMethod],
|
||||
};
|
||||
|
||||
const _$SignInMethodEnumMap = {
|
||||
SignInMethod.pubkey: 'pubkey',
|
||||
SignInMethod.nip55: 'nip55',
|
||||
};
|
||||
|
||||
+118
-18
@@ -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> {
|
||||
User(
|
||||
{super.createdAt,
|
||||
super.tags,
|
||||
required this.followers,
|
||||
required this.following});
|
||||
class User extends base.User with DataModelMixin<User> {
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
Future<void> 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<Map<String, dynamic>> 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<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
@DataRelationship(inverse: 'followers')
|
||||
final HasMany<User> following;
|
||||
@DataRelationship(inverse: 'following')
|
||||
final HasMany<User> followers;
|
||||
@DataRelationship(inverse: 'user')
|
||||
final BelongsTo<Settings> settings;
|
||||
|
||||
String get nameOrNpub => name ?? '${npub.substring(0, 10)}...';
|
||||
String get nameOrNpub => name ?? npub.shorten;
|
||||
}
|
||||
|
||||
mixin UserAdapter on NostrAdapter<User> {
|
||||
@@ -48,7 +145,7 @@ mixin UserAdapter on NostrAdapter<User> {
|
||||
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<User> {
|
||||
|
||||
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<dynamic>(
|
||||
Uri.parse('https://$domain/.well-known/nostr.json?name=$username'),
|
||||
@@ -109,14 +201,14 @@ mixin UserAdapter on NostrAdapter<User> {
|
||||
);
|
||||
}
|
||||
|
||||
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<User> {
|
||||
}
|
||||
}
|
||||
|
||||
final signedInUserProvider = Provider<User?>((ref) {
|
||||
return ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model
|
||||
?.user
|
||||
.value;
|
||||
});
|
||||
|
||||
const kZapstorePubkey =
|
||||
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ mixin _$UserAdapter on Adapter<User> {
|
||||
type: 'users',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as User).followers,
|
||||
),
|
||||
'settings': RelationshipMeta<Settings>(
|
||||
name: 'settings',
|
||||
inverseName: 'user',
|
||||
type: 'settings',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as User).settings,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -70,4 +77,11 @@ extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<Settings> get settings {
|
||||
final meta = _$UserAdapter._kUserRelationshipMetas['settings']
|
||||
as RelationshipMeta<Settings>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ZapReceipt> {
|
||||
final BelongsTo<User> recipient;
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
@override
|
||||
Object? get id => event.id;
|
||||
|
||||
ZapReceipt.fromJson(super.map)
|
||||
: recipient = belongsTo(map['recipient']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
}
|
||||
|
||||
mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
|
||||
@override
|
||||
DeserializedData<ZapReceipt> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p');
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
|
||||
List<ZapReceipt> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ZapReceipt> {
|
||||
static final Map<String, RelationshipMeta> _kZapReceiptRelationshipMetas = {
|
||||
'recipient': RelationshipMeta<User>(
|
||||
name: 'recipient',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as ZapReceipt).recipient,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
name: 'signer',
|
||||
type: 'users',
|
||||
kind: 'BelongsTo',
|
||||
instance: (_) => (_ as ZapReceipt).signer,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kZapReceiptRelationshipMetas;
|
||||
|
||||
@override
|
||||
ZapReceipt deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = model.toJson();
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _zapReceiptsFinders = <String, dynamic>{};
|
||||
|
||||
class $ZapReceiptAdapter = Adapter<ZapReceipt>
|
||||
with _$ZapReceiptAdapter, NostrAdapter<ZapReceipt>, ZapReceiptAdapter;
|
||||
|
||||
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
|
||||
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
|
||||
|
||||
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
|
||||
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
|
||||
ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter;
|
||||
}
|
||||
|
||||
extension ZapReceiptRelationshipGraphNodeX
|
||||
on RelationshipGraphNode<ZapReceipt> {
|
||||
RelationshipGraphNode<User> get recipient {
|
||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
|
||||
RelationshipGraphNode<User> get signer {
|
||||
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer']
|
||||
as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
@@ -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<void>((ref) async {
|
||||
sharedPreferences = await SharedPreferences.getInstance();
|
||||
@@ -42,6 +46,15 @@ final appInitializer = FutureProvider<void>((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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+76
-24
@@ -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<void> 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<void> 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<T> belongsTo<T extends DataModelMixin<T>>(Map<String, dynamic> map) {
|
||||
return BelongsTo<T>.fromJson(map);
|
||||
BelongsTo<T> belongsTo<T extends DataModelMixin<T>>(Map<String, dynamic>? map) {
|
||||
return map != null ? BelongsTo<T>.fromJson(map) : BelongsTo<T>();
|
||||
}
|
||||
|
||||
HasMany<T> hasMany<T extends DataModelMixin<T>>(Map<String, dynamic> map) {
|
||||
return HasMany<T>.fromJson(map);
|
||||
HasMany<T> hasMany<T extends DataModelMixin<T>>(Map<String, dynamic>? map) {
|
||||
return map != null ? HasMany<T>.fromJson(map) : HasMany<T>();
|
||||
}
|
||||
|
||||
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<List<T>> bufferByTime<T>(Stream<T> source, Duration duration) {
|
||||
final controller = StreamController<List<T>>();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<AsyncValue<NwcConnection?>> {
|
||||
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<void> 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<void> disconnectWallet() async {
|
||||
await _storage.delete(key: kNwcSecretKey);
|
||||
state = AsyncData(null);
|
||||
}
|
||||
}
|
||||
|
||||
final nwcConnectionProvider =
|
||||
StateNotifierProvider<NwcConnectionNotifier, AsyncValue<NwcConnection?>>(
|
||||
(ref) => NwcConnectionNotifier(ref));
|
||||
|
||||
extension NwcExt on AsyncValue<NwcConnection?> {
|
||||
bool get isPresent {
|
||||
return hasValue && value != null && !hasError;
|
||||
}
|
||||
}
|
||||
@@ -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<AmberSigner> initialize() async {
|
||||
final hasExternalSigner = await _signerPlugin
|
||||
.isExternalSignerInstalled('com.greenart7c3.nostrsigner');
|
||||
if (hasExternalSigner) {
|
||||
await _signerPlugin.setPackageName('com.greenart7c3.nostrsigner');
|
||||
isAvailable = true;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getPublicKey() async {
|
||||
final map = await _signerPlugin.getPublicKey();
|
||||
print('got $map');
|
||||
return map['npub'] ?? map['result'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<E> sign<E extends Event<E>>(PartialEvent<E> 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<E>()!.call(signedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
final pkSigner = Bip340PrivateKeySigner(
|
||||
'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58');
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AppCurationSet, ReplaceableEventLink> {
|
||||
extends FamilyAsyncNotifier<AppCurationSet, base.ReplaceableEventLink> {
|
||||
@override
|
||||
Future<AppCurationSet> build(ReplaceableEventLink arg) async {
|
||||
Future<AppCurationSet> 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<ReplaceableEventLink>((_) => kNostrCurationSetLink);
|
||||
StateProvider<base.ReplaceableEventLink>((_) => kNostrCurationSetLink);
|
||||
|
||||
const kNostrCurationSetLink = (30267, kFranzapPubkey, 'nostr');
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> _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'});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<List<App>> {
|
||||
|
||||
// Set timestamp of oldest, to prepare for next query
|
||||
if (apps.isNotEmpty) {
|
||||
_oldestCreatedAt = apps.last.createdAt;
|
||||
_oldestCreatedAt = apps.last.event.createdAt;
|
||||
}
|
||||
return AsyncData(apps);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<User> 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 = <InlineSpan>[];
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<User>>(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<User> 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<List<User>, ({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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AsyncValue<Set<ZapReceipt>>> {
|
||||
Ref ref;
|
||||
final FileMetadata fileMetadata;
|
||||
StreamSubscription<List<Nip01Event>>? 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<void> _loadZappers(Iterable<ZapReceipt> 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<ZapReceiptsNotifier, AsyncValue<Set<ZapReceipt>>, FileMetadata>(
|
||||
ZapReceiptsNotifier.new);
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
gtk
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+104
-33
@@ -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:
|
||||
|
||||
+11
-4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user