diff --git a/.vscode/launch.json b/.vscode/launch.json index a3ffc30..367f2de 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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": [ { diff --git a/android/app/build.gradle b/android/app/build.gradle index 64755e0..c7de3ce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 } diff --git a/lib/main.dart b/lib/main.dart index 1b64ac6..286e790 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((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 scaffoldKey = GlobalKey(); +final scaffoldKey = GlobalKey(); -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 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, + ), + ), ], ), ); diff --git a/lib/main.data.dart b/lib/main.data.dart index 109938b..d10799a 100644 --- a/lib/main.data.dart +++ b/lib/main.data.dart @@ -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 = >>{ - 'apps': appsAdapterProvider, + 'appCurationSets': appCurationSetsAdapterProvider, +'apps': appsAdapterProvider, 'fileMetadata': fileMetadataAdapterProvider, 'releases': releasesAdapterProvider, 'settings': settingsAdapterProvider, @@ -21,6 +23,7 @@ final adapterProvidersMap = >>{ }; extension AdapterWidgetRefX on WidgetRef { + Adapter get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch; Adapter get apps => watch(appsAdapterProvider)..internalWatch = watch; Adapter get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch; Adapter get releases => watch(releasesAdapterProvider)..internalWatch = watch; @@ -30,6 +33,7 @@ extension AdapterWidgetRefX on WidgetRef { extension AdapterRefX on Ref { + Adapter get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch as Watcher; Adapter get apps => watch(appsAdapterProvider)..internalWatch = watch as Watcher; Adapter get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher; Adapter get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher; diff --git a/lib/models/app.dart b/lib/models/app.dart index 6653ddb..fad94bf 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -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 with BaseApp { +class App extends BaseApp with DataModelMixin { late final HasMany releases; late final BelongsTo signer; late final BelongsTo developer; @@ -45,137 +44,7 @@ class App extends Event with BaseApp { a.architectures.contains('arm64-v8a')) .firstOrNull; } -} -mixin AppAdapter on Adapter { - ProviderSubscription? _sub; - AppLifecycleListener? _lifecycleListener; - - @override - Future 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> loadAppModels(Map 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> findAll( - {bool? remote = true, - bool? background, - Map? params = const {}, - Map? headers, - bool? syncLocal, - OnSuccessAll? onSuccess, - OnErrorAll? 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 findOne(Object id, - {bool remote = true, - bool background = false, - Map? params = const {}, - Map? headers, - OnSuccessOne? onSuccess, - OnErrorOne? 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> getInstalledAppsMap() async { - late List? 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 { + ProviderSubscription? _sub; + + @override + Future onInitialized() async { + if (!inIsolate) { + _sub = ref.listen(installedAppProvider, (_, __) { + triggerNotify(); + }); + } + super.onInitialized(); + } + + @override + void dispose() { + _sub?.close(); + super.dispose(); + } + + Future> loadAppModels(Map 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> findAll( + {bool? remote = true, + bool? background, + Map? params = const {}, + Map? headers, + bool? syncLocal, + OnSuccessAll? onSuccess, + OnErrorAll? 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 findOne(Object id, + {bool remote = true, + bool background = false, + Map? params = const {}, + Map? headers, + OnSuccessOne? onSuccess, + OnErrorOne? 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> getInstalledAppsMap({bool defer = false}) async { + late List? 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 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 _isHashMismatch(String path, String hash) async { return await Isolate.run(() async { final bytes = await File(path).readAsBytes(); diff --git a/lib/models/app_curation_set.dart b/lib/models/app_curation_set.dart new file mode 100644 index 0000000..d18663c --- /dev/null +++ b/lib/models/app_curation_set.dart @@ -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 { + late final HasMany apps; +} + +mixin AppCurationSetAdapter on Adapter { + @override + DeserializedData deserialize(Object? data, {String? key}) { + final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + final map = e as Map; + final aValues = (map['tags'] as Iterable).where((t) => t[0] == 'a'); + map['apps'] = aValues.map((e) => e[1].split(':')[2]); + } + return super.deserialize(data); + } +} diff --git a/lib/models/app_curation_set.g.dart b/lib/models/app_curation_set.g.dart new file mode 100644 index 0000000..92006cd --- /dev/null +++ b/lib/models/app_curation_set.g.dart @@ -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 { + static final Map _kAppCurationSetRelationshipMetas = + { + 'apps': RelationshipMeta( + name: 'apps', + type: 'apps', + kind: 'HasMany', + instance: (_) => (_ as AppCurationSet).apps, + ) + }; + + @override + Map get relationshipMetas => + _kAppCurationSetRelationshipMetas; + + @override + AppCurationSet deserializeLocal(map, {String? key}) { + map = transformDeserialize(map); + return internalWrapStopInit(() => _$AppCurationSetFromJson(map), key: key); + } + + @override + Map serializeLocal(model, {bool withRelationships = true}) { + final map = _$AppCurationSetToJson(model); + return transformSerialize(map, withRelationships: withRelationships); + } +} + +final _appCurationSetsFinders = {}; + +class $AppCurationSetAdapter = Adapter + with + _$AppCurationSetAdapter, + NostrAdapter, + AppCurationSetAdapter; + +final appCurationSetsAdapterProvider = Provider>( + (ref) => + $AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders))); + +extension AppCurationSetAdapterX on Adapter { + NostrAdapter get nostrAdapter => + this as NostrAdapter; + AppCurationSetAdapter get appCurationSetAdapter => + this as AppCurationSetAdapter; +} + +extension AppCurationSetRelationshipGraphNodeX + on RelationshipGraphNode { + RelationshipGraphNode get apps { + final meta = _$AppCurationSetAdapter + ._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AppCurationSet _$AppCurationSetFromJson(Map 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) + .map((e) => (e as List).map((e) => e as String).toList()) + .toList() + ..signature = json['signature'] as String? + ..apps = HasMany.fromJson(json['apps'] as Map); + +Map _$AppCurationSetToJson(AppCurationSet instance) => + { + '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, + }; diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index bf2826d..da479f1 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -9,7 +9,7 @@ part 'file_metadata.g.dart'; @JsonSerializable() @DataAdapter([NostrAdapter, FileMetadataAdapter]) -class FileMetadata extends Event with BaseFileMetadata { +class FileMetadata extends BaseFileMetadata with DataModelMixin { late final BelongsTo author; late final BelongsTo release = BelongsTo(); late final BelongsTo signer; diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index 640150f..7f75fd4 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -1,17 +1,17 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:purplebase/purplebase.dart'; -abstract class Event> = BaseEvent with DataModelMixin; - -mixin NostrAdapter> on Adapter { +mixin NostrAdapter> on Adapter { late final RelayMessageNotifier notifier = ref.read(relayMessageNotifierProvider.notifier); + // TODO rethink this Map kindType = { 0: 'users', 3: 'users', 1063: 'fileMetadata', 30063: 'releases', + 30267: 'appCurationSets', 32267: 'apps' }; @@ -32,6 +32,7 @@ mixin NostrAdapter> on Adapter { 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> on Adapter { return DeserializedData(models, included: included); } + // TODO remove int get kind => kindType.entries.firstWhere((e) => e.value == internalType).key; diff --git a/lib/models/release.dart b/lib/models/release.dart index 143d3ab..e59cf9d 100644 --- a/lib/models/release.dart +++ b/lib/models/release.dart @@ -11,7 +11,7 @@ part 'release.g.dart'; @JsonSerializable() @DataAdapter([NostrAdapter, ReleaseAdapter]) -class Release extends Event with BaseRelease { +class Release extends BaseRelease with DataModelMixin { late final HasMany artifacts; late final BelongsTo app; late final BelongsTo signer; diff --git a/lib/models/user.dart b/lib/models/user.dart index b1d165f..7aac0af 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -10,7 +10,7 @@ part 'user.g.dart'; @JsonSerializable() @DataAdapter([NostrAdapter, UserAdapter]) -class User extends Event with BaseUser { +class User extends BaseUser with DataModelMixin { @DataRelationship(inverse: 'followers') late final HasMany following; @DataRelationship(inverse: 'following') @@ -102,6 +102,10 @@ mixin UserAdapter on NostrAdapter { 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 { }); } - Future> getTrusted(User u1, User u2) async { - final url = 'https://zap.store/api/trust/${u1.npub}/${u2.npub}'; + Future> 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 { Map.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!; diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 00c7f98..c1e31d8 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -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, ({User user, App app})>((ref, arg) async { - return ref.users.userAdapter.getTrusted(arg.user, arg.app.signer.value!); + .family, ({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>(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>(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()), + ) }; } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index bb03556..a239a3c 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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, 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.generate((8 / columns).ceil(), (_) => 110.px), + List.generate((8 / columns).ceil(), (_) => 130.px), columnSizes: List.generate(columns, (_) => 1.fr), children: List.generate(8, (i) => TinyAppCard(app: apps.elementAtOrNull(i))), @@ -259,65 +254,11 @@ class SearchResultNotifier extends AsyncNotifier> { } 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", - ], -}; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7f9fd2f..9f712f9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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'), ), ], ), diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index a335e5b..4d64eb4 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -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, ), ), ), diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index f427e75..ae030ca 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -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'), + ), + ], ); } } diff --git a/lib/widgets/user_avatar.dart b/lib/widgets/user_avatar.dart index 4f97039..db2797a 100644 --- a/lib/widgets/user_avatar.dart +++ b/lib/widgets/user_avatar.dart @@ -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); } } diff --git a/pubspec.lock b/pubspec.lock index d0411e1..8c90a9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 11eaab0..687b107 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: