mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-06 04:58:23 +00:00
Organize widgets, fix includes bug
This commit is contained in:
+5
-8
@@ -21,11 +21,12 @@ import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:zapstore/screens/search_screen.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
|
||||
part 'app.g.dart';
|
||||
|
||||
const kAndroidMimeType = 'application/vnd.android.package-archive';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, AppAdapter])
|
||||
class App extends BaseApp with DataModelMixin<App> {
|
||||
@@ -215,16 +216,18 @@ mixin AppAdapter on Adapter<App> {
|
||||
}
|
||||
|
||||
Future<List<App>> loadAppModels(Map<String, dynamic> params) async {
|
||||
final includes = params.remove('includes') ?? false;
|
||||
final apps = await super.findAll(params: params);
|
||||
final releases =
|
||||
await ref.releases.findAll(params: {'#a': apps.map((app) => app.aTag)});
|
||||
final metadataIds = releases.map((r) => r.tagMap['e']!).expand((_) => _);
|
||||
|
||||
await ref.fileMetadata.findAll(params: {
|
||||
'ids': metadataIds,
|
||||
'#m': [kAndroidMimeType]
|
||||
});
|
||||
|
||||
if (params.containsKey('includes')) {
|
||||
if (includes) {
|
||||
final userIds = {
|
||||
for (final app in apps) app.signer.id,
|
||||
for (final app in apps) app.developer.id
|
||||
@@ -251,12 +254,6 @@ mixin AppAdapter on Adapter<App> {
|
||||
params['#d'] = map.keys;
|
||||
params.remove('installed');
|
||||
print('filtering by installed ${params['#d']}');
|
||||
|
||||
// final apps = findAllLocal();
|
||||
// if (apps.isNotEmpty) {
|
||||
// loadAppModels(params);
|
||||
// return apps;
|
||||
// }
|
||||
return await loadAppModels(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
import 'package:zapstore/widgets/app_drawer.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
import 'package:zapstore/widgets/rounded_image.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/versioned_app_header.dart';
|
||||
|
||||
class AppDetailScreen extends HookConsumerWidget {
|
||||
final App model;
|
||||
@@ -38,7 +32,7 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
{_.releases, _.releases.artifacts, _.signer, _.developer});
|
||||
useFuture(useMemoized(() =>
|
||||
ref.apps.findOne(model.id!, remote: true, params: {'includes': true})));
|
||||
// hack to refresh on install changes
|
||||
// TODO fix - hack to refresh on install changes
|
||||
final _ = ref.watch(installedAppProvider);
|
||||
|
||||
final app = state.model ?? model;
|
||||
@@ -272,455 +266,3 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SignerAndDeveloperRow extends StatelessWidget {
|
||||
const SignerAndDeveloperRow({
|
||||
super.key,
|
||||
required this.app,
|
||||
});
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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!, text: 'Built by', oneLine: false),
|
||||
),
|
||||
if (app.signer.isPresent)
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url =
|
||||
Uri.parse('https://njump.me/${app.signer.value!.npub}');
|
||||
launchUrl(url);
|
||||
},
|
||||
child: AuthorContainer(
|
||||
user: app.signer.value!, text: 'Signed by', oneLine: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VersionedAppHeader extends StatelessWidget {
|
||||
const VersionedAppHeader({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.showUpdate = false,
|
||||
});
|
||||
|
||||
final bool showUpdate;
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUpdate = app.canUpdate && showUpdate;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
RoundedImage(
|
||||
url: app.icons.firstOrNull,
|
||||
size: 80,
|
||||
radius: 25,
|
||||
),
|
||||
Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AutoSizeText(
|
||||
app.name!,
|
||||
minFontSize: 16,
|
||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Gap(14),
|
||||
Wrap(
|
||||
children: [
|
||||
if (isUpdate)
|
||||
PillWidget(
|
||||
text: app.installedVersion!, color: Colors.grey[800]!),
|
||||
if (isUpdate) Icon(Icons.arrow_right),
|
||||
if (app.latestMetadata != null)
|
||||
PillWidget(
|
||||
text: app.latestMetadata!.version!,
|
||||
color: Colors.grey[800]!,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUpdate)
|
||||
SizedBox(
|
||||
width: 90,
|
||||
height: 40,
|
||||
child: InstallButton(
|
||||
app: app,
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReleaseCard extends StatelessWidget {
|
||||
ReleaseCard({
|
||||
super.key,
|
||||
required this.release,
|
||||
});
|
||||
|
||||
final Release release;
|
||||
final formatter = DateFormat('dd MMM yyyy');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final metadata = release.app.value!.latestMetadata;
|
||||
return Card(
|
||||
margin: EdgeInsets.only(top: 8, bottom: 8),
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(release.version,
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
|
||||
Gap(10),
|
||||
release.content.length < 3000
|
||||
? MarkdownBody(data: release.content)
|
||||
: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: Markdown(data: release.content),
|
||||
),
|
||||
Gap(30),
|
||||
if (metadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text('Version'), Text(metadata.version!)],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Date'),
|
||||
Text(formatter.format(release.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (metadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Size'),
|
||||
Text('${metadata.size! ~/ 1024 ~/ 1024} MB')
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InstallButton extends ConsumerWidget {
|
||||
InstallButton({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.compact = false,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
final bool disabled;
|
||||
final bool compact;
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progress = ref.watch(installationProgressProvider(app.identifier));
|
||||
|
||||
return GestureDetector(
|
||||
onTap: switch (app.status) {
|
||||
AppInstallStatus.differentArchitecture => null,
|
||||
AppInstallStatus.downgrade => null,
|
||||
AppInstallStatus.updated => () {
|
||||
packageManager.openApp(app.id!.toString());
|
||||
},
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => () {
|
||||
// show trust dialog only if first install
|
||||
if (disabled) {
|
||||
context.showError('Missing signer');
|
||||
}
|
||||
if (app.canInstall) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return InstallAlertDialog(app: app);
|
||||
},
|
||||
);
|
||||
} else if (app.canUpdate) {
|
||||
app.install();
|
||||
}
|
||||
},
|
||||
ErrorInstallProgress(:final e) => () {
|
||||
// show error and reset state to idle
|
||||
context.showError((e as dynamic).message);
|
||||
ref
|
||||
.read(installationProgressProvider(app.id!.toString())
|
||||
.notifier)
|
||||
.state = IdleInstallProgress();
|
||||
},
|
||||
_ => null,
|
||||
}
|
||||
},
|
||||
child: LinearPercentIndicator(
|
||||
lineHeight: 40,
|
||||
percent: switch (progress) {
|
||||
VerifyingHashProgress() => 1,
|
||||
DownloadingInstallProgress(:final progress) => progress,
|
||||
_ => switch (app.status) {
|
||||
AppInstallStatus.updated => 1,
|
||||
_ => 0,
|
||||
},
|
||||
},
|
||||
backgroundColor: switch (progress) {
|
||||
ErrorInstallProgress() => Colors.red,
|
||||
_ => Colors.blue[700],
|
||||
},
|
||||
progressColor: Colors.blue[800],
|
||||
barRadius: Radius.circular(20),
|
||||
padding: EdgeInsets.all(0),
|
||||
animateFromLastPercent: true,
|
||||
center: switch (app.status) {
|
||||
AppInstallStatus.loading =>
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
AppInstallStatus.differentArchitecture => Text(
|
||||
'Sorry, release does not support your device',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
AppInstallStatus.downgrade => Text(
|
||||
'Installed version ${app.installedVersion ?? ''} is higher, can\'t downgrade',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
AppInstallStatus.updated => Text('Open'),
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => app.canUpdate
|
||||
? AutoSizeText(
|
||||
'Update${compact ? '' : ' to ${app.latestMetadata!.version!}'}',
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text('Install'),
|
||||
DownloadingInstallProgress(:final progress) => Text(
|
||||
'${(progress * 100).floor()}%',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
VerifyingHashProgress() => compact
|
||||
? SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator())
|
||||
: Text('Verifying file integrity'),
|
||||
HashVerifiedInstallProgress() => compact
|
||||
? SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator())
|
||||
: Text(
|
||||
'Hash verified, requesting ${app.canUpdate ? 'update' : 'installation'}'),
|
||||
ErrorInstallProgress() => compact
|
||||
? SizedBox(width: 14, height: 14, child: Icon(Icons.error))
|
||||
: Text('Error, tap to see message'),
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InstallAlertDialog extends ConsumerWidget {
|
||||
const InstallAlertDialog({
|
||||
super.key,
|
||||
required this.app,
|
||||
});
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model!
|
||||
.user
|
||||
.value;
|
||||
return AlertDialog(
|
||||
elevation: 10,
|
||||
title: Text(
|
||||
'Are you sure you want to install ${app.name}?',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'By installing this app you are trusting the signer now and for all future updates. Make sure you know who they are.'),
|
||||
Gap(20),
|
||||
// SignerAndDeveloperRow(app: app),
|
||||
if (app.signer.value != null)
|
||||
AuthorContainer(
|
||||
user: app.signer.value!, text: 'Signed by', oneLine: true),
|
||||
Gap(20),
|
||||
if (app.signer.value != null)
|
||||
WebOfTrustContainer(
|
||||
user: user, npub: user?.npub, npub2: app.signer.value!.npub),
|
||||
if (user != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(20),
|
||||
Text('The app will be downloaded from:\n'),
|
||||
Text(
|
||||
app.latestMetadata!.urls.firstOrNull ??
|
||||
'https://cdn.zap.store/${app.latestMetadata!.hash}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (user == null)
|
||||
LoginContainer(
|
||||
minimal: true,
|
||||
labelText: 'Log in to view your own web of trust',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
app.install();
|
||||
// NOTE: can't use context.pop()
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: user != null
|
||||
? Text('Install', style: TextStyle(fontWeight: FontWeight.bold))
|
||||
: Text(
|
||||
'I trust the signer, install anyway',
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// NOTE: can't use context.pop()
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Go back'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final wotProvider = FutureProvider.autoDispose
|
||||
.family<List<User>, ({String npub1, String npub2})>((ref, arg) async {
|
||||
final _ =
|
||||
ref.settings.watchOne('_', alsoWatch: (_) => {_.user}).model!.user.value;
|
||||
return ref.users.userAdapter.getTrusted(arg.npub1, arg.npub2);
|
||||
});
|
||||
|
||||
const franzapsNpub =
|
||||
'npub1wf4pufsucer5va8g9p0rj5dnhvfeh6d8w0g6eayaep5dhps6rsgs43dgh9';
|
||||
|
||||
class WebOfTrustContainer extends HookConsumerWidget {
|
||||
const WebOfTrustContainer({
|
||||
super.key,
|
||||
this.user,
|
||||
String? npub,
|
||||
required this.npub2,
|
||||
});
|
||||
|
||||
final User? user;
|
||||
final String npub2;
|
||||
String get npub => user?.npub ?? franzapsNpub;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return switch (ref.watch(wotProvider((npub1: npub, npub2: npub2)))) {
|
||||
AsyncData<List<User>>(value: final trustedUsers) => Builder(
|
||||
builder: (context) {
|
||||
final hasUser =
|
||||
trustedUsers.firstWhereOrNull((u) => u.npub == npub) != null;
|
||||
final trustedUsersWithoutUser =
|
||||
trustedUsers.where((u) => u.npub != npub).toList();
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
if (hasUser && npub != franzapsNpub) TextSpan(text: 'You, '),
|
||||
for (final trustedUser in trustedUsersWithoutUser)
|
||||
TextSpan(
|
||||
style: TextStyle(height: 1.6),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
RoundedImage(
|
||||
url: trustedUser.avatarUrl, size: 20),
|
||||
Text(
|
||||
' ${trustedUser.nameOrNpub}${trustedUsersWithoutUser.indexOf(trustedUser) == trustedUsersWithoutUser.length - 1 ? '' : ', '}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trustedUsersWithoutUser.indexOf(trustedUser) ==
|
||||
trustedUsersWithoutUser.length - 1)
|
||||
TextSpan(
|
||||
text: ' and others follow this signer on nostr.',
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncError(:final error) =>
|
||||
Center(child: Text('Error checking web of trust: $error')),
|
||||
_ => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+12
-191
@@ -1,19 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_layout_grid/flutter_layout_grid.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/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
import 'package:zapstore/widgets/categories_container.dart';
|
||||
import 'package:zapstore/widgets/latest_releases_container.dart';
|
||||
import 'package:zapstore/widgets/user_avatar.dart';
|
||||
|
||||
const kAndroidMimeType = 'application/vnd.android.package-archive';
|
||||
|
||||
class SearchScreen extends HookConsumerWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@@ -21,7 +18,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final state = ref.watch(searchResultProvider);
|
||||
final searchResultState = ref.watch(searchResultProvider);
|
||||
final scrollController = useScrollController();
|
||||
|
||||
return Column(
|
||||
@@ -52,7 +49,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
trailing: [
|
||||
if (state.isLoading)
|
||||
if (searchResultState.isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: SizedBox(
|
||||
@@ -61,7 +58,8 @@ class SearchScreen extends HookConsumerWidget {
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
),
|
||||
if (controller.text.isNotEmpty && !state.isLoading)
|
||||
if (controller.text.isNotEmpty &&
|
||||
!searchResultState.isLoading)
|
||||
IconButton(
|
||||
hoverColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
@@ -90,7 +88,8 @@ class SearchScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
Gap(10),
|
||||
if (state.hasError) Text(state.error!.toString()),
|
||||
if (searchResultState.hasError)
|
||||
Text(searchResultState.error!.toString()),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
@@ -98,7 +97,8 @@ class SearchScreen extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
key: UniqueKey(),
|
||||
children: [
|
||||
if ((state.value?.isEmpty ?? false) && !state.isLoading)
|
||||
if ((searchResultState.value?.isEmpty ?? false) &&
|
||||
!searchResultState.isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Center(
|
||||
@@ -107,8 +107,8 @@ class SearchScreen extends HookConsumerWidget {
|
||||
style: TextStyle(fontSize: 16),
|
||||
)),
|
||||
),
|
||||
if (state.value?.isNotEmpty ?? false)
|
||||
for (final app in state.value!) AppCard(app: app),
|
||||
if (searchResultState.value?.isNotEmpty ?? false)
|
||||
for (final app in searchResultState.value!) AppCard(app: app),
|
||||
Gap(20),
|
||||
CategoriesContainer(),
|
||||
Gap(20),
|
||||
@@ -123,175 +123,6 @@ class SearchScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Categories
|
||||
|
||||
class AppCategoriesNotifier
|
||||
extends FamilyAsyncNotifier<List<App>, AppCategory> {
|
||||
@override
|
||||
FutureOr<List<App>> build(AppCategory arg) async {
|
||||
if (ref.appCurationSets.countLocal < 4) {
|
||||
// Find apps for all categories, so next provider will find it locally
|
||||
final appSets = await ref.appCurationSets
|
||||
.findAll(params: {'#d': AppCategory.values.map((e) => e.name)});
|
||||
await ref.apps
|
||||
.findAll(params: {'#d': appSets.map((s) => s.appIds).flattened});
|
||||
}
|
||||
|
||||
final curationSet = ref.appCurationSets.findOneLocalById(arg.name)!;
|
||||
return ref.apps
|
||||
.findAllLocal()
|
||||
.where((a) => curationSet.appIds.contains(a.id))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final categoriesAppProvider =
|
||||
AsyncNotifierProvider.family<AppCategoriesNotifier, List<App>, AppCategory>(
|
||||
AppCategoriesNotifier.new);
|
||||
|
||||
final selectedAppCategoryProvider = StateProvider((_) => AppCategory.basics);
|
||||
|
||||
class CategoriesContainer extends HookConsumerWidget {
|
||||
const CategoriesContainer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrollController = useScrollController();
|
||||
final selectedCategory = ref.watch(selectedAppCategoryProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Discover apps',
|
||||
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
Gap(12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: scrollController,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in AppCategory.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(selectedAppCategoryProvider.notifier)
|
||||
.state = i,
|
||||
child: PillWidget(
|
||||
text: i.label,
|
||||
color: i == selectedCategory
|
||||
? Colors.blue[700]!
|
||||
: Colors.grey[800]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Gap(12),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final state = ref.watch(categoriesAppProvider(selectedCategory));
|
||||
return WrapLayout(
|
||||
apps: switch (state) {
|
||||
AsyncData(:final value) => value,
|
||||
_ => [],
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WrapLayout extends StatelessWidget {
|
||||
const WrapLayout({
|
||||
super.key,
|
||||
required this.apps,
|
||||
this.columns = 4,
|
||||
});
|
||||
|
||||
final List<App> apps;
|
||||
final int columns;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutGrid(
|
||||
key: UniqueKey(),
|
||||
columnGap: 10,
|
||||
rowGap: 10,
|
||||
rowSizes:
|
||||
List<FixedTrackSize>.generate((8 / columns).ceil(), (_) => 130.px),
|
||||
columnSizes: List<FlexibleTrackSize>.generate(columns, (_) => 1.fr),
|
||||
children:
|
||||
List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier<List<App>> {
|
||||
@override
|
||||
Future<List<App>> build() async {
|
||||
final timer = Timer.periodic(Duration(hours: 4), (_) => fetch());
|
||||
ref.onDispose(timer.cancel);
|
||||
fetch();
|
||||
return localFetch();
|
||||
}
|
||||
|
||||
List<App> localFetch() {
|
||||
return ref.releases
|
||||
.findAllLocal()
|
||||
.sorted((a, b) => b.createdAt.compareTo(a.createdAt))
|
||||
.take(10)
|
||||
.map((r) => r.app.value)
|
||||
.nonNulls
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> fetch() async {
|
||||
final releases = await ref.releases.findAll(params: {'limit': 10});
|
||||
final appIds = releases.map((r) => r.app.id!.toString()).toSet();
|
||||
await ref.apps.findAll(params: {'#d': appIds});
|
||||
update((_) => localFetch());
|
||||
}
|
||||
}
|
||||
|
||||
final latestReleasesAppProvider =
|
||||
AsyncNotifierProvider.autoDispose<LatestReleasesAppNotifier, List<App>>(
|
||||
LatestReleasesAppNotifier.new);
|
||||
|
||||
class LatestReleasesContainer extends HookConsumerWidget {
|
||||
const LatestReleasesContainer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestReleasesAppProvider);
|
||||
final apps = state.value ?? [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Latest releases',
|
||||
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
Gap(12),
|
||||
Column(
|
||||
children: (apps.isEmpty ? [null, null, null] : apps)
|
||||
.map((app) => AppCard(app: app))
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
|
||||
final searchQueryProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
final searchResultProvider =
|
||||
@@ -308,13 +139,3 @@ class SearchResultNotifier extends AsyncNotifier<List<App>?> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
enum AppCategory {
|
||||
basics(label: 'Basics'),
|
||||
nostr(label: 'Nostr'),
|
||||
bitcoin(label: 'Bitcoin'),
|
||||
privacy(label: 'Privacy & Security');
|
||||
|
||||
final String label;
|
||||
const AppCategory({required this.label});
|
||||
}
|
||||
|
||||
@@ -142,5 +142,3 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58";
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/screens/app_detail_screen.dart';
|
||||
import 'package:zapstore/widgets/versioned_app_header.dart';
|
||||
|
||||
class UpdatesScreen extends HookConsumerWidget {
|
||||
const UpdatesScreen({super.key});
|
||||
|
||||
@@ -36,3 +36,5 @@ extension ContextX on BuildContext {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58";
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_layout_grid/flutter_layout_grid.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/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
|
||||
class AppCategoriesNotifier
|
||||
extends FamilyAsyncNotifier<List<App>, AppCategory> {
|
||||
@override
|
||||
Future<List<App>> build(AppCategory arg) async {
|
||||
if (ref.appCurationSets.countLocal < 4) {
|
||||
// Find apps for all categories, so next provider will find it locally
|
||||
final appSets = await ref.appCurationSets
|
||||
.findAll(params: {'#d': AppCategory.values.map((e) => e.name)});
|
||||
await ref.apps
|
||||
.findAll(params: {'#d': appSets.map((s) => s.appIds).flattened});
|
||||
}
|
||||
|
||||
final curationSet = ref.appCurationSets.findOneLocalById(arg.name)!;
|
||||
return ref.apps
|
||||
.findAllLocal()
|
||||
.where((a) => curationSet.appIds.contains(a.id))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final categoriesAppProvider =
|
||||
AsyncNotifierProvider.family<AppCategoriesNotifier, List<App>, AppCategory>(
|
||||
AppCategoriesNotifier.new);
|
||||
|
||||
final selectedAppCategoryProvider = StateProvider((_) => AppCategory.basics);
|
||||
|
||||
class CategoriesContainer extends HookConsumerWidget {
|
||||
const CategoriesContainer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrollController = useScrollController();
|
||||
final selectedCategory = ref.watch(selectedAppCategoryProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Discover apps',
|
||||
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
Gap(12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: scrollController,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in AppCategory.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(selectedAppCategoryProvider.notifier)
|
||||
.state = i,
|
||||
child: PillWidget(
|
||||
text: i.label,
|
||||
color: i == selectedCategory
|
||||
? Colors.blue[700]!
|
||||
: Colors.grey[800]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Gap(12),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final state = ref.watch(categoriesAppProvider(selectedCategory));
|
||||
return WrapLayout(
|
||||
apps: switch (state) {
|
||||
AsyncData(:final value) => value,
|
||||
_ => [],
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WrapLayout extends StatelessWidget {
|
||||
const WrapLayout({
|
||||
super.key,
|
||||
required this.apps,
|
||||
this.columns = 4,
|
||||
});
|
||||
|
||||
final List<App> apps;
|
||||
final int columns;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutGrid(
|
||||
key: UniqueKey(),
|
||||
columnGap: 10,
|
||||
rowGap: 10,
|
||||
rowSizes:
|
||||
List<FixedTrackSize>.generate((8 / columns).ceil(), (_) => 130.px),
|
||||
columnSizes: List<FlexibleTrackSize>.generate(columns, (_) => 1.fr),
|
||||
children:
|
||||
List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum AppCategory {
|
||||
basics(label: 'Basics'),
|
||||
nostr(label: 'Nostr'),
|
||||
bitcoin(label: 'Bitcoin'),
|
||||
privacy(label: 'Privacy & Security');
|
||||
|
||||
final String label;
|
||||
const AppCategory({required this.label});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/screens/settings_screen.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
import 'package:zapstore/utils/theme.dart';
|
||||
|
||||
@@ -56,6 +56,15 @@ class ErrorContainer extends HookConsumerWidget {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Gap(12),
|
||||
Text(
|
||||
'Reporting an error will send device information',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Gap(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -68,7 +77,7 @@ class ErrorContainer extends HookConsumerWidget {
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Color.fromARGB(255, 255, 66, 66),
|
||||
backgroundColor: Color.fromARGB(255, 251, 89, 89),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||
import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/system_info.dart';
|
||||
import 'package:zapstore/widgets/app_drawer.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/wot_container.dart';
|
||||
|
||||
class InstallButton extends ConsumerWidget {
|
||||
InstallButton({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.compact = false,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
final bool disabled;
|
||||
final bool compact;
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progress = ref.watch(installationProgressProvider(app.identifier));
|
||||
|
||||
return GestureDetector(
|
||||
onTap: switch (app.status) {
|
||||
AppInstallStatus.differentArchitecture => null,
|
||||
AppInstallStatus.downgrade => null,
|
||||
AppInstallStatus.updated => () {
|
||||
packageManager.openApp(app.id!.toString());
|
||||
},
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => () {
|
||||
// show trust dialog only if first install
|
||||
if (disabled) {
|
||||
context.showError('Missing signer');
|
||||
}
|
||||
if (app.canInstall) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return InstallAlertDialog(app: app);
|
||||
},
|
||||
);
|
||||
} else if (app.canUpdate) {
|
||||
app.install();
|
||||
}
|
||||
},
|
||||
ErrorInstallProgress(:final e) => () {
|
||||
// show error and reset state to idle
|
||||
context.showError((e as dynamic).message);
|
||||
ref
|
||||
.read(installationProgressProvider(app.id!.toString())
|
||||
.notifier)
|
||||
.state = IdleInstallProgress();
|
||||
},
|
||||
_ => null,
|
||||
}
|
||||
},
|
||||
child: LinearPercentIndicator(
|
||||
lineHeight: 40,
|
||||
percent: switch (progress) {
|
||||
VerifyingHashProgress() => 1,
|
||||
DownloadingInstallProgress(:final progress) => progress,
|
||||
_ => switch (app.status) {
|
||||
AppInstallStatus.updated => 1,
|
||||
_ => 0,
|
||||
},
|
||||
},
|
||||
backgroundColor: switch (progress) {
|
||||
ErrorInstallProgress() => Colors.red,
|
||||
_ => Colors.blue[700],
|
||||
},
|
||||
progressColor: Colors.blue[800],
|
||||
barRadius: Radius.circular(20),
|
||||
padding: EdgeInsets.all(0),
|
||||
animateFromLastPercent: true,
|
||||
center: switch (app.status) {
|
||||
AppInstallStatus.loading =>
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
AppInstallStatus.differentArchitecture => Text(
|
||||
'Sorry, release does not support your device',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
AppInstallStatus.downgrade => Text(
|
||||
'Installed version ${app.installedVersion ?? ''} is higher, can\'t downgrade',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
AppInstallStatus.updated => Text('Open'),
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => app.canUpdate
|
||||
? AutoSizeText(
|
||||
'Update${compact ? '' : ' to ${app.latestMetadata!.version!}'}',
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text('Install'),
|
||||
DownloadingInstallProgress(:final progress) => Text(
|
||||
'${(progress * 100).floor()}%',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
VerifyingHashProgress() => compact
|
||||
? SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator())
|
||||
: Text('Verifying file integrity'),
|
||||
HashVerifiedInstallProgress() => compact
|
||||
? SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator())
|
||||
: Text(
|
||||
'Hash verified, requesting ${app.canUpdate ? 'update' : 'installation'}'),
|
||||
ErrorInstallProgress() => compact
|
||||
? SizedBox(width: 14, height: 14, child: Icon(Icons.error))
|
||||
: Text('Error, tap to see message'),
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InstallAlertDialog extends ConsumerWidget {
|
||||
const InstallAlertDialog({
|
||||
super.key,
|
||||
required this.app,
|
||||
});
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model!
|
||||
.user
|
||||
.value;
|
||||
return AlertDialog(
|
||||
elevation: 10,
|
||||
title: Text(
|
||||
'Are you sure you want to install ${app.name}?',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'By installing this app you are trusting the signer now and for all future updates. Make sure you know who they are.'),
|
||||
Gap(20),
|
||||
// SignerAndDeveloperRow(app: app),
|
||||
if (app.signer.value != null)
|
||||
AuthorContainer(
|
||||
user: app.signer.value!, text: 'Signed by', oneLine: true),
|
||||
Gap(20),
|
||||
if (app.signer.value != null)
|
||||
WebOfTrustContainer(
|
||||
user: user, npub: user?.npub, npub2: app.signer.value!.npub),
|
||||
if (user != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(20),
|
||||
Text('The app will be downloaded from:\n'),
|
||||
Text(
|
||||
app.latestMetadata!.urls.firstOrNull ??
|
||||
'https://cdn.zap.store/${app.latestMetadata!.hash}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (user == null)
|
||||
LoginContainer(
|
||||
minimal: true,
|
||||
labelText: 'Log in to view your own web of trust',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
app.install();
|
||||
// NOTE: can't use context.pop()
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: user != null
|
||||
? Text('Install', style: TextStyle(fontWeight: FontWeight.bold))
|
||||
: Text(
|
||||
'I trust the signer, install anyway',
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// NOTE: can't use context.pop()
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Go back'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.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/widgets/app_card.dart';
|
||||
|
||||
class LatestReleasesContainer extends HookConsumerWidget {
|
||||
const LatestReleasesContainer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestReleasesAppProvider);
|
||||
final apps = state.value ?? [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Latest releases',
|
||||
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
Gap(12),
|
||||
Column(
|
||||
children: (apps.isEmpty ? [null, null, null] : apps)
|
||||
.map((app) => AppCard(app: app))
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier<List<App>> {
|
||||
@override
|
||||
Future<List<App>> build() async {
|
||||
final timer = Timer.periodic(Duration(hours: 4), (_) => fetch());
|
||||
ref.onDispose(timer.cancel);
|
||||
fetch();
|
||||
return localFetch();
|
||||
}
|
||||
|
||||
List<App> localFetch() {
|
||||
return ref.releases
|
||||
.findAllLocal()
|
||||
.sorted((a, b) => b.createdAt.compareTo(a.createdAt))
|
||||
.take(10)
|
||||
.map((r) => r.app.value)
|
||||
.nonNulls
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> fetch() async {
|
||||
final releases = await ref.releases.findAll(params: {'limit': 10});
|
||||
final appIds = releases.map((r) => r.app.id!.toString()).toSet();
|
||||
await ref.apps.findAll(params: {'#d': appIds});
|
||||
update((_) => localFetch());
|
||||
}
|
||||
}
|
||||
|
||||
final latestReleasesAppProvider =
|
||||
AsyncNotifierProvider.autoDispose<LatestReleasesAppNotifier, List<App>>(
|
||||
LatestReleasesAppNotifier.new);
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
|
||||
class ReleaseCard extends StatelessWidget {
|
||||
ReleaseCard({
|
||||
super.key,
|
||||
required this.release,
|
||||
});
|
||||
|
||||
final Release release;
|
||||
final formatter = DateFormat('dd MMM yyyy');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final metadata = release.app.value!.latestMetadata;
|
||||
return Card(
|
||||
margin: EdgeInsets.only(top: 8, bottom: 8),
|
||||
elevation: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(release.version,
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
|
||||
Gap(10),
|
||||
release.content.length < 3000
|
||||
? MarkdownBody(data: release.content)
|
||||
: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: Markdown(data: release.content),
|
||||
),
|
||||
Gap(30),
|
||||
if (metadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text('Version'), Text(metadata.version!)],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Date'),
|
||||
Text(formatter.format(release.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (metadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Size'),
|
||||
Text('${metadata.size! ~/ 1024 ~/ 1024} MB')
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
|
||||
class SignerAndDeveloperRow extends StatelessWidget {
|
||||
const SignerAndDeveloperRow({
|
||||
super.key,
|
||||
required this.app,
|
||||
});
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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!, text: 'Built by', oneLine: false),
|
||||
),
|
||||
if (app.signer.isPresent)
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url =
|
||||
Uri.parse('https://njump.me/${app.signer.value!.npub}');
|
||||
launchUrl(url);
|
||||
},
|
||||
child: AuthorContainer(
|
||||
user: app.signer.value!, text: 'Signed by', oneLine: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/widgets/install_button.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
import 'package:zapstore/widgets/rounded_image.dart';
|
||||
|
||||
class VersionedAppHeader extends StatelessWidget {
|
||||
const VersionedAppHeader({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.showUpdate = false,
|
||||
});
|
||||
|
||||
final bool showUpdate;
|
||||
final App app;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUpdate = app.canUpdate && showUpdate;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
RoundedImage(
|
||||
url: app.icons.firstOrNull,
|
||||
size: 80,
|
||||
radius: 25,
|
||||
),
|
||||
Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AutoSizeText(
|
||||
app.name!,
|
||||
minFontSize: 16,
|
||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Gap(14),
|
||||
Wrap(
|
||||
children: [
|
||||
if (isUpdate)
|
||||
PillWidget(
|
||||
text: app.installedVersion!, color: Colors.grey[800]!),
|
||||
if (isUpdate) Icon(Icons.arrow_right),
|
||||
if (app.latestMetadata != null)
|
||||
PillWidget(
|
||||
text: app.latestMetadata!.version!,
|
||||
color: Colors.grey[800]!,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUpdate)
|
||||
SizedBox(
|
||||
width: 90,
|
||||
height: 40,
|
||||
child: InstallButton(
|
||||
app: app,
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:collection/collection.dart';
|
||||
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';
|
||||
|
||||
const franzapsNpub =
|
||||
'npub1wf4pufsucer5va8g9p0rj5dnhvfeh6d8w0g6eayaep5dhps6rsgs43dgh9';
|
||||
|
||||
class WebOfTrustContainer extends HookConsumerWidget {
|
||||
const WebOfTrustContainer({
|
||||
super.key,
|
||||
this.user,
|
||||
String? npub,
|
||||
required this.npub2,
|
||||
});
|
||||
|
||||
final User? user;
|
||||
final String npub2;
|
||||
String get npub => user?.npub ?? franzapsNpub;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return switch (ref.watch(wotProvider((npub1: npub, npub2: npub2)))) {
|
||||
AsyncData<List<User>>(value: final trustedUsers) => Builder(
|
||||
builder: (context) {
|
||||
final hasUser =
|
||||
trustedUsers.firstWhereOrNull((u) => u.npub == npub) != null;
|
||||
final trustedUsersWithoutUser =
|
||||
trustedUsers.where((u) => u.npub != npub).toList();
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
if (hasUser && npub != franzapsNpub) TextSpan(text: 'You, '),
|
||||
for (final trustedUser in trustedUsersWithoutUser)
|
||||
TextSpan(
|
||||
style: TextStyle(height: 1.6),
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
RoundedImage(
|
||||
url: trustedUser.avatarUrl, size: 20),
|
||||
Text(
|
||||
' ${trustedUser.nameOrNpub}${trustedUsersWithoutUser.indexOf(trustedUser) == trustedUsersWithoutUser.length - 1 ? '' : ', '}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trustedUsersWithoutUser.indexOf(trustedUser) ==
|
||||
trustedUsersWithoutUser.length - 1)
|
||||
TextSpan(
|
||||
text: ' and others follow this signer on nostr.',
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncError(:final error) =>
|
||||
Center(child: Text('Error checking web of trust: $error')),
|
||||
_ => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final wotProvider = FutureProvider.autoDispose
|
||||
.family<List<User>, ({String npub1, String npub2})>((ref, arg) async {
|
||||
final _ =
|
||||
ref.settings.watchOne('_', alsoWatch: (_) => {_.user}).model!.user.value;
|
||||
return ref.users.userAdapter.getTrusted(arg.npub1, arg.npub2);
|
||||
});
|
||||
Reference in New Issue
Block a user