App curations set feature

This commit is contained in:
franzap
2024-10-13 17:28:52 -03:00
parent bab1873c4e
commit c4f62f840c
8 changed files with 154 additions and 141 deletions
+5 -2
View File
@@ -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> {
+13
View File
@@ -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);
}
}
+4 -1
View File
@@ -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);
}
}
});
+3 -3
View File
@@ -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),
],
),
+6 -5
View File
@@ -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),
],
),
)
+122
View File
@@ -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'));
-129
View File
@@ -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});
}
+1 -1
View File
@@ -34,7 +34,7 @@ class RoundedImage extends StatelessWidget {
: CachedNetworkImage(
imageUrl: url!,
errorWidget: (_, __, ___) => fallbackContainer,
useOldImageOnUrlChange: true,
useOldImageOnUrlChange: false,
fit: BoxFit.cover,
width: size,
height: size,