diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..dff2f71 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -23,6 +23,8 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + prefer_const_constructors: false + avoid_print: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/main.dart b/lib/main.dart index 482aeb5..ec6d847 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer_const_literals_to_create_immutables, prefer_const_constructors + import 'package:flutter/material.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:go_router/go_router.dart'; @@ -29,6 +31,10 @@ class ZapstoreApp extends StatelessWidget { return MaterialApp.router( routerConfig: goRouter, debugShowCheckedModeBanner: false, + darkTheme: ThemeData( + primarySwatch: Colors.purple, + brightness: Brightness.dark, + ), theme: ThemeData.dark(useMaterial3: true), ); } @@ -39,6 +45,8 @@ final _rootNavigatorKey = GlobalKey(); final _searchNavigatorKey = GlobalKey(debugLabel: 'search'); final _updatesNavigatorKey = GlobalKey(debugLabel: 'updates'); final _profileNavigatorKey = GlobalKey(debugLabel: 'profile'); +final _notificationsNavigatorKey = + GlobalKey(debugLabel: 'notifications'); final goRouter = GoRouter( initialLocation: '/search', @@ -82,8 +90,8 @@ final goRouter = GoRouter( pageBuilder: (context, state) => NoTransitionPage( child: Center( child: ElevatedButton( - onPressed: () => context.go('/search/details', extra: 1), - child: const Text('Go to the Details screen'), + onPressed: () => {}, + child: const Text('Updates coming soon!'), ), ), ), @@ -103,6 +111,22 @@ final goRouter = GoRouter( ), ], ), + StatefulShellBranch( + navigatorKey: _notificationsNavigatorKey, + routes: [ + GoRoute( + path: '/notifications', + pageBuilder: (context, state) => NoTransitionPage( + child: Center( + child: ElevatedButton( + onPressed: () => {}, + child: const Text('Notifications coming soon!'), + ), + ), + ), + ), + ], + ), ], ), ], @@ -136,34 +160,29 @@ class ScaffoldWithNestedNavigation extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final initializer = ref.watch(newInitializer); return SafeArea( - child: initializer.when( - data: (_) => LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 450) { - return ScaffoldWithNavigationBar( - body: navigationShell, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: _goBranch, - ); - } else { - return ScaffoldWithNavigationRail( - body: navigationShell, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: _goBranch, - ); - } - }, - ), - error: (e, _) => Text('error'), - loading: () => CircularProgressIndicator(), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 450) { + return ScaffoldWithNavigationBar( + body: navigationShell, + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: _goBranch, + ); + } else { + return ScaffoldWithNavigationRail( + body: navigationShell, + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: _goBranch, + ); + } + }, ), ); } } -class ScaffoldWithNavigationBar extends StatelessWidget { +class ScaffoldWithNavigationBar extends HookConsumerWidget { const ScaffoldWithNavigationBar({ super.key, required this.body, @@ -175,9 +194,14 @@ class ScaffoldWithNavigationBar extends StatelessWidget { final ValueChanged onDestinationSelected; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final initializer = ref.watch(newInitializer); return Scaffold( - body: body, + body: initializer.when( + data: (_) => body, + error: (e, _) => const Text('error'), + loading: () => const Center(child: CircularProgressIndicator()), + ), bottomNavigationBar: NavigationBar( selectedIndex: selectedIndex, labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, @@ -226,18 +250,22 @@ class ScaffoldWithNavigationRail extends StatelessWidget { onDestinationSelected: onDestinationSelected, labelType: NavigationRailLabelType.all, destinations: [ - NavigationRailDestination( + const NavigationRailDestination( label: Text('Search'), icon: Icon(Icons.search_outlined), ), - NavigationRailDestination( + const NavigationRailDestination( label: Text('Updates'), icon: Icon(Icons.download_for_offline_outlined), ), - NavigationRailDestination( + const NavigationRailDestination( label: Text('Profile'), icon: Icon(Icons.person_outline), ), + const NavigationRailDestination( + label: Text('Notifications'), + icon: Icon(Icons.notifications_outlined), + ), ], ), body, diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index 62f1335..a00f8cf 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -1,14 +1,81 @@ +import 'dart:io'; + +import 'package:android_package_installer/android_package_installer.dart'; +import 'package:android_package_manager/android_package_manager.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/ndk.dart' as ndk; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/user.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; part 'file_metadata.g.dart'; -@DataRepository([NostrAdapter], - fromJson: 'FileMetadata.fromMap(map)', toJson: 'model.toMap()') +final packageManager = AndroidPackageManager(); + +@DataRepository([NostrAdapter, FileMetadataAdapter], + fromJson: 'FileMetadata.fromMapFactory(map)', toJson: 'model.toMap()') class FileMetadata extends BaseEvent with ndk.FileMetadata { FileMetadata.fromMap(super.map) : super.fromMap(); + factory FileMetadata.fromMapFactory(Map map) { + final m = FileMetadata.fromMap(map); + m.author = BelongsTo.fromJson(map['author']); + return m; + } + + late final BelongsTo author; late final BelongsTo release = BelongsTo(); + + Future install() async { + // throw Exception('something nasty'); + var status = await Permission.storage.status; + if (!status.isGranted) { + // If not we will ask for permission first + await Permission.storage.request(); + } + + // download file + + final uri = Uri.parse(url); + final response = await http.get(uri); + final dir = await getApplicationDocumentsDirectory(); + final file = File(path.join(dir.path, path.basename(url))); + await file.writeAsBytes(response.bodyBytes); + + // check hash + + final digest = sha256.convert(response.bodyBytes); + final shaOk = digest.toString() == hash; + + if (!shaOk) { + throw Exception('sha not ok'); + } + + int? code = + await AndroidPackageInstaller.installApk(apkFilePath: file.path); + if (code != 0) { + throw Exception( + 'installation error: ${code != null ? PackageInstallerStatus.byCode(code) : ''}'); + } + // final apps = await packageManager.getInstalledPackages(); + // apps?.firstWhere((e) => e.packageName == release.value!.identifier); + } +} + +mixin FileMetadataAdapter on RemoteAdapter { + @override + Future> deserialize(Object? data) async { + final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + final map = e as Map; + map['author'] = map['pubkey']; + } + final result = await super.deserialize(data); + print(result); + return result; + } } diff --git a/lib/models/file_metadata.g.dart b/lib/models/file_metadata.g.dart index 477c62e..adeafe6 100644 --- a/lib/models/file_metadata.g.dart +++ b/lib/models/file_metadata.g.dart @@ -10,6 +10,12 @@ part of 'file_metadata.dart'; mixin $FileMetadataLocalAdapter on LocalAdapter { static final Map _kFileMetadataRelationshipMetas = { + 'author': RelationshipMeta( + name: 'author', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as FileMetadata).author, + ), 'release': RelationshipMeta( name: 'release', inverseName: 'artifacts', @@ -26,7 +32,7 @@ mixin $FileMetadataLocalAdapter on LocalAdapter { @override FileMetadata deserialize(map) { map = transformDeserialize(map); - return FileMetadata.fromMap(map); + return FileMetadata.fromMapFactory(map); } @override @@ -43,7 +49,7 @@ class $FileMetadataHiveLocalAdapter = HiveLocalAdapter with $FileMetadataLocalAdapter; class $FileMetadataRemoteAdapter = RemoteAdapter - with NostrAdapter; + with NostrAdapter, FileMetadataAdapter; final internalFileMetadataRemoteAdapterProvider = Provider>((ref) => $FileMetadataRemoteAdapter( @@ -56,10 +62,19 @@ final fileMetadataRepositoryProvider = extension FileMetadataDataRepositoryX on Repository { NostrAdapter get nostrAdapter => remoteAdapter as NostrAdapter; + FileMetadataAdapter get fileMetadataAdapter => + remoteAdapter as FileMetadataAdapter; } extension FileMetadataRelationshipGraphNodeX on RelationshipGraphNode { + RelationshipGraphNode get author { + final meta = $FileMetadataLocalAdapter + ._kFileMetadataRelationshipMetas['author'] as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } + RelationshipGraphNode get release { final meta = $FileMetadataLocalAdapter._kFileMetadataRelationshipMetas['release'] diff --git a/lib/models/release.dart b/lib/models/release.dart index dbb0fdc..506e6a1 100644 --- a/lib/models/release.dart +++ b/lib/models/release.dart @@ -10,6 +10,8 @@ part 'release.g.dart'; class Release extends BaseEvent with ndk.Release { Release.fromMap(super.map) : super.fromMap(); + String get identifier => tagMap['i']!.first; + factory Release.fromMapFactory(Map map) { final m = Release.fromMap(map); m.artifacts = HasMany.fromJson(map['artifacts']); @@ -35,23 +37,3 @@ mixin ReleaseAdapter on RemoteAdapter { return result; } } - -// main() async { -// final container = ProviderContainer(); -// await container.read(repositoryInitializerProvider.future); -// // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member -// final fm = container -// .read(releasesRepositoryProvider) -// .remoteAdapter -// .localAdapter -// .deserialize({ -// 'id': 's938j89j4f', -// 'content': 'some shit', -// 'pubkey': '78hf47hf', -// 'kind': 1063, -// 'tags': [ -// ['m', 'application/pwa+zip'] -// ] -// }); -// print(fm.bs); -// } diff --git a/lib/models/user.dart b/lib/models/user.dart index d66ceaf..826cdda 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,4 +1,4 @@ -import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:ndk/ndk.dart' as ndk; @@ -7,14 +7,14 @@ part 'user.g.dart'; abstract class BaseEvent> = ndk.Event with DataModelMixin; -@DataRepository([NostrAdapter], +@DataRepository([NostrAdapter, UserAdapter], fromJson: 'User.fromMapFactory(map)', toJson: 'model.toMap()') -class User extends BaseEvent with ndk.User { +class User extends BaseEvent with ndk.User, EquatableMixin { User.fromMap(super.map) : super.fromMap(); factory User.fromMapFactory(Map map) { final user = User.fromMap(map); - user.following = HasMany.fromJson({}); + user.following = HasMany.fromJson(map['following']); return user; } @@ -22,6 +22,82 @@ class User extends BaseEvent with ndk.User { late final HasMany following; @DataRelationship(inverse: 'following') late final HasMany followers = HasMany(); + + @override + List get props => [id]; +} + +mixin UserAdapter on NostrAdapter { + @override + Future> deserialize(Object? data) async { + final users = []; + final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + final map = e as Map; + if (map['kind'] == 3) { + for (final [_, id, ..._] in map['tags'] as Iterable) { + users.add(User.fromMapFactory({ + 'id': id, + 'content': '', + 'pubkey': id, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'kind': 0, + 'tags': [], + 'following': {'_': null}, + })); + } + } + } + + for (final e in list) { + final map = e as Map; + if (map['kind'] == 0) { + map['id'] = map['pubkey']; + // TODO workaround - should not assume empty is null + if (users.isNotEmpty) { + map['following'] = users.map((e) => e.id).toList(); + } + final data0 = await super.deserialize(map); + final user = data0.model; + if (user != null) { + users.add(user); + } + } + } + + return DeserializedData(users); + } + + @override + Future findOne(Object id, + {bool? remote, + bool? background, + Map? params, + Map? headers, + OnSuccessOne? onSuccess, + OnErrorOne? onError, + DataRequestLabel? label}) async { + if (remote == false) { + final key = graph.getKeyForId(internalType, id.toString()); + return localAdapter.findOne(key!); + } + + final req = ndk.Req( + authors: {id.toString()}, + kinds: {kind, ...?params?.remove('kinds')}, + tags: params ?? {}, + ); + + print('[findOne] req $req'); + final result = await notifier.query(req); + final data = await deserialize(result); + + for (final m in data.models) { + print('saving model of kind ${m.kind}: ${m.content}'); + m.init().saveLocal(); + } + return data.models.firstWhere((e) => e.id == id); + } } mixin NostrAdapter> on RemoteAdapter { @@ -51,9 +127,11 @@ mixin NostrAdapter> on RemoteAdapter { // super.dispose(); // } - Map typeKind = { - 'fileMetadata': 1063, - 'releases': 30063, + Map kindType = { + 0: 'users', + 3: 'users', + 1063: 'fileMetadata', + 30063: 'releases', }; @override @@ -65,8 +143,7 @@ mixin NostrAdapter> on RemoteAdapter { for (final e in list) { final map = e as Map; final kind = map['kind'] as int; - final xType = - typeKind.entries.firstWhereOrNull((e) => e.value == kind)?.key; + final xType = kindType[kind]; if (xType != null) { if (xType == internalType) { final newData = await super.deserialize(map); @@ -80,6 +157,9 @@ mixin NostrAdapter> on RemoteAdapter { return DeserializedData(models, included: included); } + int get kind => + kindType.entries.firstWhere((e) => e.value == internalType).key; + @override Future> findAll( {bool? remote, @@ -90,81 +170,23 @@ mixin NostrAdapter> on RemoteAdapter { OnSuccessAll? onSuccess, OnErrorAll? onError, DataRequestLabel? label}) async { - final req = ndk.Req( - kinds: {typeKind[internalType] as int, ...?params?.remove('kinds')}, - tags: params ?? {}, - ); if (remote == false) { return localAdapter.findAll(); } + final req = ndk.Req( + kinds: {kind, ...?params?.remove('kinds')}, + tags: params ?? {}, + ); + print('req $req'); final result = await notifier.query(req); final data = await deserialize(result); print('req result ${data.models.length}'); for (final m in data.models) { + print('saving model of kind ${m.kind}: ${m.content}'); m.init().saveLocal(); } return data.models; } } - -// final profileProvider = -// FutureProvider.family((ref, record) async { -// final (pubkey, loadContacts) = record; -// final completer = Completer(); -// final notifier = ref.read(frameProvider.notifier); -// print('profile provider sending req'); -// notifier.send(jsonEncode([ -// "REQ", -// pubkey, -// { -// 'authors': [pubkey], -// 'kinds': [0, 3], -// } -// ])); - -// late Function _sub; - -// User? u; -// List? contacts; -// _sub = ref.watch(frameProvider.notifier).addListener((frame) async { -// print('listener: ${frame.event}'); -// final event = frame.event; - -// if (event is Metadata) { -// u = (await ref.users.findOne(event.pubkey, remote: false)) ?? -// User(id: event.pubkey); -// final map = jsonDecode(event.content); -// u!.name = map['displayName'] ?? map['display_name'] ?? map['name']; -// u!.nip05 = map['nip05']; -// } - -// if (loadContacts == false && u != null && completer.isCompleted == false) { -// completer.complete(u); -// return; -// } - -// if (event is ContactList) { -// contacts = [...?contacts, ...event.tagMap['p']!]; -// } - -// if (u != null && contacts != null) { -// if (completer.isCompleted == false) { -// print('completing with $u'); -// contacts!.forEach((c) { -// final uc = User(id: c); -// u!.following.add(uc); -// uc.saveLocal(); -// }); -// u!.saveLocal(); -// completer.complete(u); -// } -// } -// }); -// ref.onDispose(() { -// print('disposing'); -// _sub(); -// }); -// return completer.future; -// }); \ No newline at end of file diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index 0d798f6..86385c9 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -48,7 +48,8 @@ final _usersFinders = {}; // ignore: must_be_immutable class $UserHiveLocalAdapter = HiveLocalAdapter with $UserLocalAdapter; -class $UserRemoteAdapter = RemoteAdapter with NostrAdapter; +class $UserRemoteAdapter = RemoteAdapter + with NostrAdapter, UserAdapter; final internalUsersRemoteAdapterProvider = Provider>( (ref) => $UserRemoteAdapter( @@ -59,6 +60,7 @@ final usersRepositoryProvider = extension UserDataRepositoryX on Repository { NostrAdapter get nostrAdapter => remoteAdapter as NostrAdapter; + UserAdapter get userAdapter => remoteAdapter as UserAdapter; } extension UserRelationshipGraphNodeX on RelationshipGraphNode { diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 1fff83e..740723a 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,6 +1,16 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ndk/ndk.dart' as ndk; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:zapstore/main.data.dart'; +import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/release.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/screens/profile_screen.dart'; class AppDetailScreen extends HookConsumerWidget { final Release release; @@ -11,15 +21,195 @@ class AppDetailScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Column( + final artifacts = release.artifacts.toList(); + return ListView.builder( + shrinkWrap: true, + itemCount: release.artifacts.length, + itemBuilder: (context, index) { + final event = artifacts[index]; + return CardWidget(fileMetadata: event); + }, + ); + } +} + +class CardWidget extends HookConsumerWidget { + final FileMetadata fileMetadata; + + const CardWidget({super.key, required this.fileMetadata}); + + User? get author => fileMetadata.author.value; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentUser = ref.read(loggedInUser); + final fetchUser = useMemoized(() => ref.users.findOne(author!.id!)); + useFuture(fetchUser); + + final isWebOfTrust = [...?currentUser?.following.toList()] + .contains(fileMetadata.author.value); + + return ExpansionTileCard( + leading: ClipRRect( + borderRadius: BorderRadius.circular(15.0), + child: Image.network( + fileMetadata.release.value!.image, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (context, err, _) => Text(err.toString()), + ), + ), + onExpansionChanged: (_) { + ref.users.findOne(author!.id!); + }, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileMetadata.content, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 26), + ), + SizedBox(height: 10), + Row( + children: [ + CircleAvatar( + backgroundImage: NetworkImage(author?.profilePicture ?? ''), + maxRadius: 10, + ), + SizedBox(width: 10), + Text( + '${author?.name ?? author?.id}', + style: TextStyle(fontSize: 14), + ), + ], + ) + ], + ), children: [ - for (final artifact in release.artifacts.toList()) - Expanded( - child: Card( - child: Text( - '${artifact.kind.toString()} / ${artifact.release.value?.content}'), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isWebOfTrust) + Text( + 'You are following this author!', + style: TextStyle(color: Colors.green), + ), + if (!isWebOfTrust) + Row( + children: [ + Text('Author is not in your web of trust', + style: TextStyle(color: Colors.red)), + GestureDetector( + onTap: () => launchUrlString( + 'https://primal.net/p/${author?.id.toString().npub}', + ), + child: Text( + 'see profile', + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ) + ], + ), + AsyncButtonBuilder( + loadingWidget: const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + height: 16.0, + width: 16.0, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + successWidget: const Padding( + padding: EdgeInsets.all(4.0), + child: Icon( + Icons.check, + color: Colors.purpleAccent, + ), + ), + onPressed: () async { + try { + await fileMetadata.install(); + showToastWidget( + Text( + 'Success!', + style: TextStyle(fontSize: 20), + ), + // ignore: use_build_context_synchronously + context: context, + position: StyledToastPosition.center, + animation: StyledToastAnimation.scale, + reverseAnimation: StyledToastAnimation.fade, + duration: Duration(seconds: 4), + animDuration: Duration(seconds: 1), + curve: Curves.elasticOut, + reverseCurve: Curves.linear); + } on Exception catch (e) { + showToastWidget( + Text( + 'Error! $e', + style: TextStyle(fontSize: 20), + ), + // ignore: use_build_context_synchronously + context: context, + position: StyledToastPosition.center, + animation: StyledToastAnimation.scale, + reverseAnimation: StyledToastAnimation.fade, + duration: Duration(seconds: 4), + animDuration: Duration(seconds: 1), + curve: Curves.elasticOut, + reverseCurve: Curves.linear); + } + }, + loadingSwitchInCurve: Curves.bounceInOut, + loadingTransitionBuilder: (child, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1.0), + end: const Offset(0, 0), + ).animate(animation), + child: child, + ); + }, + builder: (context, child, callback, state) { + return Material( + color: state.maybeWhen( + success: () => Colors.purple[100], + orElse: () => Colors.black12, + ), + // This prevents the loading indicator showing below the + // button + clipBehavior: Clip.hardEdge, + shape: const StadiumBorder(), + child: InkWell( + onTap: callback, + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: const Icon(Icons.download), + ), + ), + ], ), ), + ), ], ); } diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 649663f..9f520f4 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -2,55 +2,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ndk/ndk.dart' as ndk; +import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/user.dart'; final loggedInUser = StateProvider((ref) => null); -final downloadsPath = StateProvider((ref) => '/tmp'); class ProfileScreen extends HookConsumerWidget { + const ProfileScreen({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final u2 = ref.watch(loggedInUser); final controller = useTextEditingController(); - final controller2 = useTextEditingController(text: ref.read(downloadsPath)); return Padding( padding: const EdgeInsets.all(12.0), child: Column( children: [ - TextField(controller: controller), + if (u2 == null) TextField(controller: controller), SizedBox(height: 10), - ElevatedButton( + if (u2 == null) + ElevatedButton( onPressed: () async { final pubkey = controller.text.hexKey; ref.read(loggedInUser.notifier).state = - await ref.read(usersRepositoryProvider).findOne(pubkey); + await ref.users.findOne(pubkey, params: { + 'kinds': {3} + }); }, - child: Text('Log in with npub')), + child: Text('Log in with npub'), + ), if (u2 != null) Expanded( child: Column( children: [ - Text('Logged in as ${u2.name}'), - Text('(following: ${u2.following.length})'), + Card( + child: ListTile( + dense: true, + leading: CircleAvatar( + backgroundImage: NetworkImage(u2.profilePicture ?? ''), + ), + title: Text( + u2.name ?? u2.id?.toString() ?? '', + ), + subtitle: Text('following ${u2.following.length}'), + ), + ), + ElevatedButton( + onPressed: () { + ref.read(loggedInUser.notifier).state = null; + controller.clear(); + }, + child: Text('Log out'), + ), ], ), ), - SizedBox(height: 20), - // ListView.builder( - // shrinkWrap: true, - // itemCount: contacts.length, - // itemBuilder: (context, i) { - // return Text(' - ${contacts[i].id}'); - // }), - TextField(controller: controller2), - SizedBox(height: 10), - ElevatedButton( - onPressed: () async { - ref.read(downloadsPath.notifier).state = controller2.text; - }, - child: Text('Save downloads path'), - ), ], ), ); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 5e36fc6..bade243 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,115 +1,63 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:android_package_installer/android_package_installer.dart'; -import 'package:crypto/crypto.dart'; -import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/screens/profile_screen.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; -import 'package:permission_handler/permission_handler.dart'; +import 'package:zapstore/screens/app_detail_screen.dart'; import '../main.data.dart'; +const kAndroidMimeType = 'application/vnd.android.package-archive'; + class SearchScreen extends HookConsumerWidget { - SearchScreen({super.key}); - final r = Random(); + const SearchScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final searchState = useState(null); final labelState = useState(null); - final value = ref.releases.watchAll(remote: false); + final value = ref.fileMetadata.watchAll(remote: false); + final artifacts = value.model + .where((a) => a.release.isPresent && a.mimeType == kAndroidMimeType) + .where((a) => searchState.value != null + ? a.release.value!.title + .toLowerCase() + .contains(searchState.value!.toLowerCase()) + : true) + .toList(); return Padding( - padding: const EdgeInsets.all(12.0), + padding: const EdgeInsets.all(18.0), child: Column( children: [ - TextButton( - onPressed: () async { - var status = await Permission.storage.status; - if (!status.isGranted) { - // If not we will ask for permission first - await Permission.storage.request(); - } - final apk = await rootBundle.load('tmp/gg.apk'); - final dir = await getApplicationDocumentsDirectory(); - await dir.create(recursive: true); - final buffer = apk.buffer; - await File('${dir.path}/gg.apk').writeAsBytes( - buffer.asUint8List(apk.offsetInBytes, apk.lengthInBytes)); - - int? code = await AndroidPackageInstaller.installApk( - apkFilePath: '${dir.path}/gg.apk'); - if (code != null) { - PackageInstallerStatus installationStatus = - PackageInstallerStatus.byCode(code); - print(installationStatus.name); - } - }, - child: Text('install')), SearchBar( - elevation: MaterialStatePropertyAll(2.2), + elevation: const MaterialStatePropertyAll(2.2), onSubmitted: (query) async { searchState.value = query; - labelState.value = DataRequestLabel('query', - type: 'releases', requestId: 'sub-${r.nextInt(9999999)}'); - await ref.releases.findAll(); + await ref.releases.findAll(params: { + 'since': 1708658457, + }); await ref.fileMetadata.findAll( params: { // 'kinds': {1063}, // 'limit': 20, // 'search': query, - '#m': [ - // 'application/pwa+zip', - 'application/vnd.android.package-archive' - ], - // 'since': DateTime.now() - // .subtract(Duration(days: 1)) - // .millisecondsSinceEpoch ~/ - // 1000, + '#m': [kAndroidMimeType], + 'since': 1708658457, }, label: labelState.value, ); - - // final notifier = ref.read(frameProvider.notifier); - // notifier.send(jsonEncode([ - // "REQ", - // subscriptionIdState.value!, - // ])); }, ), - Padding( - padding: const EdgeInsets.all(6.0), - child: Text('Total results: ${value.model.length}'), - ), + const SizedBox(height: 20), Expanded( child: ListView.builder( shrinkWrap: true, - itemCount: value.model.length, + itemCount: artifacts.length, itemBuilder: (context, index) { - final event = value.model[index]; - return GestureDetector( - onTap: () { - context.push('/search/details', extra: event); - }, - child: Card( - child: Text( - '${event.content} / ${event.id} / ${event.kind} / ${event.artifacts.keys} - ${event.runtimeType}'), - ), - ); - // return CardWidget(note: event as FileMetadata); + final event = artifacts[index]; + return CardWidget(fileMetadata: event); }, ), ), @@ -118,121 +66,3 @@ class SearchScreen extends HookConsumerWidget { ); } } - -class CardWidget extends HookConsumerWidget { - final FileMetadata fm; - - const CardWidget({super.key, required this.fm}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final authorUser = useState(null); - final currentUser = ref.read(loggedInUser); - - final isWebOfTrust = - currentUser?.following.contains(authorUser.value) ?? false; - final isSha256Ok = useState(null); - - return ExpansionTileCard( - leading: ClipRRect( - borderRadius: BorderRadius.circular(15.0), - child: Image.network( - fm.tagMap['thumb']!.first, - width: 80, - height: 80, - fit: BoxFit.cover, - errorBuilder: (context, err, _) => Text(err.toString()), - ), - ), - title: Text( - fm.content, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Type: ${fm.mimeType} (${fm.id})'), - ], - ), - onExpansionChanged: (isOpen) async { - if (isOpen && currentUser != null) { - // final existingUser = - // await ref.users.findOne(note.pubkey, remote: false); - // if (existingUser?.name != null) { - // print('fd has ${note.pubkey}! (${existingUser!.name}) '); - // authorUser.value = existingUser; - // } else { - // print('fetch in network'); - // authorUser.value = - // await ref.read(profileProvider((note.pubkey, false)).future); - // } - } - }, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // if (authorUser.value != null) - // Text( - // 'Author: ${authorUser.value!.name} (${authorUser.value!.nip05})'), - if (isWebOfTrust) - Text( - 'Author is in your web of trust', - style: TextStyle(color: Colors.green), - ), - if (!isWebOfTrust) - Text('Author is not in your web of trust', - style: TextStyle(color: Colors.red)), - if (isSha256Ok.value == true) - Text( - 'Installed! Hash and signature matched', - style: TextStyle(color: Colors.green), - ), - if (isSha256Ok.value == false) - Text( - 'Removed! Hash or signature did not match', - style: TextStyle(color: Colors.red), - ), - SizedBox(height: 20), - if (isSha256Ok.value != false) - ElevatedButton.icon( - label: Text('Install'), - icon: Icon(Icons.arrow_downward), - onPressed: () async { - final uri = Uri.parse(fm.tagMap['url']!.first); - final response = await http.get(uri); - final dir = ref.read(downloadsPath); - - File file = - File(path.join(dir, fm.tagMap['name']!.first)); - await file.writeAsBytes(response.bodyBytes); - - final digest = sha256.convert(response.bodyBytes); - isSha256Ok.value = - digest.toString() == fm.tagMap['x']!.first; - - if (isSha256Ok.value == false) { - print('actual digest $digest'); - await file.delete(); - } else { - print('digest ok $digest'); - } - }, - ), - ], - ), - ), - ), - ], - ); - } -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e7a91ff..de7c877 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index dd5b86a..ea8c597 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST handy_window screen_retriever + url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3e13b0f..5730c07 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import path_provider_foundation import screen_retriever +import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5030f14..ed386e1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,6 +26,14 @@ packages: url: "https://github.com/ImranR98/android_package_installer" source: git version: "0.0.1" + android_package_manager: + dependency: "direct main" + description: + name: android_package_manager + sha256: "2de859fae7226a7de1c1ff9a2308f1967599408800330501a1ce97927c051153" + url: "https://pub.dev" + source: hosted + version: "0.7.1" archive: dependency: transitive description: @@ -50,6 +58,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + async_button_builder: + dependency: "direct main" + description: + name: async_button_builder + sha256: "51221ec05e48b14c2c346071e2fb968207f6120cc9fe5d727b251987bbafe869" + url: "https://pub.dev" + source: hosted + version: "3.0.0+1" bech32: dependency: transitive description: @@ -219,7 +235,7 @@ packages: source: hosted version: "2.3.4" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 @@ -303,6 +319,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_riverpod: dependency: transitive description: @@ -311,6 +332,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.10" + flutter_styled_toast: + dependency: "direct main" + description: + name: flutter_styled_toast + sha256: e667f13a665820eb0fa8506547e47eacbcddf1948d6d3036cfd3b089bd4b0516 + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -321,6 +350,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -425,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.3+2" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" io: dependency: transitive description: @@ -861,6 +906,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" + url: "https://pub.dev" + source: hosted + version: "6.2.5" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + url: "https://pub.dev" + source: hosted + version: "6.2.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" uuid: dependency: transitive description: @@ -951,4 +1060,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml index 44bd859..2e5ca07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,11 @@ dependencies: permission_handler: ^11.3.0 path: ^1.9.0 http: ^1.2.1 + async_button_builder: ^3.0.0+1 + android_package_manager: ^0.7.1 + flutter_styled_toast: ^2.2.1 + equatable: ^2.0.5 + url_launcher: ^6.2.5 dependency_overrides: android_package_installer: