Developer screen WIP, remove concept of developer and just refer to signer as developer

This commit is contained in:
franzap
2025-03-20 22:37:10 -03:00
parent 1fc42cecc3
commit 9799d7f0c6
15 changed files with 240 additions and 187 deletions
+6 -18
View File
@@ -34,12 +34,10 @@ class App extends base.App with DataModelMixin<App> {
final HasMany<Release> releases;
final BelongsTo<User> signer;
final BelongsTo<User> developer;
final BelongsTo<LocalApp> localApp;
App.fromJson(super.map)
: developer = belongsTo(map['developer']),
releases = hasMany(map['releases']),
: releases = hasMany(map['releases']),
signer = belongsTo(map['signer']),
localApp = belongsTo(map['localApp']),
super.fromJson();
@@ -74,6 +72,9 @@ class App extends base.App with DataModelMixin<App> {
bool get hasCertificateMismatch =>
localApp.value?.status == AppInstallStatus.certificateMismatch;
bool get isSelfSigned => !(signer.value!.pubkey == kZapstorePubkey &&
identifier != kZapstoreAppIdentifier);
Future<void> install({bool alwaysTrustSigner = false}) async {
if (!canInstall && !canUpdate || hasCertificateMismatch) {
throw Exception(
@@ -221,7 +222,7 @@ mixin AppAdapter on Adapter<App> {
// If looking up multiple apps via `#d`, use `queriedAtMap`
// to find the earliest queried at Date, to be used in the
// next request `since` field
if (params.containsKey('#d') && !params['skipCache']) {
if (params.containsKey('#d') && !(params['skipCache'] ?? false)) {
DateTime? earliestQueryAt;
final identifiers = <String>{...params['#d']};
@@ -348,21 +349,8 @@ mixin AppAdapter on Adapter<App> {
for (final Map<String, dynamic> map in list) {
final tags = map['tags'];
final pubkey = map['pubkey'];
map['localApp'] = base.BaseUtil.getTag(tags, 'd')!;
// Find developer
final zapTag = base.BaseUtil.getTag(tags, 'zap');
if (pubkey == kZapstorePubkey) {
if (zapTag != null) {
map['developer'] = zapTag;
}
if (map['localApp'] == kZapstoreAppIdentifier) {
map['developer'] = pubkey;
}
} else {
map['developer'] = pubkey;
}
map['signer'] = map['pubkey'];
}
return super.deserialize(data);
+1 -13
View File
@@ -19,16 +19,11 @@ mixin _$AppAdapter on Adapter<App> {
),
'signer': RelationshipMeta<User>(
name: 'signer',
inverseName: 'apps',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as App).signer,
),
'developer': RelationshipMeta<User>(
name: 'developer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as App).developer,
),
'localApp': RelationshipMeta<LocalApp>(
name: 'localApp',
type: 'localApps',
@@ -81,13 +76,6 @@ extension AppRelationshipGraphNodeX on RelationshipGraphNode<App> {
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get developer {
final meta = _$AppAdapter._kAppRelationshipMetas['developer']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<LocalApp> get localApp {
final meta = _$AppAdapter._kAppRelationshipMetas['localApp']
as RelationshipMeta<LocalApp>;
+1 -5
View File
@@ -164,11 +164,7 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
final metadataIds =
releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _);
final apps = releases.map((r) => r.app.value).nonNulls;
final userIds = {
for (final app in apps) app.signer.id,
for (final app in apps) app.developer.id,
}.nonNulls;
final userIds = apps.map((a) => a.signer.id).nonNulls;
// Metadata and users probably go to separate relays
// so query in parallel
+5
View File
@@ -6,6 +6,7 @@ import 'package:flutter_data/flutter_data.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/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/verify_reputation_dvm.dart';
@@ -24,6 +25,7 @@ class User extends base.User with DataModelMixin<User> {
: followers = hasMany(map['followers']),
following = hasMany(map['following']),
settings = belongsTo(map['settings']),
apps = hasMany(map['apps']),
super.fromJson();
User.fromPubkey(String pubkey)
@@ -45,6 +47,9 @@ class User extends base.User with DataModelMixin<User> {
@DataRelationship(inverse: 'user')
final BelongsTo<Settings> settings;
@DataRelationship(inverse: 'signer')
final HasMany<App> apps;
String get nameOrNpub => name ?? npub.shorten;
// Zaps
+14
View File
@@ -30,6 +30,13 @@ mixin _$UserAdapter on Adapter<User> {
type: 'settings',
kind: 'BelongsTo',
instance: (_) => (_ as User).settings,
),
'apps': RelationshipMeta<App>(
name: 'apps',
inverseName: 'signer',
type: 'apps',
kind: 'HasMany',
instance: (_) => (_ as User).apps,
)
};
@@ -84,4 +91,11 @@ extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<App> get apps {
final meta =
_$UserAdapter._kUserRelationshipMetas['apps'] as RelationshipMeta<App>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
+14 -14
View File
@@ -4,10 +4,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/navigation/app_initializer.dart';
import 'package:zapstore/navigation/desktop_scaffold.dart';
import 'package:zapstore/navigation/mobile_scaffold.dart';
import 'package:zapstore/screens/app_detail_screen.dart';
import 'package:zapstore/screens/developer_screen.dart';
import 'package:zapstore/screens/search_screen.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/screens/updates_screen.dart';
@@ -37,13 +39,7 @@ final appRouter = GoRouter(
GoRoute(
path: '/',
builder: (context, state) => SearchScreen(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) =>
AppDetailScreen(model: state.extra as App),
),
],
routes: [appDetailsRoute, developerRoute],
),
],
),
@@ -53,13 +49,7 @@ final appRouter = GoRouter(
GoRoute(
path: '/updates',
builder: (context, state) => UpdatesScreen(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) =>
AppDetailScreen(model: state.extra as App),
),
],
routes: [appDetailsRoute, developerRoute],
),
],
),
@@ -79,6 +69,16 @@ final appRouter = GoRouter(
],
);
final appDetailsRoute = GoRoute(
path: 'details',
builder: (context, state) => AppDetailScreen(model: state.extra as App),
);
final developerRoute = GoRoute(
path: 'developer',
builder: (context, state) => DeveloperScreen(model: state.extra as User),
);
class ScaffoldWithNestedNavigation extends HookConsumerWidget {
const ScaffoldWithNestedNavigation({
Key? key,
+15 -4
View File
@@ -14,9 +14,10 @@ import 'package:zapstore/models/release.dart';
import 'package:zapstore/utils/extensions.dart';
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/signer_container.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
import 'package:zapstore/widgets/versioned_app_header.dart';
import 'package:zapstore/widgets/zap_button.dart';
import 'package:zapstore/widgets/zap_receipts.dart';
class AppDetailScreen extends HookConsumerWidget {
@@ -43,7 +44,6 @@ class AppDetailScreen extends HookConsumerWidget {
_.releases,
_.releases.artifacts,
_.signer,
_.developer
});
final app = state.model ?? model;
@@ -166,9 +166,20 @@ class AppDetailScreen extends HookConsumerWidget {
Gap(10),
Padding(
padding: const EdgeInsets.only(right: 14),
child: SignerAndDeveloperRow(app: app),
child: SignerContainer(app: app),
),
ZapReceipts(app: app),
if (app.latestMetadata != null && app.isSelfSigned)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(10),
SizedBox(
width: double.infinity,
child: ZapButton(app: app)),
Gap(10),
ZapReceipts(app: app),
],
),
Gap(20),
Container(
padding: EdgeInsets.all(10),
+91
View File
@@ -0,0 +1,91 @@
import 'package:auto_size_text/auto_size_text.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:url_launcher/url_launcher.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/app_card.dart';
import 'package:zapstore/widgets/author_container.dart';
import 'package:zapstore/widgets/relevant_who_follow_container.dart';
import 'package:zapstore/widgets/rounded_image.dart';
import 'package:zapstore/widgets/user_avatar.dart';
import 'package:zapstore/widgets/zap_receipts.dart';
class DeveloperScreen extends HookConsumerWidget {
final User model;
DeveloperScreen({
required this.model,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useFuture(useMemoized(() async {
if (model.pubkey != kZapstorePubkey) {
await ref.apps.findAll(
params: {
'authors': [model.pubkey],
'ignoreReturn': false,
},
);
}
}));
final state = ref.users.watchOne(model.id!, alsoWatch: (_) => {_.apps});
return SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
RoundedImage(url: model.avatarUrl, size: 80),
Gap(10),
Expanded(
child: AutoSizeText(
model.nameOrNpub,
style: TextStyle(fontSize: 34, fontWeight: FontWeight.bold),
),
),
],
),
Gap(20),
// if (model.apps.first.latestMetadata != null)
// ZapReceipts(developer: model),
RelevantWhoFollowContainer(
toNpub: model.npub,
loadingText: 'Loading connections...',
),
Gap(10),
ElevatedButton(
onPressed: () {
final url = Uri.parse('https://npub.world/${model.npub}');
launchUrl(url);
},
child: Text(
'View ${model.nameOrNpub} on nostr',
),
),
Gap(20),
Text('Signed apps',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Gap(10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.hasModel)
for (final app in model.apps.where((a) => a.isSelfSigned))
AppCard(model: app, showUpdate: true),
],
),
],
),
);
}
}
+2 -7
View File
@@ -23,13 +23,8 @@ class UpdatesScreen extends HookConsumerWidget {
key: UniqueKey(),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text('Installed apps',
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
],
),
Text('Installed apps',
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
if (updatedApps.isEmpty && updatableApps.isEmpty)
SpinningLogo(size: 80),
if (updatableApps.isNotEmpty)
+3 -9
View File
@@ -7,7 +7,6 @@ import 'package:remove_markdown/remove_markdown.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/author_container.dart';
import 'package:zapstore/widgets/install_button.dart';
@@ -33,9 +32,7 @@ class AppCard extends HookConsumerWidget {
final isUpdate = app.canUpdate && showUpdate;
return GestureDetector(
onTap: () {
context.go('${showUpdate ? '/updates' : ''}/details', extra: app);
},
onTap: () => context.push('/details', extra: app),
child: Card(
margin: EdgeInsets.only(top: 6, bottom: 6),
elevation: 0,
@@ -82,10 +79,7 @@ class AppCard extends HookConsumerWidget {
softWrap: true,
),
Gap(6),
// Only show Signed by apps' original authors
if (app.signer.isPresent &&
(app.signer.value!.pubkey != kZapstorePubkey ||
{kZapstoreAppIdentifier}.contains(app.identifier)))
if (app.signer.isPresent && app.isSelfSigned)
AuthorContainer(
user: app.signer.value!,
beforeText: 'Signed by',
@@ -186,7 +180,7 @@ class TinyAppCard extends HookConsumerWidget {
return GestureDetector(
onTap: () {
if (app != null) {
context.go('/details', extra: app);
context.push('/details', extra: app);
}
},
child: Card(
+14 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/screens/settings_screen.dart';
@@ -10,8 +11,10 @@ class RelevantWhoFollowContainer extends HookConsumerWidget {
const RelevantWhoFollowContainer({
super.key,
required this.toNpub,
this.loadingText = 'Checking reputation...',
});
final String loadingText;
final String toNpub;
@override
@@ -19,16 +22,23 @@ class RelevantWhoFollowContainer extends HookConsumerWidget {
final signedInUser = ref.watch(signedInUserProvider);
final data = ref.watch(relevantWhoFollowProvider((to: toNpub)));
if (signedInUser == null) {
return Container();
}
return switch (data) {
AsyncData<List<User>>(value: final trustedUsers) => Builder(
AsyncData<List<User>>(value: final users) => Builder(
builder: (context) {
if (trustedUsers.isEmpty) {
// Find relevant who follow, remove the target that comes included
final relevantWhoFollow =
users.where((u) => u.pubkey.npub != toNpub).toList();
if (relevantWhoFollow.isEmpty) {
return Text(
'No reputable profiles follow the signer. Make sure you know the signer.');
}
return UsersRichText(
users: trustedUsers,
users: relevantWhoFollow,
signedInUser: signedInUser,
onlyUseCommaSeparator: true,
trailingText: ' and others follow this signer on nostr.',
@@ -42,7 +52,7 @@ class RelevantWhoFollowContainer extends HookConsumerWidget {
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text('Checking reputation...'),
Text(loadingText),
Gap(10),
SmallCircularProgressIndicator(),
],
-60
View File
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/widgets/author_container.dart';
class SignerAndDeveloperRow extends ConsumerWidget {
const SignerAndDeveloperRow({
super.key,
required this.app,
});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsState = ref.settings.watchOne('_', remote: false);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (app.developer.isPresent)
GestureDetector(
onTap: () async {
final url =
Uri.parse('https://njump.me/${app.developer.value!.npub}');
launchUrl(url);
},
child: AuthorContainer(
user: app.developer.value!,
beforeText: 'Developed by',
oneLine: false),
),
if (app.signer.isPresent)
GestureDetector(
onTap: () async {
final url =
Uri.parse('https://njump.me/${app.signer.value!.npub}');
launchUrl(url);
},
child: Row(
children: [
AuthorContainer(
user: app.signer.value!,
beforeText: 'Signed by',
oneLine: false,
afterText: settingsState.hasModel &&
settingsState.model!.trustedUsers
.contains(app.signer.value!)
? '(trusted)'
: null,
),
],
),
),
],
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/widgets/author_container.dart';
class SignerContainer extends ConsumerWidget {
const SignerContainer({
super.key,
required this.app,
});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsState = ref.settings.watchOne('_', remote: false);
if (!app.signer.isPresent) {
return Container();
}
return GestureDetector(
onTap: () async {
if (app.isSelfSigned) {
context.push('/developer', extra: app.signer.value);
} else {
final url = Uri.parse('https://npub.world/${app.signer.value!.npub}');
launchUrl(url);
}
},
child: Row(
children: [
AuthorContainer(
user: app.signer.value!,
beforeText: 'Signed by',
oneLine: false,
afterText: settingsState.hasModel &&
settingsState.model!.trustedUsers
.contains(app.signer.value!)
? '(trusted)'
: null,
),
],
),
);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class ZapButton extends HookConsumerWidget {
final amountController = TextEditingController();
final commentController = TextEditingController();
if (app.developer.value?.lud16 == null) {
if (app.signer.value?.lud16 == null) {
return Container();
}
+23 -52
View File
@@ -16,7 +16,6 @@ import 'package:zapstore/models/zap_receipt.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
import 'package:zapstore/widgets/zap_button.dart';
class ZapReceipts extends HookConsumerWidget {
ZapReceipts({
@@ -33,30 +32,21 @@ class ZapReceipts extends HookConsumerWidget {
// NOTE: These watchers will become more efficient
ref.users.watchAll();
final fileMetadata = app.latestMetadata;
// NOTE: Only show zaps for self-signed apps (at least for now)
final isSelfSigned = app.signer.value == app.developer.value;
if (fileMetadata == null || !isSelfSigned) {
return Container();
}
final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata));
final zapReceipts = ref.watch(zapReceiptsNotifier(app.latestMetadata!));
if (zapReceipts.isLoading) {
return _wrap(
Row(
children: [
Gap(4),
Text('Loading zaps...'),
Gap(10),
SmallCircularProgressIndicator(),
],
),
return Row(
children: [
Gap(4),
Text('Loading zaps...'),
Gap(10),
SmallCircularProgressIndicator(),
],
);
}
if (zapReceipts.value!.isEmpty) {
return _wrap(Container());
return Container();
}
final receipts = zapReceipts.value!;
@@ -75,36 +65,20 @@ class ZapReceipts extends HookConsumerWidget {
return totalUserAmount.wrapped;
});
return _wrap(
UsersRichText(
leadingTextSpan: TextSpan(
style: TextStyle(fontSize: 16, height: 1.7),
children: [
TextSpan(text: '⚡'),
TextSpan(
text: ' $formattedSatsAmount sats ',
style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'zapped by'),
],
),
users: zappers,
signedInUser: signedInUser,
maxUsersToDisplay: kMaxUsersToDisplay,
),
);
}
Widget _wrap(Widget inner) {
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
return UsersRichText(
leadingTextSpan: TextSpan(
style: TextStyle(fontSize: 16, height: 1.7),
children: [
SizedBox(width: double.maxFinite, child: ZapButton(app: app)),
inner,
TextSpan(text: '⚡'),
TextSpan(
text: ' $formattedSatsAmount sats ',
style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'zapped by'),
],
),
users: zappers,
signedInUser: signedInUser,
maxUsersToDisplay: kMaxUsersToDisplay,
);
}
}
@@ -119,13 +93,10 @@ class ZapReceiptsNotifier extends StateNotifier<AsyncValue<Set<ZapReceipt>>> {
ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) {
final adapter = ref.users.nostrAdapter;
final developerPubkey =
fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey;
final developerPubkey = fileMetadata.event.pubkey;
final app = fileMetadata.release.value!.app.value!;
// If it is a Zapstore-signed release from the indexer, return
if (developerPubkey == kZapstorePubkey &&
fileMetadata.release.value!.app.value!.identifier !=
kZapstoreAppIdentifier) {
if (!app.isSelfSigned) {
state = AsyncData({});
return;
}