From 22e7d24e4501117a3caeec0cff364120ea0c85e4 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 2 Jul 2024 18:20:17 -0300 Subject: [PATCH] Organize widgets, fix includes bug --- lib/models/app.dart | 13 +- lib/screens/app_detail_screen.dart | 468 +-------------------- lib/screens/search_screen.dart | 203 +-------- lib/screens/settings_screen.dart | 2 - lib/screens/updates_screen.dart | 2 +- lib/utils/extensions.dart | 2 + lib/widgets/categories_container.dart | 127 ++++++ lib/widgets/error_container.dart | 13 +- lib/widgets/install_button.dart | 211 ++++++++++ lib/widgets/latest_releases_container.dart | 67 +++ lib/widgets/release_card.dart | 77 ++++ lib/widgets/signer_and_developer_row.dart | 42 ++ lib/widgets/versioned_app_header.dart | 74 ++++ lib/widgets/wot_container.dart | 85 ++++ 14 files changed, 719 insertions(+), 667 deletions(-) create mode 100644 lib/widgets/categories_container.dart create mode 100644 lib/widgets/install_button.dart create mode 100644 lib/widgets/latest_releases_container.dart create mode 100644 lib/widgets/release_card.dart create mode 100644 lib/widgets/signer_and_developer_row.dart create mode 100644 lib/widgets/versioned_app_header.dart create mode 100644 lib/widgets/wot_container.dart diff --git a/lib/models/app.dart b/lib/models/app.dart index 5d3f413..23da631 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -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 { @@ -215,16 +216,18 @@ mixin AppAdapter on Adapter { } Future> loadAppModels(Map 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 { 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); } } diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 3c49109..9c8c4c7 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -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, ({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>(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()), - ), - ) - }; - } -} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index f916781..a9d8746 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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, AppCategory> { - @override - FutureOr> 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, 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 apps; - final int columns; - - @override - Widget build(BuildContext context) { - return LayoutGrid( - key: UniqueKey(), - columnGap: 10, - rowGap: 10, - rowSizes: - List.generate((8 / columns).ceil(), (_) => 130.px), - columnSizes: List.generate(columns, (_) => 1.fr), - children: - List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))), - ); - } -} - -class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier> { - @override - Future> build() async { - final timer = Timer.periodic(Duration(hours: 4), (_) => fetch()); - ref.onDispose(timer.cancel); - fetch(); - return localFetch(); - } - - List localFetch() { - return ref.releases - .findAllLocal() - .sorted((a, b) => b.createdAt.compareTo(a.createdAt)) - .take(10) - .map((r) => r.app.value) - .nonNulls - .toSet() - .toList(); - } - - Future 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.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((ref) => null); final searchResultProvider = @@ -308,13 +139,3 @@ class SearchResultNotifier extends AsyncNotifier?> { 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}); -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3effaf2..aaa379f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -142,5 +142,3 @@ class SettingsScreen extends HookConsumerWidget { ); } } - -const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58"; diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index b125133..46a432e 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -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}); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index d9c6704..017bec7 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -36,3 +36,5 @@ extension ContextX on BuildContext { ); } } + +const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58"; diff --git a/lib/widgets/categories_container.dart b/lib/widgets/categories_container.dart new file mode 100644 index 0000000..a5f7c0c --- /dev/null +++ b/lib/widgets/categories_container.dart @@ -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, AppCategory> { + @override + Future> 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, 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 apps; + final int columns; + + @override + Widget build(BuildContext context) { + return LayoutGrid( + key: UniqueKey(), + columnGap: 10, + rowGap: 10, + rowSizes: + List.generate((8 / columns).ceil(), (_) => 130.px), + columnSizes: List.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}); +} diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart index 44a75af..8f72f71 100644 --- a/lib/widgets/error_container.dart +++ b/lib/widgets/error_container.dart @@ -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), diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart new file mode 100644 index 0000000..621d23f --- /dev/null +++ b/lib/widgets/install_button.dart @@ -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'), + ), + ], + ); + } +} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart new file mode 100644 index 0000000..4f27bc6 --- /dev/null +++ b/lib/widgets/latest_releases_container.dart @@ -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> { + @override + Future> build() async { + final timer = Timer.periodic(Duration(hours: 4), (_) => fetch()); + ref.onDispose(timer.cancel); + fetch(); + return localFetch(); + } + + List localFetch() { + return ref.releases + .findAllLocal() + .sorted((a, b) => b.createdAt.compareTo(a.createdAt)) + .take(10) + .map((r) => r.app.value) + .nonNulls + .toSet() + .toList(); + } + + Future 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.new); diff --git a/lib/widgets/release_card.dart b/lib/widgets/release_card.dart new file mode 100644 index 0000000..f866f6e --- /dev/null +++ b/lib/widgets/release_card.dart @@ -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') + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/signer_and_developer_row.dart b/lib/widgets/signer_and_developer_row.dart new file mode 100644 index 0000000..0aea39e --- /dev/null +++ b/lib/widgets/signer_and_developer_row.dart @@ -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), + ), + ], + ); + } +} diff --git a/lib/widgets/versioned_app_header.dart b/lib/widgets/versioned_app_header.dart new file mode 100644 index 0000000..c9a032d --- /dev/null +++ b/lib/widgets/versioned_app_header.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/wot_container.dart b/lib/widgets/wot_container.dart new file mode 100644 index 0000000..e2c3e6f --- /dev/null +++ b/lib/widgets/wot_container.dart @@ -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>(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, ({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); +});