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:
franzap
2024-05-27 17:14:14 -03:00
parent 9f07511ee7
commit 3ac22cb8f7
19 changed files with 750 additions and 521 deletions
-3
View File
@@ -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", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
-2
View File
@@ -65,8 +65,6 @@ android {
buildTypes { buildTypes {
release { 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.debug
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
+87 -54
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -16,21 +17,22 @@ const kDbVersion = 1;
void main() { void main() {
runApp( runApp(
ProviderScope( Phoenix(
overrides: [ child: ProviderScope(
localStorageProvider.overrideWithValue( overrides: [
LocalStorage( localStorageProvider.overrideWithValue(
baseDirFn: () async { LocalStorage(
final path = (await getApplicationSupportDirectory()).path; baseDirFn: () async {
print('initializing local storage at $path'); final path = (await getApplicationSupportDirectory()).path;
return path; print('initializing local storage at $path');
}, return path;
// TODO RESTORE! },
clear: LocalStorageClearStrategy.always, clear: LocalStorageClearStrategy.whenError,
), ),
) )
], ],
child: const ZapstoreApp(), child: const ZapstoreApp(),
),
), ),
); );
} }
@@ -107,9 +109,7 @@ final goRouter = GoRouter(
GoRoute( GoRoute(
path: '/settings', path: '/settings',
pageBuilder: (context, state) => NoTransitionPage( 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 { final newInitializer = FutureProvider<void>((ref) async {
await ref.read(initializeFlutterData(adapterProvidersMap).future); await ref.read(initializeFlutterData(adapterProvidersMap).future);
ref ref
.read(relayMessageNotifierProvider.notifier) .read(relayMessageNotifierProvider.notifier)
.initialize(['wss://relay.zap.store', 'wss://relay.nostr.band']); .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 { class ScaffoldWithNestedNavigation extends HookConsumerWidget {
const ScaffoldWithNestedNavigation({ const ScaffoldWithNestedNavigation({
Key? key, Key? key,
@@ -147,32 +164,41 @@ class ScaffoldWithNestedNavigation extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final initializer = ref.watch(newInitializer);
return SafeArea( return SafeArea(
child: LayoutBuilder( child: initializer.when(
builder: (context, constraints) { data: (_) => LayoutBuilder(
if (constraints.maxWidth < 550) { builder: (context, constraints) {
return ScaffoldWithNavigationBar( if (constraints.maxWidth < 550) {
body: navigationShell, return MobileScaffold(
selectedIndex: navigationShell.currentIndex, body: navigationShell,
onDestinationSelected: _goBranch, selectedIndex: navigationShell.currentIndex,
); onDestinationSelected: _goBranch,
} else { );
return ScaffoldWithNavigationRail( } else {
body: navigationShell, return DesktopScaffold(
selectedIndex: navigationShell.currentIndex, body: navigationShell,
onDestinationSelected: _goBranch, 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 { class MobileScaffold extends StatelessWidget {
const ScaffoldWithNavigationBar({ const MobileScaffold({
super.key, super.key,
required this.body, required this.body,
required this.selectedIndex, required this.selectedIndex,
@@ -183,20 +209,12 @@ class ScaffoldWithNavigationBar extends HookConsumerWidget {
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final initializer = ref.watch(newInitializer);
return Scaffold( return Scaffold(
key: scaffoldKey, key: scaffoldKey,
body: Padding( body: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
child: initializer.when( child: body,
data: (_) => body,
error: (e, stack) {
print(stack);
return Text('error $e');
},
loading: () => const Center(child: CircularProgressIndicator()),
),
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
height: 60, height: 60,
@@ -224,14 +242,17 @@ class ScaffoldWithNavigationBar extends HookConsumerWidget {
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
), ),
drawer: Drawer( drawer: Drawer(
child: AppDrawer(), child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: LoginContainer(),
),
), ),
); );
} }
} }
class ScaffoldWithNavigationRail extends StatelessWidget { class DesktopScaffold extends StatelessWidget {
const ScaffoldWithNavigationRail({ const DesktopScaffold({
super.key, super.key,
required this.body, required this.body,
required this.selectedIndex, required this.selectedIndex,
@@ -247,25 +268,37 @@ class ScaffoldWithNavigationRail extends StatelessWidget {
body: Row( body: Row(
children: [ children: [
NavigationRail( NavigationRail(
minWidth: 120,
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: [ destinations: [
const NavigationRailDestination( const NavigationRailDestination(
label: Text('Search'), label: Text('Home'),
icon: Icon(Icons.search_outlined), icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_filled),
), ),
const NavigationRailDestination( const NavigationRailDestination(
label: Text('Updates'), label: Text('Updates'),
icon: Icon(Icons.download_for_offline_outlined), icon: Icon(Icons.download_for_offline_outlined),
selectedIcon: Icon(Icons.download_for_offline),
), ),
const NavigationRailDestination( const NavigationRailDestination(
label: Text('Profile'), label: Text('Settings'),
icon: Icon(Icons.person_outline), 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
View File
@@ -6,6 +6,7 @@
import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/app.dart';
import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/release.dart';
@@ -13,7 +14,8 @@ import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/user.dart';
final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
'apps': appsAdapterProvider, 'appCurationSets': appCurationSetsAdapterProvider,
'apps': appsAdapterProvider,
'fileMetadata': fileMetadataAdapterProvider, 'fileMetadata': fileMetadataAdapterProvider,
'releases': releasesAdapterProvider, 'releases': releasesAdapterProvider,
'settings': settingsAdapterProvider, 'settings': settingsAdapterProvider,
@@ -21,6 +23,7 @@ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
}; };
extension AdapterWidgetRefX on WidgetRef { extension AdapterWidgetRefX on WidgetRef {
Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch;
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch; Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch; Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch;
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch; Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch;
@@ -30,6 +33,7 @@ extension AdapterWidgetRefX on WidgetRef {
extension AdapterRefX on Ref { 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<App> get apps => watch(appsAdapterProvider)..internalWatch = watch as Watcher;
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher; Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher;
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher; Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher;
+139 -132
View File
@@ -6,7 +6,6 @@ import 'dart:typed_data';
import 'package:android_package_manager/android_package_manager.dart'; import 'package:android_package_manager/android_package_manager.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_data/flutter_data.dart';
import 'package:install_plugin/install_plugin.dart'; import 'package:install_plugin/install_plugin.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
@@ -26,7 +25,7 @@ part 'app.g.dart';
@JsonSerializable() @JsonSerializable()
@DataAdapter([NostrAdapter, AppAdapter]) @DataAdapter([NostrAdapter, AppAdapter])
class App extends Event<App> with BaseApp { class App extends BaseApp with DataModelMixin<App> {
late final HasMany<Release> releases; late final HasMany<Release> releases;
late final BelongsTo<User> signer; late final BelongsTo<User> signer;
late final BelongsTo<User> developer; late final BelongsTo<User> developer;
@@ -45,137 +44,7 @@ class App extends Event<App> with BaseApp {
a.architectures.contains('arm64-v8a')) a.architectures.contains('arm64-v8a'))
.firstOrNull; .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 canInstall => status == AppInstallStatus.installable;
bool get canUpdate => status == AppInstallStatus.updatable; bool get canUpdate => status == AppInstallStatus.updatable;
bool get isUpdated => status == AppInstallStatus.updated; 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 { Future<bool> _isHashMismatch(String path, String hash) async {
return await Isolate.run(() async { return await Isolate.run(() async {
final bytes = await File(path).readAsBytes(); final bytes = await File(path).readAsBytes();
+27
View File
@@ -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);
}
}
+95
View File
@@ -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,
};
+1 -1
View File
@@ -9,7 +9,7 @@ part 'file_metadata.g.dart';
@JsonSerializable() @JsonSerializable()
@DataAdapter([NostrAdapter, FileMetadataAdapter]) @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<User> author;
late final BelongsTo<Release> release = BelongsTo(); late final BelongsTo<Release> release = BelongsTo();
late final BelongsTo<User> signer; late final BelongsTo<User> signer;
+5 -3
View File
@@ -1,17 +1,17 @@
import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
abstract class Event<T extends Event<T>> = BaseEvent with DataModelMixin<T>; mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
late final RelayMessageNotifier notifier = late final RelayMessageNotifier notifier =
ref.read(relayMessageNotifierProvider.notifier); ref.read(relayMessageNotifierProvider.notifier);
// TODO rethink this
Map<int, String> kindType = { Map<int, String> kindType = {
0: 'users', 0: 'users',
3: 'users', 3: 'users',
1063: 'fileMetadata', 1063: 'fileMetadata',
30063: 'releases', 30063: 'releases',
30267: 'appCurationSets',
32267: 'apps' 32267: 'apps'
}; };
@@ -32,6 +32,7 @@ mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
if (dTags.length == 1) { if (dTags.length == 1) {
map['id'] = (dTags.first as List)[1]; map['id'] = (dTags.first as List)[1];
} }
// TODO remove
map['signer'] = map['pubkey']; map['signer'] = map['pubkey'];
final zapTags = (map['tags'] as Iterable).where((t) => t[0] == 'zap'); final zapTags = (map['tags'] as Iterable).where((t) => t[0] == 'zap');
if (zapTags.length == 1) { if (zapTags.length == 1) {
@@ -52,6 +53,7 @@ mixin NostrAdapter<T extends Event<T>> on Adapter<T> {
return DeserializedData<T>(models, included: included); return DeserializedData<T>(models, included: included);
} }
// TODO remove
int get kind => int get kind =>
kindType.entries.firstWhere((e) => e.value == internalType).key; kindType.entries.firstWhere((e) => e.value == internalType).key;
+1 -1
View File
@@ -11,7 +11,7 @@ part 'release.g.dart';
@JsonSerializable() @JsonSerializable()
@DataAdapter([NostrAdapter, ReleaseAdapter]) @DataAdapter([NostrAdapter, ReleaseAdapter])
class Release extends Event<Release> with BaseRelease { class Release extends BaseRelease with DataModelMixin<Release> {
late final HasMany<FileMetadata> artifacts; late final HasMany<FileMetadata> artifacts;
late final BelongsTo<App> app; late final BelongsTo<App> app;
late final BelongsTo<User> signer; late final BelongsTo<User> signer;
+14 -4
View File
@@ -10,7 +10,7 @@ part 'user.g.dart';
@JsonSerializable() @JsonSerializable()
@DataAdapter([NostrAdapter, UserAdapter]) @DataAdapter([NostrAdapter, UserAdapter])
class User extends Event<User> with BaseUser { class User extends BaseUser with DataModelMixin<User> {
@DataRelationship(inverse: 'followers') @DataRelationship(inverse: 'followers')
late final HasMany<User> following; late final HasMany<User> following;
@DataRelationship(inverse: 'following') @DataRelationship(inverse: 'following')
@@ -102,6 +102,10 @@ mixin UserAdapter on NostrAdapter<User> {
final result = final result =
await notifier.query(req, relayUrls: ['wss://relay.nostr.band']); 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); final data = await deserializeAsync(result, save: true);
return data.models; return data.models;
} }
@@ -153,8 +157,8 @@ mixin UserAdapter on NostrAdapter<User> {
}); });
} }
Future<List<User>> getTrusted(User u1, User u2) async { Future<List<User>> getTrusted(String npub1, String npub2) async {
final url = 'https://zap.store/api/trust/${u1.npub}/${u2.npub}'; final url = 'https://zap.store/api/trust/$npub1/$npub2';
final users = await sendRequest( final users = await sendRequest(
Uri.parse(url), Uri.parse(url),
onSuccess: (response, label) async { onSuccess: (response, label) async {
@@ -163,7 +167,13 @@ mixin UserAdapter on NostrAdapter<User> {
Map<String, dynamic>.from(jsonDecode(response.body.toString())); Map<String, dynamic>.from(jsonDecode(response.body.toString()));
final trustedKeys = map.keys.map((npub) => npub.hexKey); 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!; return users!;
+167 -133
View File
@@ -8,13 +8,15 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.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/author_container.dart';
import 'package:zapstore/widgets/pill_widget.dart'; import 'package:zapstore/widgets/pill_widget.dart';
import 'package:zapstore/widgets/rounded_image.dart'; import 'package:zapstore/widgets/rounded_image.dart';
@@ -196,15 +198,15 @@ class AppDetailScreen extends HookConsumerWidget {
), ),
Divider(height: 50), Divider(height: 50),
Text( Text(
'Releases'.toUpperCase(), 'Latest release'.toUpperCase(),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
letterSpacing: 3, letterSpacing: 3,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
), ),
), ),
for (final release in app.releases.ordered) if (app.releases.ordered.isNotEmpty)
ReleaseCard(release: release), ReleaseCard(release: app.releases.ordered.first),
], ],
), ),
), ),
@@ -413,55 +415,67 @@ class InstallButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(installationProgressProvider(app.identifier)); final progress = ref.watch(installationProgressProvider(app.identifier));
return SizedBox( return GestureDetector(
width: double.infinity, onTap: switch (app.status) {
child: ElevatedButton( AppInstallStatus.differentArchitecture => null,
onPressed: switch (app.status) { AppInstallStatus.downgrade => null,
AppInstallStatus.differentArchitecture => null, AppInstallStatus.updated => () {
AppInstallStatus.downgrade => null, LaunchApp.openApp(androidPackageName: app.id!.toString());
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( backgroundColor: switch (progress) {
disabledForegroundColor: Colors.white, ErrorInstallProgress() => Colors.red,
disabledBackgroundColor: Colors.blue[700], _ => Colors.blue[700],
foregroundColor: Colors.white, },
backgroundColor: switch (progress) { progressColor: Colors.blue[800],
ErrorInstallProgress() => Colors.red, barRadius: Radius.circular(18),
_ => Colors.blue[700], animateFromLastPercent: true,
}), center: switch (app.status) {
child: switch (app.status) {
AppInstallStatus.loading => AppInstallStatus.loading =>
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()), SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
AppInstallStatus.differentArchitecture => AppInstallStatus.differentArchitecture => Text(
Text('Sorry, release does not support your device'), 'Sorry, release does not support your device',
textAlign: TextAlign.center,
),
AppInstallStatus.downgrade => Text( 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'), AppInstallStatus.updated => Text('Open'),
_ => switch (progress) { _ => switch (progress) {
IdleInstallProgress() => app.canUpdate IdleInstallProgress() => app.canUpdate
@@ -498,59 +512,71 @@ class InstallAlertDialog extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( return AlertDialog(
elevation: 10, elevation: 10,
title: Text( title: Text(
'Are you sure you want to install ${app.name}?', 'Are you sure you want to install ${app.name}?',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
content: Column( content: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
'By installing this app you are trusting the signer now and for all future updates. Make sure you know who they are.'), Text(
Gap(20), 'By installing this app you are trusting the signer now and for all future updates. Make sure you know who they are.'),
// SignerAndDeveloperRow(app: app), Gap(20),
AuthorContainer( // SignerAndDeveloperRow(app: app),
user: app.signer.value!, text: 'Signed by', oneLine: true), if (app.signer.value != null)
Gap(20), AuthorContainer(
if (user != null) user: app.signer.value!, text: 'Signed by', oneLine: true),
Column( Gap(20),
crossAxisAlignment: CrossAxisAlignment.start, if (app.signer.value != null)
children: [ WebOfTrustContainer(
WebOfTrustContainer(user: user, app: app), user: user, npub: user?.npub, npub2: app.signer.value!.npub),
Gap(20), if (user != null)
Text('The app will be downloaded from:\n'), Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
app.latestMetadata!.urls.firstOrNull ?? children: [
'https://cdn.zap.store/${app.latestMetadata!.hash}', Gap(20),
style: TextStyle(fontWeight: FontWeight.bold), 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: [ actions: [
if (user == null) if (user == null)
TextButton( LoginContainer(
onPressed: () { minimal: true,
Navigator.of(context).pop(); labelText:
scaffoldKey.currentState!.openDrawer(); 'Log in to view your own connections (NIP-05 or npub, no nsec!)',
}, ),
child: Text('Log in to view web of trust', Padding(
style: TextStyle(fontWeight: FontWeight.bold)), 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( TextButton(
onPressed: () { onPressed: () {
@@ -565,66 +591,74 @@ class InstallAlertDialog extends ConsumerWidget {
} }
final wotProvider = FutureProvider.autoDispose final wotProvider = FutureProvider.autoDispose
.family<List<User>, ({User user, App app})>((ref, arg) async { .family<List<User>, ({String npub1, String npub2})>((ref, arg) async {
return ref.users.userAdapter.getTrusted(arg.user, arg.app.signer.value!); 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 { class WebOfTrustContainer extends HookConsumerWidget {
const WebOfTrustContainer({ const WebOfTrustContainer({
super.key, super.key,
required this.user, this.user,
required this.app, String? npub,
required this.npub2,
}); });
final User user; final User? user;
final App app; final String npub2;
String get npub => user?.npub ?? franzapsNpub;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return switch (ref.watch(wotProvider((user: user, app: app)))) { return switch (ref.watch(wotProvider((npub1: npub, npub2: npub2)))) {
AsyncData<List<User>>(value: final trustedUsers) => () { AsyncData<List<User>>(value: final trustedUsers) => Builder(
// Crappy workaround until we fix the graph, builder: (context) {
// jack no longer follows zap.store but it still shows final hasUser =
const jacksNpub = trustedUsers.firstWhereOrNull((u) => u.npub == npub) != null;
'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m'; return RichText(
final hasUser = trustedUsers.contains(user); text: TextSpan(
return Wrap( children: [
children: [ if (hasUser && npub != franzapsNpub) TextSpan(text: 'You, '),
if (hasUser) Text('You, '), for (final tu in trustedUsers)
for (final tu in trustedUsers) if (tu.npub != npub)
if (tu != user && tu.npub != jacksNpub) TextSpan(
Wrap( style: TextStyle(height: 1.6),
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Wrap(
children: [ children: [
RoundedImage(url: tu.avatarUrl, size: 22), WidgetSpan(
SizedBox(width: 4), alignment: PlaceholderAlignment.middle,
Text( child: Wrap(
softWrap: true, crossAxisAlignment: WrapCrossAlignment.center,
'${tu.nameOrNpub}${trustedUsers.indexOf(tu) == trustedUsers.length - 1 ? '' : ','}', 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) == if (trustedUsers.indexOf(tu) ==
trustedUsers.length - 1) trustedUsers.length - 1)
Text('and others follow this signer.', TextSpan(
softWrap: true) text: ' and others follow this signer on nostr.',
)
], ],
) ),
], ],
), ),
], );
); },
}(), ),
AsyncError(:final error) => AsyncError(:final error) =>
Center(child: Text('Error checking web of trust: $error')), Center(child: Text('Error checking web of trust: $error')),
_ => Center( _ => Center(
child: Column( child: SizedBox(
children: [ width: 14, height: 14, child: CircularProgressIndicator()),
Text('Loading web of trust connections...'), )
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
],
))
}; };
} }
} }
+13 -72
View File
@@ -83,16 +83,6 @@ class SearchScreen extends HookConsumerWidget {
], ],
), ),
Gap(10), 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()), if (state.hasError) Text(state.error!.toString()),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -120,11 +110,16 @@ class SearchScreen extends HookConsumerWidget {
final categoriesAppProvider = final categoriesAppProvider =
FutureProvider.family<List<App>, AppCategory>((ref, category) async { 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(); return apps..shuffle();
}); });
final selectedAppCategoryProvider = StateProvider((_) => AppCategory.wallets); final selectedAppCategoryProvider = StateProvider((_) => AppCategory.basics);
class CategoriesContainer extends HookConsumerWidget { class CategoriesContainer extends HookConsumerWidget {
const CategoriesContainer({ const CategoriesContainer({
@@ -196,10 +191,10 @@ class WrapLayout extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutGrid( return LayoutGrid(
key: UniqueKey(), key: UniqueKey(),
columnGap: 10, // Adjust the gap between columns as needed columnGap: 10,
rowGap: 10, // Adjust the gap between rows as needed rowGap: 10,
rowSizes: rowSizes:
List<FixedTrackSize>.generate((8 / columns).ceil(), (_) => 110.px), List<FixedTrackSize>.generate((8 / columns).ceil(), (_) => 130.px),
columnSizes: List<FlexibleTrackSize>.generate(columns, (_) => 1.fr), columnSizes: List<FlexibleTrackSize>.generate(columns, (_) => 1.fr),
children: children:
List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))), List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))),
@@ -259,65 +254,11 @@ class SearchResultNotifier extends AsyncNotifier<List<App>> {
} }
enum AppCategory { enum AppCategory {
wallets(label: 'Wallets'),
nostr(label: 'Nostr'),
basics(label: 'Basics'), basics(label: 'Basics'),
privacy(label: 'Privacy & Security'), nostr(label: 'Nostr'),
productivity(label: 'Productivity'); bitcoin(label: 'Bitcoin'),
privacy(label: 'Privacy & Security');
final String label; final String label;
const AppCategory({required this.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",
],
};
+72 -22
View File
@@ -1,13 +1,18 @@
import 'package:async_button_builder/async_button_builder.dart'; import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/app_drawer.dart';
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -15,11 +20,14 @@ class SettingsScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController(); final controller = useTextEditingController();
final user = ref.settings.watchOne('_').model!.user.value; final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
.user
.value;
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -40,27 +48,69 @@ class SettingsScreen extends HookConsumerWidget {
maxLines: 10, maxLines: 10,
), ),
Gap(20), Gap(20),
AsyncButtonBuilder( if (user == null) LoginContainer(minimal: true),
loadingWidget: SizedBox( if (user != null)
width: 14, height: 14, child: CircularProgressIndicator()), AsyncButtonBuilder(
onPressed: () async { loadingWidget: SizedBox(
if (user == null) { width: 14, height: 14, child: CircularProgressIndicator()),
scaffoldKey.currentState!.openDrawer(); onPressed: () async {
return; final text =
} '${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]';
final text = '${controller.text.trim()} [from ${user.npub}]'; final event = BaseEvent.partial(content: text).sign(kI);
final event = BaseEvent.partial(content: text).sign(kI); await ref.apps.nostrAdapter.notifier
await ref.apps.nostrAdapter.notifier .publish(event, relayUrls: ['wss://relay.zap.store']);
.publish(event, relayUrls: ['wss://relay.zap.store']); controller.clear();
controller.clear(); },
}, builder: (context, child, callback, state) {
builder: (context, child, callback, buttonState) { return switch (state) {
return ElevatedButton( _ => ElevatedButton(
onPressed: callback, onPressed: callback,
child: child, 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
View File
@@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/widgets/pill_widget.dart'; import 'package:zapstore/widgets/pill_widget.dart';
import 'package:zapstore/widgets/rounded_image.dart'; import 'package:zapstore/widgets/rounded_image.dart';
@@ -74,6 +73,7 @@ class AppCard extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Gap(2),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -82,7 +82,7 @@ class AppCard extends HookConsumerWidget {
app!.name!, app!.name!,
minFontSize: 16, minFontSize: 16,
style: TextStyle( style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold), fontSize: 19, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
), ),
@@ -95,17 +95,14 @@ class AppCard extends HookConsumerWidget {
), ),
], ],
), ),
Padding( Gap(6),
padding: const EdgeInsets.only(top: 4), Text(
child: Text( app!.content,
// TODO fix markdown? style:
app!.content, TextStyle(fontSize: 14, fontWeight: FontWeight.w300),
style: TextStyle( overflow: TextOverflow.ellipsis,
fontSize: 14, fontWeight: FontWeight.w300), maxLines: 1,
overflow: TextOverflow.ellipsis, softWrap: true,
maxLines: 1,
softWrap: true,
),
), ),
], ],
), ),
@@ -131,16 +128,19 @@ class TinyAppCard extends HookConsumerWidget {
onTap: () { onTap: () {
context.go('/details', extra: app); context.go('/details', extra: app);
}, },
child: Padding( child: Container(
constraints: BoxConstraints(
maxWidth: 120,
),
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: app == null child: app == null
? Skeletonizer.zone( ? Skeletonizer.zone(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Bone.square(uniRadius: 10, size: 58), Bone.square(uniRadius: 10, size: 60),
Gap(8), Gap(8),
Bone.text(), Bone.multiText(lines: 2, fontSize: 10),
], ],
), ),
) )
@@ -150,7 +150,7 @@ class TinyAppCard extends HookConsumerWidget {
children: [ children: [
RoundedImage( RoundedImage(
url: app!.icons.firstOrNull, url: app!.icons.firstOrNull,
size: 60, size: 58,
radius: 12, radius: 12,
), ),
Gap(8), Gap(8),
@@ -160,10 +160,10 @@ class TinyAppCard extends HookConsumerWidget {
app!.name!, app!.name!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
minFontSize: 10, minFontSize: 10,
style: TextStyle( style: TextStyle(fontSize: 11),
fontSize: 13, fontWeight: FontWeight.bold),
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
maxLines: 1, maxLines: 2,
wrapWords: false,
), ),
), ),
), ),
+79 -71
View File
@@ -7,8 +7,13 @@ import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/settings.dart';
import 'package:zapstore/widgets/rounded_image.dart'; import 'package:zapstore/widgets/rounded_image.dart';
class AppDrawer extends HookConsumerWidget { class LoginContainer extends HookConsumerWidget {
AppDrawer({super.key}); final String labelText;
final bool minimal;
LoginContainer(
{super.key,
this.minimal = false,
this.labelText = 'Input your NIP-05 or npub (no nsec!)'});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -19,83 +24,86 @@ class AppDrawer extends HookConsumerWidget {
.value; .value;
final controller = useTextEditingController(); final controller = useTextEditingController();
return Padding( return Column(
padding: const EdgeInsets.only(top: 24, left: 16), crossAxisAlignment: CrossAxisAlignment.start,
child: ListView( mainAxisSize: MainAxisSize.min,
children: [ 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( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Gap(20),
padding: EdgeInsets.only(top: 4, bottom: 4), Text(labelText),
child: Row( TextField(
children: [ autocorrect: false,
RoundedImage(url: user?.avatarUrl, size: 46), controller: controller,
Gap(10), decoration: InputDecoration(
if (user != null) suffixIcon: AsyncButtonBuilder(
Column( loadingWidget: SizedBox(
mainAxisAlignment: MainAxisAlignment.center, width: 14,
crossAxisAlignment: CrossAxisAlignment.start, height: 14,
children: [ child: CircularProgressIndicator(),
Row( ),
children: [ onPressed: () async {
Text( final user =
user.nameOrNpub, await ref.users.findOne(controller.text.trim());
style: TextStyle(fontWeight: FontWeight.bold), ref.settings.findOneLocalById('_')!.user.value = user;
), },
Gap(4), builder: (context, child, callback, buttonState) {
Icon(Icons.verified, return Padding(
color: Colors.lightBlue, size: 18), 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'),
),
],
); );
} }
} }
+6 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/widgets/rounded_image.dart'; import 'package:zapstore/widgets/rounded_image.dart';
class UserAvatar extends HookConsumerWidget { class UserAvatar extends HookConsumerWidget {
@@ -8,7 +9,11 @@ class UserAvatar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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); return RoundedImage(url: user?.avatarUrl, size: 46);
} }
} }
+16
View File
@@ -394,6 +394,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.1" 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: flutter_riverpod:
dependency: transitive dependency: transitive
description: description:
@@ -716,6 +724,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" 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: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
+3 -1
View File
@@ -2,7 +2,7 @@ name: zapstore
description: The permissionless app store description: The permissionless app store
publish_to: 'none' publish_to: 'none'
version: 0.1.1 version: 0.1.2+12
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
@@ -40,6 +40,8 @@ dependencies:
install_plugin: ^2.1.0 install_plugin: ^2.1.0
flutter_layout_grid: ^2.0.6 flutter_layout_grid: ^2.0.6
skeletonizer: ^1.1.2+1 skeletonizer: ^1.1.2+1
flutter_phoenix: ^1.1.1
percent_indicator: ^4.2.3
dependency_overrides: dependency_overrides:
purplebase: purplebase: