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",
"configurations": [
{
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+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()
@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;
+5 -3
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()),
)
};
}
}
+13 -72
View File
@@ -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",
],
};
+72 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
),
],
);
}
}
+6 -1
View File
@@ -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);
}
}
+16
View File
@@ -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
View File
@@ -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: