mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
Tablet support to fix #28, add custom curation set for recommendations, wot default fixes #11, add clear cache fixes #45, other adjustments
This commit is contained in:
Vendored
-3
@@ -1,7 +1,4 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
|
||||
@@ -65,8 +65,6 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
|
||||
+87
-54
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -16,21 +17,22 @@ const kDbVersion = 1;
|
||||
|
||||
void main() {
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localStorageProvider.overrideWithValue(
|
||||
LocalStorage(
|
||||
baseDirFn: () async {
|
||||
final path = (await getApplicationSupportDirectory()).path;
|
||||
print('initializing local storage at $path');
|
||||
return path;
|
||||
},
|
||||
// TODO RESTORE!
|
||||
clear: LocalStorageClearStrategy.always,
|
||||
),
|
||||
)
|
||||
],
|
||||
child: const ZapstoreApp(),
|
||||
Phoenix(
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
localStorageProvider.overrideWithValue(
|
||||
LocalStorage(
|
||||
baseDirFn: () async {
|
||||
final path = (await getApplicationSupportDirectory()).path;
|
||||
print('initializing local storage at $path');
|
||||
return path;
|
||||
},
|
||||
clear: LocalStorageClearStrategy.whenError,
|
||||
),
|
||||
)
|
||||
],
|
||||
child: const ZapstoreApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -107,9 +109,7 @@ final goRouter = GoRouter(
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: Center(
|
||||
child: SettingsScreen(),
|
||||
),
|
||||
child: SettingsScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -119,13 +119,30 @@ final goRouter = GoRouter(
|
||||
],
|
||||
);
|
||||
|
||||
AppLifecycleListener? _lifecycleListener;
|
||||
|
||||
final newInitializer = FutureProvider<void>((ref) async {
|
||||
await ref.read(initializeFlutterData(adapterProvidersMap).future);
|
||||
ref
|
||||
.read(relayMessageNotifierProvider.notifier)
|
||||
.initialize(['wss://relay.zap.store', 'wss://relay.nostr.band']);
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onStateChange: (state) async {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final adapter = ref.apps.appAdapter;
|
||||
await adapter.getInstalledAppsMap();
|
||||
adapter.triggerNotify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.onDispose(() {
|
||||
_lifecycleListener?.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// Scaffolding
|
||||
|
||||
class ScaffoldWithNestedNavigation extends HookConsumerWidget {
|
||||
const ScaffoldWithNestedNavigation({
|
||||
Key? key,
|
||||
@@ -147,32 +164,41 @@ class ScaffoldWithNestedNavigation extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final initializer = ref.watch(newInitializer);
|
||||
|
||||
return SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 550) {
|
||||
return ScaffoldWithNavigationBar(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: _goBranch,
|
||||
);
|
||||
} else {
|
||||
return ScaffoldWithNavigationRail(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: _goBranch,
|
||||
);
|
||||
}
|
||||
child: initializer.when(
|
||||
data: (_) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 550) {
|
||||
return MobileScaffold(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: _goBranch,
|
||||
);
|
||||
} else {
|
||||
return DesktopScaffold(
|
||||
body: navigationShell,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: _goBranch,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
error: (e, stack) {
|
||||
print(stack);
|
||||
return Text('error $e');
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
class ScaffoldWithNavigationBar extends HookConsumerWidget {
|
||||
const ScaffoldWithNavigationBar({
|
||||
class MobileScaffold extends StatelessWidget {
|
||||
const MobileScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
@@ -183,20 +209,12 @@ class ScaffoldWithNavigationBar extends HookConsumerWidget {
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final initializer = ref.watch(newInitializer);
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
key: scaffoldKey,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
||||
child: initializer.when(
|
||||
data: (_) => body,
|
||||
error: (e, stack) {
|
||||
print(stack);
|
||||
return Text('error $e');
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
height: 60,
|
||||
@@ -224,14 +242,17 @@ class ScaffoldWithNavigationBar extends HookConsumerWidget {
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
),
|
||||
drawer: Drawer(
|
||||
child: AppDrawer(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
|
||||
child: LoginContainer(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScaffoldWithNavigationRail extends StatelessWidget {
|
||||
const ScaffoldWithNavigationRail({
|
||||
class DesktopScaffold extends StatelessWidget {
|
||||
const DesktopScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
@@ -247,25 +268,37 @@ class ScaffoldWithNavigationRail extends StatelessWidget {
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
minWidth: 120,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: [
|
||||
const NavigationRailDestination(
|
||||
label: Text('Search'),
|
||||
icon: Icon(Icons.search_outlined),
|
||||
label: Text('Home'),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home_filled),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
label: Text('Updates'),
|
||||
icon: Icon(Icons.download_for_offline_outlined),
|
||||
selectedIcon: Icon(Icons.download_for_offline),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
label: Text('Profile'),
|
||||
icon: Icon(Icons.person_outline),
|
||||
label: Text('Settings'),
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
body,
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 768,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+5
-1
@@ -6,6 +6,7 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:zapstore/models/app_curation_set.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/file_metadata.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
@@ -13,7 +14,8 @@ import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/models/user.dart';
|
||||
|
||||
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
||||
'apps': appsAdapterProvider,
|
||||
'appCurationSets': appCurationSetsAdapterProvider,
|
||||
'apps': appsAdapterProvider,
|
||||
'fileMetadata': fileMetadataAdapterProvider,
|
||||
'releases': releasesAdapterProvider,
|
||||
'settings': settingsAdapterProvider,
|
||||
@@ -21,6 +23,7 @@ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
|
||||
};
|
||||
|
||||
extension AdapterWidgetRefX on WidgetRef {
|
||||
Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
|
||||
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch;
|
||||
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch;
|
||||
@@ -30,6 +33,7 @@ extension AdapterWidgetRefX on WidgetRef {
|
||||
|
||||
extension AdapterRefX on Ref {
|
||||
|
||||
Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch as Watcher;
|
||||
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch as Watcher;
|
||||
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher;
|
||||
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher;
|
||||
|
||||
+139
-132
@@ -6,7 +6,6 @@ import 'dart:typed_data';
|
||||
import 'package:android_package_manager/android_package_manager.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:install_plugin/install_plugin.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
@@ -26,7 +25,7 @@ part 'app.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, AppAdapter])
|
||||
class App extends Event<App> with BaseApp {
|
||||
class App extends BaseApp with DataModelMixin<App> {
|
||||
late final HasMany<Release> releases;
|
||||
late final BelongsTo<User> signer;
|
||||
late final BelongsTo<User> developer;
|
||||
@@ -45,137 +44,7 @@ class App extends Event<App> with BaseApp {
|
||||
a.architectures.contains('arm64-v8a'))
|
||||
.firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
mixin AppAdapter on Adapter<App> {
|
||||
ProviderSubscription? _sub;
|
||||
AppLifecycleListener? _lifecycleListener;
|
||||
|
||||
@override
|
||||
Future<void> onInitialized() async {
|
||||
if (!ref.read(localStorageProvider).inIsolate) {
|
||||
_sub = ref.listen(installedAppProvider, (_, __) {
|
||||
triggerNotify();
|
||||
});
|
||||
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onStateChange: (state) async {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
await getInstalledAppsMap();
|
||||
triggerNotify();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
super.onInitialized();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.close();
|
||||
_lifecycleListener?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<App>> loadAppModels(Map<String, dynamic> params) async {
|
||||
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')) {
|
||||
final userIds = {
|
||||
for (final app in apps) app.signer.id,
|
||||
for (final app in apps) app.developer.id
|
||||
}.nonNulls;
|
||||
await ref.users.findAll(params: {'authors': userIds});
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<App>> findAll(
|
||||
{bool? remote = true,
|
||||
bool? background,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<App>? onSuccess,
|
||||
OnErrorAll<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final map = await getInstalledAppsMap();
|
||||
|
||||
if (params!.containsKey('installed')) {
|
||||
if (map.keys.isNotEmpty) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return await loadAppModels(params);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<App?> findOne(Object id,
|
||||
{bool remote = true,
|
||||
bool background = false,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
OnSuccessOne<App>? onSuccess,
|
||||
OnErrorOne<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final apps = await loadAppModels({
|
||||
...params!,
|
||||
'#d': [id]
|
||||
});
|
||||
// If ID not found in relay then clear from local storage
|
||||
if (apps.isEmpty) {
|
||||
deleteLocalById(id);
|
||||
return null;
|
||||
}
|
||||
return apps.first;
|
||||
}
|
||||
|
||||
static AndroidPackageManager? _packageManager;
|
||||
|
||||
Future<Map<String, String>> getInstalledAppsMap() async {
|
||||
late List<PackageInfo>? infos;
|
||||
if (Platform.isAndroid) {
|
||||
_packageManager ??= AndroidPackageManager();
|
||||
infos = await _packageManager!.getInstalledPackages();
|
||||
} else {
|
||||
infos = [];
|
||||
}
|
||||
|
||||
final installedPackageInfos = infos!.where((i) => ![
|
||||
'android',
|
||||
'com.android',
|
||||
'com.google',
|
||||
'org.chromium.webview_shell',
|
||||
'app.grapheneos',
|
||||
'app.vanadium'
|
||||
].any((e) => i.packageName!.startsWith(e)));
|
||||
|
||||
return ref.read(installedAppProvider.notifier).state = {
|
||||
for (final info in installedPackageInfos)
|
||||
info.packageName!: info.versionName!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension AppX on App {
|
||||
bool get canInstall => status == AppInstallStatus.installable;
|
||||
bool get canUpdate => status == AppInstallStatus.updatable;
|
||||
bool get isUpdated => status == AppInstallStatus.updated;
|
||||
@@ -279,6 +148,144 @@ extension AppX on App {
|
||||
}
|
||||
}
|
||||
|
||||
mixin AppAdapter on Adapter<App> {
|
||||
ProviderSubscription? _sub;
|
||||
|
||||
@override
|
||||
Future<void> onInitialized() async {
|
||||
if (!inIsolate) {
|
||||
_sub = ref.listen(installedAppProvider, (_, __) {
|
||||
triggerNotify();
|
||||
});
|
||||
}
|
||||
super.onInitialized();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<App>> loadAppModels(Map<String, dynamic> params) async {
|
||||
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')) {
|
||||
final userIds = {
|
||||
for (final app in apps) app.signer.id,
|
||||
for (final app in apps) app.developer.id
|
||||
}.nonNulls;
|
||||
await ref.users.findAll(params: {'authors': userIds});
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<App>> findAll(
|
||||
{bool? remote = true,
|
||||
bool? background,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
bool? syncLocal,
|
||||
OnSuccessAll<App>? onSuccess,
|
||||
OnErrorAll<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final map = await getInstalledAppsMap(defer: true);
|
||||
|
||||
if (params!.containsKey('installed')) {
|
||||
if (map.keys.isNotEmpty) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return await loadAppModels(params);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<App?> findOne(Object id,
|
||||
{bool remote = true,
|
||||
bool background = false,
|
||||
Map<String, dynamic>? params = const {},
|
||||
Map<String, String>? headers,
|
||||
OnSuccessOne<App>? onSuccess,
|
||||
OnErrorOne<App>? onError,
|
||||
DataRequestLabel? label}) async {
|
||||
final apps = await loadAppModels({
|
||||
...params!,
|
||||
'#d': [id]
|
||||
});
|
||||
// If ID not found in relay then clear from local storage
|
||||
if (apps.isEmpty) {
|
||||
deleteLocalById(id);
|
||||
return null;
|
||||
}
|
||||
return apps.first;
|
||||
}
|
||||
|
||||
static AndroidPackageManager? _packageManager;
|
||||
|
||||
Future<Map<String, String>> getInstalledAppsMap({bool defer = false}) async {
|
||||
late List<PackageInfo>? infos;
|
||||
if (Platform.isAndroid) {
|
||||
_packageManager ??= AndroidPackageManager();
|
||||
infos = await _packageManager!.getInstalledPackages();
|
||||
} else {
|
||||
infos = [];
|
||||
}
|
||||
|
||||
final installedPackageInfos = infos!.where((i) => ![
|
||||
'android',
|
||||
'com.android',
|
||||
'com.google',
|
||||
'org.chromium.webview_shell',
|
||||
'app.grapheneos',
|
||||
'app.vanadium'
|
||||
].any((e) => i.packageName!.startsWith(e)));
|
||||
|
||||
final newState = {
|
||||
for (final info in installedPackageInfos)
|
||||
info.packageName!: info.versionName!
|
||||
};
|
||||
|
||||
// Providers can't set other providers state
|
||||
// while initializing, so defer setting state
|
||||
if (defer) {
|
||||
Future.microtask(() {
|
||||
ref.read(installedAppProvider.notifier).state = newState;
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
|
||||
return ref.read(installedAppProvider.notifier).state = newState;
|
||||
}
|
||||
|
||||
@override
|
||||
DeserializedData<App> deserialize(Object? data, {String? key}) {
|
||||
// map['signer'] = map['pubkey'];
|
||||
// final zapTags = (map['tags'] as Iterable).where((t) => t[0] == 'zap');
|
||||
// if (zapTags.length == 1) {
|
||||
// map['developer'] = (zapTags.first as List)[1];
|
||||
// }
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHashMismatch(String path, String hash) async {
|
||||
return await Isolate.run(() async {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/nostr_adapter.dart';
|
||||
|
||||
part 'app_curation_set.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, AppCurationSetAdapter])
|
||||
class AppCurationSet extends BaseAppCurationSet
|
||||
with DataModelMixin<AppCurationSet> {
|
||||
late final HasMany<App> apps;
|
||||
}
|
||||
|
||||
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
@override
|
||||
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
|
||||
final list = data is Iterable ? data : [data as Map];
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
final aValues = (map['tags'] as Iterable).where((t) => t[0] == 'a');
|
||||
map['apps'] = aValues.map((e) => e[1].split(':')[2]);
|
||||
}
|
||||
return super.deserialize(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_curation_set.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// AdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
|
||||
|
||||
mixin _$AppCurationSetAdapter on Adapter<AppCurationSet> {
|
||||
static final Map<String, RelationshipMeta> _kAppCurationSetRelationshipMetas =
|
||||
{
|
||||
'apps': RelationshipMeta<App>(
|
||||
name: 'apps',
|
||||
type: 'apps',
|
||||
kind: 'HasMany',
|
||||
instance: (_) => (_ as AppCurationSet).apps,
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, RelationshipMeta> get relationshipMetas =>
|
||||
_kAppCurationSetRelationshipMetas;
|
||||
|
||||
@override
|
||||
AppCurationSet deserializeLocal(map, {String? key}) {
|
||||
map = transformDeserialize(map);
|
||||
return internalWrapStopInit(() => _$AppCurationSetFromJson(map), key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
|
||||
final map = _$AppCurationSetToJson(model);
|
||||
return transformSerialize(map, withRelationships: withRelationships);
|
||||
}
|
||||
}
|
||||
|
||||
final _appCurationSetsFinders = <String, dynamic>{};
|
||||
|
||||
class $AppCurationSetAdapter = Adapter<AppCurationSet>
|
||||
with
|
||||
_$AppCurationSetAdapter,
|
||||
NostrAdapter<AppCurationSet>,
|
||||
AppCurationSetAdapter;
|
||||
|
||||
final appCurationSetsAdapterProvider = Provider<Adapter<AppCurationSet>>(
|
||||
(ref) =>
|
||||
$AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders)));
|
||||
|
||||
extension AppCurationSetAdapterX on Adapter<AppCurationSet> {
|
||||
NostrAdapter<AppCurationSet> get nostrAdapter =>
|
||||
this as NostrAdapter<AppCurationSet>;
|
||||
AppCurationSetAdapter get appCurationSetAdapter =>
|
||||
this as AppCurationSetAdapter;
|
||||
}
|
||||
|
||||
extension AppCurationSetRelationshipGraphNodeX
|
||||
on RelationshipGraphNode<AppCurationSet> {
|
||||
RelationshipGraphNode<App> get apps {
|
||||
final meta = _$AppCurationSetAdapter
|
||||
._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta<App>;
|
||||
return meta.clone(
|
||||
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
|
||||
}
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
AppCurationSet _$AppCurationSetFromJson(Map<String, dynamic> json) =>
|
||||
AppCurationSet()
|
||||
..id = json['id']
|
||||
..pubkey = json['pubkey'] as String
|
||||
..createdAt = DateTime.parse(json['createdAt'] as String)
|
||||
..content = json['content'] as String
|
||||
..kind = (json['kind'] as num).toInt()
|
||||
..tags = (json['tags'] as List<dynamic>)
|
||||
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList()
|
||||
..signature = json['signature'] as String?
|
||||
..apps = HasMany<App>.fromJson(json['apps'] as Map<String, dynamic>);
|
||||
|
||||
Map<String, dynamic> _$AppCurationSetToJson(AppCurationSet instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'pubkey': instance.pubkey,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'content': instance.content,
|
||||
'kind': instance.kind,
|
||||
'tags': instance.tags,
|
||||
'signature': instance.signature,
|
||||
'apps': instance.apps,
|
||||
};
|
||||
@@ -9,7 +9,7 @@ part 'file_metadata.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, FileMetadataAdapter])
|
||||
class FileMetadata extends Event<FileMetadata> with BaseFileMetadata {
|
||||
class FileMetadata extends BaseFileMetadata with DataModelMixin<FileMetadata> {
|
||||
late final BelongsTo<User> author;
|
||||
late final BelongsTo<Release> release = BelongsTo();
|
||||
late final BelongsTo<User> signer;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
|
||||
abstract class Event<T extends Event<T>> = BaseEvent with DataModelMixin<T>;
|
||||
|
||||
mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
|
||||
mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
|
||||
late final RelayMessageNotifier notifier =
|
||||
ref.read(relayMessageNotifierProvider.notifier);
|
||||
|
||||
// TODO rethink this
|
||||
Map<int, String> kindType = {
|
||||
0: 'users',
|
||||
3: 'users',
|
||||
1063: 'fileMetadata',
|
||||
30063: 'releases',
|
||||
30267: 'appCurationSets',
|
||||
32267: 'apps'
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
|
||||
if (dTags.length == 1) {
|
||||
map['id'] = (dTags.first as List)[1];
|
||||
}
|
||||
// TODO remove
|
||||
map['signer'] = map['pubkey'];
|
||||
final zapTags = (map['tags'] as Iterable).where((t) => t[0] == 'zap');
|
||||
if (zapTags.length == 1) {
|
||||
@@ -52,6 +53,7 @@ mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
|
||||
return DeserializedData<T>(models, included: included);
|
||||
}
|
||||
|
||||
// TODO remove
|
||||
int get kind =>
|
||||
kindType.entries.firstWhere((e) => e.value == internalType).key;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ part 'release.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, ReleaseAdapter])
|
||||
class Release extends Event<Release> with BaseRelease {
|
||||
class Release extends BaseRelease with DataModelMixin<Release> {
|
||||
late final HasMany<FileMetadata> artifacts;
|
||||
late final BelongsTo<App> app;
|
||||
late final BelongsTo<User> signer;
|
||||
|
||||
+14
-4
@@ -10,7 +10,7 @@ part 'user.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@DataAdapter([NostrAdapter, UserAdapter])
|
||||
class User extends Event<User> with BaseUser {
|
||||
class User extends BaseUser with DataModelMixin<User> {
|
||||
@DataRelationship(inverse: 'followers')
|
||||
late final HasMany<User> following;
|
||||
@DataRelationship(inverse: 'following')
|
||||
@@ -102,6 +102,10 @@ mixin UserAdapter on NostrAdapter<User> {
|
||||
|
||||
final result =
|
||||
await notifier.query(req, relayUrls: ['wss://relay.nostr.band']);
|
||||
if (onSuccess != null) {
|
||||
return await onSuccess.call(DataResponse(statusCode: 200, body: result),
|
||||
label ?? DataRequestLabel('findAll', type: type), this);
|
||||
}
|
||||
final data = await deserializeAsync(result, save: true);
|
||||
return data.models;
|
||||
}
|
||||
@@ -153,8 +157,8 @@ mixin UserAdapter on NostrAdapter<User> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<User>> getTrusted(User u1, User u2) async {
|
||||
final url = 'https://zap.store/api/trust/${u1.npub}/${u2.npub}';
|
||||
Future<List<User>> getTrusted(String npub1, String npub2) async {
|
||||
final url = 'https://zap.store/api/trust/$npub1/$npub2';
|
||||
final users = await sendRequest(
|
||||
Uri.parse(url),
|
||||
onSuccess: (response, label) async {
|
||||
@@ -163,7 +167,13 @@ mixin UserAdapter on NostrAdapter<User> {
|
||||
Map<String, dynamic>.from(jsonDecode(response.body.toString()));
|
||||
|
||||
final trustedKeys = map.keys.map((npub) => npub.hexKey);
|
||||
return await findAll(params: {'authors': trustedKeys});
|
||||
return await findAll(
|
||||
params: {'authors': trustedKeys},
|
||||
onSuccess: (response, label, adapter) {
|
||||
final data = deserialize(response.body);
|
||||
return data.models;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return users!;
|
||||
|
||||
+167
-133
@@ -8,13 +8,15 @@ 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.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/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';
|
||||
@@ -196,15 +198,15 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
Divider(height: 50),
|
||||
Text(
|
||||
'Releases'.toUpperCase(),
|
||||
'Latest release'.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
letterSpacing: 3,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
for (final release in app.releases.ordered)
|
||||
ReleaseCard(release: release),
|
||||
if (app.releases.ordered.isNotEmpty)
|
||||
ReleaseCard(release: app.releases.ordered.first),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -413,55 +415,67 @@ class InstallButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progress = ref.watch(installationProgressProvider(app.identifier));
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: switch (app.status) {
|
||||
AppInstallStatus.differentArchitecture => null,
|
||||
AppInstallStatus.downgrade => null,
|
||||
AppInstallStatus.updated => () {
|
||||
LaunchApp.openApp(androidPackageName: app.id!.toString());
|
||||
return GestureDetector(
|
||||
onTap: switch (app.status) {
|
||||
AppInstallStatus.differentArchitecture => null,
|
||||
AppInstallStatus.downgrade => null,
|
||||
AppInstallStatus.updated => () {
|
||||
LaunchApp.openApp(androidPackageName: app.id!.toString());
|
||||
},
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => () {
|
||||
print(app.canInstall);
|
||||
// show trust dialog only if first install
|
||||
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.toString());
|
||||
ref
|
||||
.read(installationProgressProvider(app.id!.toString())
|
||||
.notifier)
|
||||
.state = IdleInstallProgress();
|
||||
},
|
||||
_ => null,
|
||||
}
|
||||
},
|
||||
child: LinearPercentIndicator(
|
||||
lineHeight: 40,
|
||||
percent: switch (progress) {
|
||||
DeviceInstallProgress() => 1,
|
||||
DownloadingInstallProgress(:final progress) => progress,
|
||||
_ => switch (app.status) {
|
||||
AppInstallStatus.updated => 1,
|
||||
_ => 0,
|
||||
},
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => () {
|
||||
// show trust dialog only if first install
|
||||
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.toString());
|
||||
ref
|
||||
.read(installationProgressProvider(app.id!.toString())
|
||||
.notifier)
|
||||
.state = IdleInstallProgress();
|
||||
},
|
||||
_ => null,
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
disabledForegroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.blue[700],
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: switch (progress) {
|
||||
ErrorInstallProgress() => Colors.red,
|
||||
_ => Colors.blue[700],
|
||||
}),
|
||||
child: switch (app.status) {
|
||||
backgroundColor: switch (progress) {
|
||||
ErrorInstallProgress() => Colors.red,
|
||||
_ => Colors.blue[700],
|
||||
},
|
||||
progressColor: Colors.blue[800],
|
||||
barRadius: Radius.circular(18),
|
||||
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'),
|
||||
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'),
|
||||
'Installed version ${app.installedVersion ?? ''} is higher, can\'t downgrade',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
AppInstallStatus.updated => Text('Open'),
|
||||
_ => switch (progress) {
|
||||
IdleInstallProgress() => app.canUpdate
|
||||
@@ -498,59 +512,71 @@ class InstallAlertDialog extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.settings.watchOne('_').model!.user.value;
|
||||
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: 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),
|
||||
AuthorContainer(
|
||||
user: app.signer.value!, text: 'Signed by', oneLine: true),
|
||||
Gap(20),
|
||||
if (user != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WebOfTrustContainer(user: user, app: app),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
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)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
scaffoldKey.currentState!.openDrawer();
|
||||
},
|
||||
child: Text('Log in to view web of trust',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
LoginContainer(
|
||||
minimal: true,
|
||||
labelText:
|
||||
'Log in to view your own connections (NIP-05 or npub, no nsec!)',
|
||||
),
|
||||
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: () {
|
||||
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'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -565,66 +591,74 @@ class InstallAlertDialog extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final wotProvider = FutureProvider.autoDispose
|
||||
.family<List<User>, ({User user, App app})>((ref, arg) async {
|
||||
return ref.users.userAdapter.getTrusted(arg.user, arg.app.signer.value!);
|
||||
.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,
|
||||
required this.user,
|
||||
required this.app,
|
||||
this.user,
|
||||
String? npub,
|
||||
required this.npub2,
|
||||
});
|
||||
|
||||
final User user;
|
||||
final App app;
|
||||
final User? user;
|
||||
final String npub2;
|
||||
String get npub => user?.npub ?? franzapsNpub;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return switch (ref.watch(wotProvider((user: user, app: app)))) {
|
||||
AsyncData<List<User>>(value: final trustedUsers) => () {
|
||||
// Crappy workaround until we fix the graph,
|
||||
// jack no longer follows zap.store but it still shows
|
||||
const jacksNpub =
|
||||
'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m';
|
||||
final hasUser = trustedUsers.contains(user);
|
||||
return Wrap(
|
||||
children: [
|
||||
if (hasUser) Text('You, '),
|
||||
for (final tu in trustedUsers)
|
||||
if (tu != user && tu.npub != jacksNpub)
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Wrap(
|
||||
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;
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
if (hasUser && npub != franzapsNpub) TextSpan(text: 'You, '),
|
||||
for (final tu in trustedUsers)
|
||||
if (tu.npub != npub)
|
||||
TextSpan(
|
||||
style: TextStyle(height: 1.6),
|
||||
children: [
|
||||
RoundedImage(url: tu.avatarUrl, size: 22),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
softWrap: true,
|
||||
'${tu.nameOrNpub}${trustedUsers.indexOf(tu) == trustedUsers.length - 1 ? '' : ','}',
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
RoundedImage(url: tu.avatarUrl, size: 20),
|
||||
Text(
|
||||
' ${tu.nameOrNpub}${trustedUsers.indexOf(tu) == trustedUsers.length - 1 ? '' : ', '}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
if (trustedUsers.indexOf(tu) ==
|
||||
trustedUsers.length - 1)
|
||||
Text('and others follow this signer.',
|
||||
softWrap: true)
|
||||
TextSpan(
|
||||
text: ' and others follow this signer on nostr.',
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncError(:final error) =>
|
||||
Center(child: Text('Error checking web of trust: $error')),
|
||||
_ => Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Loading web of trust connections...'),
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
],
|
||||
))
|
||||
child: SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +83,6 @@ class SearchScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
Gap(10),
|
||||
// if (state.hasMessage)
|
||||
// Expanded(
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// state.message!,
|
||||
// textAlign: TextAlign.center,
|
||||
// style: context.theme.textTheme.bodyLarge,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
if (state.hasError) Text(state.error!.toString()),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
@@ -120,11 +110,16 @@ class SearchScreen extends HookConsumerWidget {
|
||||
|
||||
final categoriesAppProvider =
|
||||
FutureProvider.family<List<App>, AppCategory>((ref, category) async {
|
||||
final apps = await ref.apps.findAll(params: {'#d': appCategories[category]!});
|
||||
final appSets = await ref.appCurationSets.findAll(params: {
|
||||
'#d': [category.name]
|
||||
});
|
||||
if (appSets.isEmpty) return [];
|
||||
final apps = await ref.apps
|
||||
.findAll(params: {'#d': appSets.first.aTags.map((a) => a.split(':')[2])});
|
||||
return apps..shuffle();
|
||||
});
|
||||
|
||||
final selectedAppCategoryProvider = StateProvider((_) => AppCategory.wallets);
|
||||
final selectedAppCategoryProvider = StateProvider((_) => AppCategory.basics);
|
||||
|
||||
class CategoriesContainer extends HookConsumerWidget {
|
||||
const CategoriesContainer({
|
||||
@@ -196,10 +191,10 @@ class WrapLayout extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutGrid(
|
||||
key: UniqueKey(),
|
||||
columnGap: 10, // Adjust the gap between columns as needed
|
||||
rowGap: 10, // Adjust the gap between rows as needed
|
||||
columnGap: 10,
|
||||
rowGap: 10,
|
||||
rowSizes:
|
||||
List<FixedTrackSize>.generate((8 / columns).ceil(), (_) => 110.px),
|
||||
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))),
|
||||
@@ -259,65 +254,11 @@ class SearchResultNotifier extends AsyncNotifier<List<App>> {
|
||||
}
|
||||
|
||||
enum AppCategory {
|
||||
wallets(label: 'Wallets'),
|
||||
nostr(label: 'Nostr'),
|
||||
basics(label: 'Basics'),
|
||||
privacy(label: 'Privacy & Security'),
|
||||
productivity(label: 'Productivity');
|
||||
nostr(label: 'Nostr'),
|
||||
bitcoin(label: 'Bitcoin'),
|
||||
privacy(label: 'Privacy & Security');
|
||||
|
||||
final String label;
|
||||
const AppCategory({required this.label});
|
||||
}
|
||||
|
||||
final appCategories = {
|
||||
AppCategory.basics: [
|
||||
"org.fossify.notes",
|
||||
"org.fossify.filemanager",
|
||||
"org.fossify.contacts",
|
||||
"org.fossify.calendar",
|
||||
"io.sanford.wormhole_william",
|
||||
"me.zhanghai.android.files",
|
||||
"org.breezyweather",
|
||||
"app.organicmaps.web",
|
||||
],
|
||||
AppCategory.wallets: [
|
||||
"com.greenaddress.greenbits_android_wallet",
|
||||
"io.nunchuk.android",
|
||||
"io.bluewallet.bluewallet",
|
||||
"io.aquawallet.android",
|
||||
"app.zeusln.zeus",
|
||||
"com.mutinywallet.mutinywallet",
|
||||
"xyz.elliptica.enuts.beta",
|
||||
"fr.acinq.phoenix.mainnet",
|
||||
],
|
||||
AppCategory.privacy: [
|
||||
"chat.simplex.app",
|
||||
"im.molly.app",
|
||||
"com.kunzisoft.keepass.free",
|
||||
"com.x8bit.bitwarden",
|
||||
"io.simplelogin.android.fdroid",
|
||||
"eu.darken.myperm",
|
||||
"net.ivpn.client",
|
||||
"ch.protonvpn.android",
|
||||
],
|
||||
AppCategory.nostr: [
|
||||
"com.greenart7c3.citrine",
|
||||
"com.greenart7c3.nostrsigner",
|
||||
"net.primal.android",
|
||||
"com.oxchat.nostr",
|
||||
"com.vitorpamplona.amethyst",
|
||||
"com.nostr.universe",
|
||||
"com.dluvian.voyage",
|
||||
"com.apps.freerse",
|
||||
],
|
||||
AppCategory.productivity: [
|
||||
"io.ente.photos.independent",
|
||||
"md.obsidian",
|
||||
"com.logseq.app",
|
||||
"com.nutomic.syncthingandroid",
|
||||
"ch.protonmail.android",
|
||||
"org.localsend.localsend_app",
|
||||
"org.fossify.gallery",
|
||||
"org.fossify.musicplayer",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'package:async_button_builder/async_button_builder.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_data/flutter_data.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/main.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/widgets/app_drawer.dart';
|
||||
|
||||
class SettingsScreen extends HookConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -15,11 +20,14 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final user = ref.settings.watchOne('_').model!.user.value;
|
||||
final user = ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model!
|
||||
.user
|
||||
.value;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
@@ -40,27 +48,69 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
maxLines: 10,
|
||||
),
|
||||
Gap(20),
|
||||
AsyncButtonBuilder(
|
||||
loadingWidget: SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
onPressed: () async {
|
||||
if (user == null) {
|
||||
scaffoldKey.currentState!.openDrawer();
|
||||
return;
|
||||
}
|
||||
final text = '${controller.text.trim()} [from ${user.npub}]';
|
||||
final event = BaseEvent.partial(content: text).sign(kI);
|
||||
await ref.apps.nostrAdapter.notifier
|
||||
.publish(event, relayUrls: ['wss://relay.zap.store']);
|
||||
controller.clear();
|
||||
},
|
||||
builder: (context, child, callback, buttonState) {
|
||||
return ElevatedButton(
|
||||
onPressed: callback,
|
||||
child: child,
|
||||
if (user == null) LoginContainer(minimal: true),
|
||||
if (user != null)
|
||||
AsyncButtonBuilder(
|
||||
loadingWidget: SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator()),
|
||||
onPressed: () async {
|
||||
final text =
|
||||
'${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]';
|
||||
final event = BaseEvent.partial(content: text).sign(kI);
|
||||
await ref.apps.nostrAdapter.notifier
|
||||
.publish(event, relayUrls: ['wss://relay.zap.store']);
|
||||
controller.clear();
|
||||
},
|
||||
builder: (context, child, callback, state) {
|
||||
return switch (state) {
|
||||
_ => ElevatedButton(
|
||||
onPressed: callback,
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
},
|
||||
child: Text('Send as ${user.nameOrNpub}'),
|
||||
),
|
||||
Gap(40),
|
||||
Divider(),
|
||||
Gap(40),
|
||||
Text(
|
||||
'Tools',
|
||||
style: context.theme.textTheme.headlineLarge!
|
||||
.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Confirm clear'),
|
||||
content: Text(
|
||||
'Are you sure you want to clear the local cache and restart the app?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text('Confirm'),
|
||||
onPressed: () {
|
||||
ref.read(localStorageProvider).destroy().then((_) {
|
||||
Phoenix.rebirth(context);
|
||||
Navigator.of(context).pop();
|
||||
context.go('/');
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(user != null ? 'Send' : 'Tap to log in'),
|
||||
child: Text('Delete local cache'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+20
-20
@@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/models/app.dart';
|
||||
import 'package:zapstore/models/release.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
import 'package:zapstore/widgets/rounded_image.dart';
|
||||
|
||||
@@ -74,6 +73,7 @@ class AppCard extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Gap(2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -82,7 +82,7 @@ class AppCard extends HookConsumerWidget {
|
||||
app!.name!,
|
||||
minFontSize: 16,
|
||||
style: TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.bold),
|
||||
fontSize: 19, fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
@@ -95,17 +95,14 @@ class AppCard extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
// TODO fix markdown?
|
||||
app!.content,
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w300),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
softWrap: true,
|
||||
),
|
||||
Gap(6),
|
||||
Text(
|
||||
app!.content,
|
||||
style:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w300),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
softWrap: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -131,16 +128,19 @@ class TinyAppCard extends HookConsumerWidget {
|
||||
onTap: () {
|
||||
context.go('/details', extra: app);
|
||||
},
|
||||
child: Padding(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 120,
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: app == null
|
||||
? Skeletonizer.zone(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Bone.square(uniRadius: 10, size: 58),
|
||||
Bone.square(uniRadius: 10, size: 60),
|
||||
Gap(8),
|
||||
Bone.text(),
|
||||
Bone.multiText(lines: 2, fontSize: 10),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -150,7 +150,7 @@ class TinyAppCard extends HookConsumerWidget {
|
||||
children: [
|
||||
RoundedImage(
|
||||
url: app!.icons.firstOrNull,
|
||||
size: 60,
|
||||
size: 58,
|
||||
radius: 12,
|
||||
),
|
||||
Gap(8),
|
||||
@@ -160,10 +160,10 @@ class TinyAppCard extends HookConsumerWidget {
|
||||
app!.name!,
|
||||
textAlign: TextAlign.center,
|
||||
minFontSize: 10,
|
||||
style: TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.clip,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
wrapWords: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+79
-71
@@ -7,8 +7,13 @@ import 'package:zapstore/main.data.dart';
|
||||
import 'package:zapstore/models/settings.dart';
|
||||
import 'package:zapstore/widgets/rounded_image.dart';
|
||||
|
||||
class AppDrawer extends HookConsumerWidget {
|
||||
AppDrawer({super.key});
|
||||
class LoginContainer extends HookConsumerWidget {
|
||||
final String labelText;
|
||||
final bool minimal;
|
||||
LoginContainer(
|
||||
{super.key,
|
||||
this.minimal = false,
|
||||
this.labelText = 'Input your NIP-05 or npub (no nsec!)'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -19,83 +24,86 @@ class AppDrawer extends HookConsumerWidget {
|
||||
.value;
|
||||
final controller = useTextEditingController();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24, left: 16),
|
||||
child: ListView(
|
||||
children: [
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46),
|
||||
if (!minimal) Gap(10),
|
||||
if (user != null && !minimal)
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
user.nameOrNpub,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Gap(4),
|
||||
Icon(Icons.verified, color: Colors.lightBlue, size: 18),
|
||||
],
|
||||
),
|
||||
// if (user.following.isNotEmpty)
|
||||
// Text('${user.following.length} contacts'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (user == null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 4, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
RoundedImage(url: user?.avatarUrl, size: 46),
|
||||
Gap(10),
|
||||
if (user != null)
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
user.nameOrNpub,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Gap(4),
|
||||
Icon(Icons.verified,
|
||||
color: Colors.lightBlue, size: 18),
|
||||
],
|
||||
Gap(20),
|
||||
Text(labelText),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: AsyncButtonBuilder(
|
||||
loadingWidget: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
onPressed: () async {
|
||||
final user =
|
||||
await ref.users.findOne(controller.text.trim());
|
||||
ref.settings.findOneLocalById('_')!.user.value = user;
|
||||
},
|
||||
builder: (context, child, callback, buttonState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SizedBox(
|
||||
child: ElevatedButton(
|
||||
onPressed: callback,
|
||||
style: ElevatedButton.styleFrom(
|
||||
disabledBackgroundColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent),
|
||||
child: child,
|
||||
),
|
||||
// if (user.following.isNotEmpty)
|
||||
// Text('${user.following.length} contacts'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Log in'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (user == null)
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'NIP-05 address or npub (no nsec!)',
|
||||
),
|
||||
),
|
||||
Gap(5),
|
||||
if (user == null)
|
||||
AsyncButtonBuilder(
|
||||
loadingWidget: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
onPressed: () async {
|
||||
final user =
|
||||
await ref.users.findOne(controller.text.trim());
|
||||
ref.settings.findOneLocalById('_')!.user.value = user;
|
||||
},
|
||||
builder: (context, child, callback, buttonState) {
|
||||
return ElevatedButton(
|
||||
onPressed: callback,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Text('Log in'),
|
||||
),
|
||||
if (user != null)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
ref.settings.findOneLocalById('_')!.user.value = null;
|
||||
controller.clear();
|
||||
},
|
||||
child: Text('Log out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(5),
|
||||
if (user != null && !minimal)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
ref.settings.findOneLocalById('_')!.user.value = null;
|
||||
controller.clear();
|
||||
},
|
||||
child: Text('Log out'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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/widgets/rounded_image.dart';
|
||||
|
||||
class UserAvatar extends HookConsumerWidget {
|
||||
@@ -8,7 +9,11 @@ class UserAvatar extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.settings.watchOne('_').model!.user.value;
|
||||
final user = ref.settings
|
||||
.watchOne('_', alsoWatch: (_) => {_.user})
|
||||
.model!
|
||||
.user
|
||||
.value;
|
||||
return RoundedImage(url: user?.avatarUrl, size: 46);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +394,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
flutter_phoenix:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_phoenix
|
||||
sha256: "39589dac934ea476d0e43fb60c1ddfba58f14960743640c8250dea11c4333378"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter_riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -716,6 +724,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
percent_indicator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: percent_indicator
|
||||
sha256: c37099ad833a883c9d71782321cb65c3a848c21b6939b6185f0ff6640d05814c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ name: zapstore
|
||||
description: The permissionless app store
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.1.1
|
||||
version: 0.1.2+12
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
@@ -40,6 +40,8 @@ dependencies:
|
||||
install_plugin: ^2.1.0
|
||||
flutter_layout_grid: ^2.0.6
|
||||
skeletonizer: ^1.1.2+1
|
||||
flutter_phoenix: ^1.1.1
|
||||
percent_indicator: ^4.2.3
|
||||
|
||||
dependency_overrides:
|
||||
purplebase:
|
||||
|
||||
Reference in New Issue
Block a user