mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
App curations set feature
This commit is contained in:
@@ -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<AppCurationSet> {
|
||||
final HasMany<App> apps;
|
||||
|
||||
AppCurationSet({required this.apps});
|
||||
final BelongsTo<User> signer;
|
||||
|
||||
AppCurationSet.fromJson(super.map)
|
||||
: apps = hasMany(map['apps']),
|
||||
signer = belongsTo(map['signer']),
|
||||
super.fromJson();
|
||||
|
||||
Map<String, dynamic> toJson() => super.toMap();
|
||||
|
||||
String get name => content.isNotEmpty ? content : identifier!;
|
||||
}
|
||||
|
||||
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
|
||||
@@ -16,6 +16,12 @@ mixin _$AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
type: 'apps',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as AppCurationSet).apps,
|
||||
),
|
||||
'signer': RelationshipMeta<User>(
|
||||
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<User> get signer {
|
||||
final meta = _$AppCurationSetAdapter
|
||||
._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta<User>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,13 +171,16 @@ final dataLibrariesInitializer = FutureProvider<void>((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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<App> 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<AppCurationSet, ReplaceableEventLink> {
|
||||
@override
|
||||
Future<AppCurationSet> 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<ReplaceableEventLink>(
|
||||
(_) => (30267, kZapstorePubkey, 'nostr'));
|
||||
@@ -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<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((
|
||||
30267,
|
||||
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d',
|
||||
arg.name
|
||||
).formatted)!;
|
||||
return ref.apps.appAdapter.findWhereIdInLocal(curationSet.appIds);
|
||||
}
|
||||
}
|
||||
|
||||
final categoriesAppProvider =
|
||||
AsyncNotifierProvider.family<AppCategoriesNotifier, List<App>, 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<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 {
|
||||
nostr(label: 'Nostr'),
|
||||
bitcoin(label: 'Bitcoin'),
|
||||
basics(label: 'Basics'),
|
||||
privacy(label: 'Privacy & Security');
|
||||
|
||||
final String label;
|
||||
const AppCategory({required this.label});
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class RoundedImage extends StatelessWidget {
|
||||
: CachedNetworkImage(
|
||||
imageUrl: url!,
|
||||
errorWidget: (_, __, ___) => fallbackContainer,
|
||||
useOldImageOnUrlChange: true,
|
||||
useOldImageOnUrlChange: false,
|
||||
fit: BoxFit.cover,
|
||||
width: size,
|
||||
height: size,
|
||||
|
||||
Reference in New Issue
Block a user