Organize widgets, fix includes bug

This commit is contained in:
franzap
2024-07-02 18:20:17 -03:00
parent 460a9a74b8
commit 22e7d24e45
14 changed files with 719 additions and 667 deletions
+5 -8
View File
@@ -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);
}
}
+5 -463
View File
@@ -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
View File
@@ -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});
}
-2
View File
@@ -142,5 +142,3 @@ class SettingsScreen extends HookConsumerWidget {
);
}
}
const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58";
+1 -1
View File
@@ -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});
+2
View File
@@ -36,3 +36,5 @@ extension ContextX on BuildContext {
);
}
}
const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58";
+127
View File
@@ -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});
}
+11 -2
View File
@@ -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),
+211
View File
@@ -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);
+77
View File
@@ -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')
],
),
),
],
),
),
);
}
}
+42
View File
@@ -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),
),
],
);
}
}
+74
View File
@@ -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,
),
),
],
);
}
}
+85
View File
@@ -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);
});