diff --git a/lib/models/app_curation_set.dart b/lib/models/app_curation_set.dart index cd1a4fc..1f330bf 100644 --- a/lib/models/app_curation_set.dart +++ b/lib/models/app_curation_set.dart @@ -2,6 +2,7 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/models/user.dart'; import 'package:zapstore/utils/extensions.dart'; part 'app_curation_set.g.dart'; @@ -10,14 +11,16 @@ part 'app_curation_set.g.dart'; class AppCurationSet extends BaseAppCurationSet with DataModelMixin { final HasMany apps; - - AppCurationSet({required this.apps}); + final BelongsTo signer; AppCurationSet.fromJson(super.map) : apps = hasMany(map['apps']), + signer = belongsTo(map['signer']), super.fromJson(); Map toJson() => super.toMap(); + + String get name => content.isNotEmpty ? content : identifier!; } mixin AppCurationSetAdapter on Adapter { diff --git a/lib/models/app_curation_set.g.dart b/lib/models/app_curation_set.g.dart index abf385e..e30c5e7 100644 --- a/lib/models/app_curation_set.g.dart +++ b/lib/models/app_curation_set.g.dart @@ -16,6 +16,12 @@ mixin _$AppCurationSetAdapter on Adapter { type: 'apps', kind: 'HasMany', instance: (_) => (_ as AppCurationSet).apps, + ), + 'signer': RelationshipMeta( + name: 'signer', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as AppCurationSet).signer, ) }; @@ -63,4 +69,11 @@ extension AppCurationSetRelationshipGraphNodeX return meta.clone( parent: this is RelationshipMeta ? this as RelationshipMeta : null); } + + RelationshipGraphNode get signer { + final meta = _$AppCurationSetAdapter + ._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } } diff --git a/lib/navigation/router.dart b/lib/navigation/router.dart index 269f547..b38224c 100644 --- a/lib/navigation/router.dart +++ b/lib/navigation/router.dart @@ -171,13 +171,16 @@ final dataLibrariesInitializer = FutureProvider((ref) async { }, ); + // In this initial phase, load there more or less fixed curation sets here + await ref.appCurationSets.findAll(); + // Handle deep links final appLinksSub = appLinks.uriLinkStream.listen((uri) async { if (uri.scheme == "zapstore") { final adapter = ref.apps.appAdapter; final App? app = await adapter.findOne(uri.host); if (app != null) { - appRouter.go('/updates/details', extra: app); + appRouter.go('/details', extra: app); } } }); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 508ddb6..21a64c9 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -7,7 +7,7 @@ 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/categories_container.dart'; +import 'package:zapstore/widgets/app_curation_container.dart'; import 'package:zapstore/widgets/latest_releases_container.dart'; import 'package:zapstore/widgets/user_avatar.dart'; @@ -110,9 +110,9 @@ class SearchScreen extends HookConsumerWidget { if (searchResultState.value?.isNotEmpty ?? false) for (final app in searchResultState.value!) AppCard(app: app), Gap(20), - const CategoriesContainer(), + const AppCurationContainer(), Gap(20), - const LatestReleasesContainer(), + LatestReleasesContainer(scrollController: scrollController), Gap(10), ], ), diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 6c0a57e..d007882 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -43,7 +43,7 @@ class AppCard extends HookConsumerWidget { Bone.text(fontSize: 20), Padding( padding: const EdgeInsets.only(top: 4), - child: Bone.multiText(lines: 2), + child: Bone.multiText(lines: 4), ), ], ), @@ -140,17 +140,18 @@ class TinyAppCard extends HookConsumerWidget { context.go('/details', extra: app); }, child: Card( + margin: EdgeInsets.all(0), elevation: 0, - child: Container( - padding: const EdgeInsets.all(12), + child: Padding( + padding: const EdgeInsets.all(9), child: app == null ? Skeletonizer.zone( child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - Bone.square(uniRadius: 12, size: 50), + Bone.square(uniRadius: 12, size: 46), Gap(8), - Bone.multiText(lines: 2, fontSize: 10), + Bone.multiText(lines: 2, fontSize: 8), ], ), ) diff --git a/lib/widgets/app_curation_container.dart b/lib/widgets/app_curation_container.dart new file mode 100644 index 0000000..5b389c2 --- /dev/null +++ b/lib/widgets/app_curation_container.dart @@ -0,0 +1,122 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/app.dart'; +import 'package:zapstore/models/app_curation_set.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/widgets/author_container.dart'; +import 'package:zapstore/widgets/pill_widget.dart'; + +class AppCurationContainer extends HookConsumerWidget { + const AppCurationContainer({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); + final selectedAppCurationSet = ref.watch(_selectedIdProvider); + final appCurationSets = ref.appCurationSets.findAllLocal(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: Row( + children: [ + for (final appCurationSet in appCurationSets) + Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () => ref.read(_selectedIdProvider.notifier).state = + appCurationSet.getReplaceableEventLink(), + child: PillWidget( + text: appCurationSet.name, + color: appCurationSet.getReplaceableEventLink() == + selectedAppCurationSet + ? Colors.blue[700]! + : Colors.grey[800]!, + ), + ), + ), + ], + ), + ), + Gap(12), + Consumer( + builder: (context, ref, _) { + final state = + ref.watch(appCurationSetProvider(selectedAppCurationSet)); + return switch (state) { + AsyncData(value: final set) => Column( + children: [ + if (set.signer.isPresent) + Padding( + padding: const EdgeInsets.only(left: 6, bottom: 10), + child: AuthorContainer( + user: set.signer.value!, + text: + '${set.name} (${set.apps.length} apps) curated by'), + ), + HorizontalGrid(apps: set.apps.toList()), + ], + ), + _ => HorizontalGrid(apps: []), + }; + }, + ), + ], + ); + } +} + +class HorizontalGrid extends StatelessWidget { + final List apps; + + HorizontalGrid({super.key, required this.apps}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 220, + child: GridView.builder( + scrollDirection: Axis.horizontal, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: apps.isEmpty ? 8 : apps.length, + itemBuilder: (context, i) => TinyAppCard(app: apps.elementAtOrNull(i)), + ), + ); + } +} + +// Providers + +class AppCurationSetNotifier + extends FamilyAsyncNotifier { + @override + Future build(ReplaceableEventLink arg) async { + final appCurationSet = ref.appCurationSets.findOneLocalById(arg.formatted)!; + await ref.apps + .findAll(params: {'#d': appCurationSet.appIds}); // TODO: since + // await appCurationSet.signer.load(); + return appCurationSet; + } +} + +final appCurationSetProvider = AsyncNotifierProvider.family< + AppCurationSetNotifier, + AppCurationSet, + ReplaceableEventLink>(AppCurationSetNotifier.new); + +final _selectedIdProvider = StateProvider( + (_) => (30267, kZapstorePubkey, 'nostr')); diff --git a/lib/widgets/categories_container.dart b/lib/widgets/categories_container.dart deleted file mode 100644 index 7aa5b19..0000000 --- a/lib/widgets/categories_container.dart +++ /dev/null @@ -1,129 +0,0 @@ -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:purplebase/purplebase.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(( - 30267, - '78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d', - arg.name - ).formatted)!; - return ref.apps.appAdapter.findWhereIdInLocal(curationSet.appIds); - } -} - -final categoriesAppProvider = - AsyncNotifierProvider.family, AppCategory>( - AppCategoriesNotifier.new); - -final selectedAppCategoryProvider = StateProvider((_) => AppCategory.nostr); - -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 { - nostr(label: 'Nostr'), - bitcoin(label: 'Bitcoin'), - basics(label: 'Basics'), - privacy(label: 'Privacy & Security'); - - final String label; - const AppCategory({required this.label}); -} diff --git a/lib/widgets/rounded_image.dart b/lib/widgets/rounded_image.dart index 5508329..8aae573 100644 --- a/lib/widgets/rounded_image.dart +++ b/lib/widgets/rounded_image.dart @@ -34,7 +34,7 @@ class RoundedImage extends StatelessWidget { : CachedNetworkImage( imageUrl: url!, errorWidget: (_, __, ___) => fallbackContainer, - useOldImageOnUrlChange: true, + useOldImageOnUrlChange: false, fit: BoxFit.cover, width: size, height: size,