Aim for MVP

This commit is contained in:
franzap
2024-02-23 00:23:21 -03:00
parent a830231fc5
commit 2be91581f0
15 changed files with 618 additions and 352 deletions
+2
View File
@@ -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
+57 -29
View File
@@ -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<NavigatorState>();
final _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search');
final _updatesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'updates');
final _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile');
final _notificationsNavigatorKey =
GlobalKey<NavigatorState>(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<int> 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,
+69 -2
View File
@@ -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<FileMetadata> with ndk.FileMetadata {
FileMetadata.fromMap(super.map) : super.fromMap();
factory FileMetadata.fromMapFactory(Map<String, dynamic> map) {
final m = FileMetadata.fromMap(map);
m.author = BelongsTo<User>.fromJson(map['author']);
return m;
}
late final BelongsTo<User> author;
late final BelongsTo<Release> release = BelongsTo();
Future<void> 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<FileMetadata> {
@override
Future<DeserializedData<FileMetadata>> deserialize(Object? data) async {
final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>;
map['author'] = map['pubkey'];
}
final result = await super.deserialize(data);
print(result);
return result;
}
}
+17 -2
View File
@@ -10,6 +10,12 @@ part of 'file_metadata.dart';
mixin $FileMetadataLocalAdapter on LocalAdapter<FileMetadata> {
static final Map<String, RelationshipMeta> _kFileMetadataRelationshipMetas = {
'author': RelationshipMeta<User>(
name: 'author',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as FileMetadata).author,
),
'release': RelationshipMeta<Release>(
name: 'release',
inverseName: 'artifacts',
@@ -26,7 +32,7 @@ mixin $FileMetadataLocalAdapter on LocalAdapter<FileMetadata> {
@override
FileMetadata deserialize(map) {
map = transformDeserialize(map);
return FileMetadata.fromMap(map);
return FileMetadata.fromMapFactory(map);
}
@override
@@ -43,7 +49,7 @@ class $FileMetadataHiveLocalAdapter = HiveLocalAdapter<FileMetadata>
with $FileMetadataLocalAdapter;
class $FileMetadataRemoteAdapter = RemoteAdapter<FileMetadata>
with NostrAdapter<FileMetadata>;
with NostrAdapter<FileMetadata>, FileMetadataAdapter;
final internalFileMetadataRemoteAdapterProvider =
Provider<RemoteAdapter<FileMetadata>>((ref) => $FileMetadataRemoteAdapter(
@@ -56,10 +62,19 @@ final fileMetadataRepositoryProvider =
extension FileMetadataDataRepositoryX on Repository<FileMetadata> {
NostrAdapter<FileMetadata> get nostrAdapter =>
remoteAdapter as NostrAdapter<FileMetadata>;
FileMetadataAdapter get fileMetadataAdapter =>
remoteAdapter as FileMetadataAdapter;
}
extension FileMetadataRelationshipGraphNodeX
on RelationshipGraphNode<FileMetadata> {
RelationshipGraphNode<User> get author {
final meta = $FileMetadataLocalAdapter
._kFileMetadataRelationshipMetas['author'] as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<Release> get release {
final meta =
$FileMetadataLocalAdapter._kFileMetadataRelationshipMetas['release']
+2 -20
View File
@@ -10,6 +10,8 @@ part 'release.g.dart';
class Release extends BaseEvent<Release> with ndk.Release {
Release.fromMap(super.map) : super.fromMap();
String get identifier => tagMap['i']!.first;
factory Release.fromMapFactory(Map<String, dynamic> map) {
final m = Release.fromMap(map);
m.artifacts = HasMany<FileMetadata>.fromJson(map['artifacts']);
@@ -35,23 +37,3 @@ mixin ReleaseAdapter on RemoteAdapter<Release> {
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);
// }
+95 -73
View File
@@ -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<T extends BaseEvent<T>> = ndk.Event
with DataModelMixin<T>;
@DataRepository([NostrAdapter],
@DataRepository([NostrAdapter, UserAdapter],
fromJson: 'User.fromMapFactory(map)', toJson: 'model.toMap()')
class User extends BaseEvent<User> with ndk.User {
class User extends BaseEvent<User> with ndk.User, EquatableMixin {
User.fromMap(super.map) : super.fromMap();
factory User.fromMapFactory(Map<String, dynamic> map) {
final user = User.fromMap(map);
user.following = HasMany<User>.fromJson({});
user.following = HasMany<User>.fromJson(map['following']);
return user;
}
@@ -22,6 +22,82 @@ class User extends BaseEvent<User> with ndk.User {
late final HasMany<User> following;
@DataRelationship(inverse: 'following')
late final HasMany<User> followers = HasMany();
@override
List<Object?> get props => [id];
}
mixin UserAdapter on NostrAdapter<User> {
@override
Future<DeserializedData<User>> deserialize(Object? data) async {
final users = <User>[];
final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>;
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<String, dynamic>;
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<User?> findOne(Object id,
{bool? remote,
bool? background,
Map<String, dynamic>? params,
Map<String, String>? headers,
OnSuccessOne<User>? onSuccess,
OnErrorOne<User>? 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<T extends BaseEvent<T>> on RemoteAdapter<T> {
@@ -51,9 +127,11 @@ mixin NostrAdapter<T extends BaseEvent<T>> on RemoteAdapter<T> {
// super.dispose();
// }
Map<String, int> typeKind = {
'fileMetadata': 1063,
'releases': 30063,
Map<int, String> kindType = {
0: 'users',
3: 'users',
1063: 'fileMetadata',
30063: 'releases',
};
@override
@@ -65,8 +143,7 @@ mixin NostrAdapter<T extends BaseEvent<T>> on RemoteAdapter<T> {
for (final e in list) {
final map = e as Map<String, dynamic>;
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<T extends BaseEvent<T>> on RemoteAdapter<T> {
return DeserializedData<T>(models, included: included);
}
int get kind =>
kindType.entries.firstWhere((e) => e.value == internalType).key;
@override
Future<List<T>> findAll(
{bool? remote,
@@ -90,81 +170,23 @@ mixin NostrAdapter<T extends BaseEvent<T>> on RemoteAdapter<T> {
OnSuccessAll<T>? onSuccess,
OnErrorAll<T>? 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<User, (String, bool)>((ref, record) async {
// final (pubkey, loadContacts) = record;
// final completer = Completer<User>();
// 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<String>? 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;
// });
+3 -1
View File
@@ -48,7 +48,8 @@ final _usersFinders = <String, dynamic>{};
// ignore: must_be_immutable
class $UserHiveLocalAdapter = HiveLocalAdapter<User> with $UserLocalAdapter;
class $UserRemoteAdapter = RemoteAdapter<User> with NostrAdapter<User>;
class $UserRemoteAdapter = RemoteAdapter<User>
with NostrAdapter<User>, UserAdapter;
final internalUsersRemoteAdapterProvider = Provider<RemoteAdapter<User>>(
(ref) => $UserRemoteAdapter(
@@ -59,6 +60,7 @@ final usersRepositoryProvider =
extension UserDataRepositoryX on Repository<User> {
NostrAdapter<User> get nostrAdapter => remoteAdapter as NostrAdapter<User>;
UserAdapter get userAdapter => remoteAdapter as UserAdapter;
}
extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
+196 -6
View File
@@ -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<Color>(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<Offset>(
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),
),
),
],
),
),
),
],
);
}
+30 -23
View File
@@ -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<User?>((ref) => null);
final downloadsPath = StateProvider<String>((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'),
),
],
),
);
+24 -194
View File
@@ -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<String?>(null);
final labelState = useState<DataRequestLabel?>(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<User?>(null);
final currentUser = ref.read(loggedInUser);
final isWebOfTrust =
currentUser?.following.contains(authorUser.value) ?? false;
final isSha256Ok = useState<bool?>(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');
}
},
),
],
),
),
),
],
);
}
}
@@ -8,6 +8,7 @@
#include <handy_window/handy_window_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
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);
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
handy_window
screen_retriever
url_launcher_linux
window_manager
)
@@ -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"))
}
+111 -2
View File
@@ -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"
+5
View File
@@ -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: