From e7d729541224b51fd3f0687409bb1e03186bfd8c Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Mon, 6 May 2024 19:56:22 -0300 Subject: [PATCH] more installation wip --- lib/main.dart | 2 +- lib/models/app.dart | 249 +++++++++++++++--- lib/models/app.g.dart | 8 +- lib/models/file_metadata.dart | 90 ------- lib/models/release.dart | 6 - lib/screens/app_detail_screen.dart | 200 ++++++++------ lib/screens/search_screen.dart | 6 +- lib/screens/updates_screen.dart | 163 +++++------- lib/widgets/app_drawer.dart | 9 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 36 ++- pubspec.yaml | 4 +- 12 files changed, 448 insertions(+), 327 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 3bcf4e4..94c3fb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -83,7 +83,7 @@ final goRouter = GoRouter( GoRoute( path: 'details', builder: (context, state) => - AppDetailScreen(app: state.extra as App), + AppDetailScreen(model: state.extra as App), ), ], ), diff --git a/lib/models/app.dart b/lib/models/app.dart index 2ae4bd7..d379341 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -1,13 +1,23 @@ import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'package:android_package_installer/android_package_installer.dart'; 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:json_annotation/json_annotation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/nostr_adapter.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 'app.g.dart'; @@ -17,11 +27,52 @@ class App extends Event with BaseApp { late final HasMany releases; late final BelongsTo signer; late final BelongsTo developer; - String? installedVersion; + + String? get installedVersion => + adapter.ref.read(installedAppProvider)[id!.toString()]; + + FileMetadata? get latestMetadata { + // if (Platform.isAndroid) { + // final androidInfo = await deviceInfo.androidInfo; + // return androidInfo.architecture; // e.g. "arm64-v8a" + // } + return releases.latest?.artifacts + .where((a) => + a.mimeType == 'application/vnd.android.package-archive' && + a.architectures.contains('arm64-v8a')) + .firstOrNull; + } } mixin AppAdapter on Adapter { - AndroidPackageManager? _packageManager; + 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(); + } @override Future> findAll( @@ -33,26 +84,29 @@ mixin AppAdapter on Adapter { OnSuccessAll? onSuccess, OnErrorAll? onError, DataRequestLabel? label}) async { + final map = await getInstalledAppsMap(); + if (params!.containsKey('installed')) { - final records = await getInstalledAppIdVersions(); - final ids = records.map((r) => r.$1).toSet(); - if (ids.isNotEmpty) { - params['#d'] = ids; + if (map.keys.isNotEmpty) { + params['#d'] = map.keys; + params.remove('installed'); print('filtering by installed ${params['#d']}'); + + final apps = findAllLocal(); + if (apps.isNotEmpty) { + super.findAll(params: params); + return apps; + } + return super.findAll(params: params); } - final apps = await super.findAll(params: params); - for (final (id, version) in records) { - final app = findOneLocalById(id); - app - ?..installedVersion = version - ..saveLocal(); - } - return apps; } + return super.findAll(params: params); } - Future> getInstalledAppIdVersions() async { + static AndroidPackageManager? _packageManager; + + Future> getInstalledAppsMap() async { late List? infos; if (Platform.isAndroid) { _packageManager ??= AndroidPackageManager(); @@ -60,28 +114,155 @@ mixin AppAdapter on Adapter { } else { infos = []; } - final pairs = infos! - .map((i) => (i.packageName!, i.versionName!)) - .where((r) => - // TODO need to filter way more - !r.$1.startsWith('android') && !r.$1.startsWith('com.android')) - .toSet(); - return pairs; + + final installedPackageInfos = infos!.where((i) => ![ + 'android', + 'com.android', + 'org.chromium.webview_shell' + ].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 canUpdate => - installedVersion != null && - releases.latest?.androidArtifacts.firstOrNull != null && - releases.latest!.androidArtifacts.first.version! - .compareTo(installedVersion!) == - 1; + bool get canInstall => status == AppInstallStatus.installable; + bool get canUpdate => status == AppInstallStatus.updatable; + bool get isUpdated => status == AppInstallStatus.updated; - bool get isUpdated => - installedVersion != null && - releases.latest?.androidArtifacts.firstOrNull != null && - releases.latest!.androidArtifacts.first.version! - .compareTo(installedVersion!) == - 0; + AppInstallStatus get status { + if (latestMetadata == null) { + return AppInstallStatus.notInstallable; + } + if (installedVersion == null) { + return AppInstallStatus.installable; + } + final comp = latestMetadata!.version!.compareTo(installedVersion!); + if (comp == 1) return AppInstallStatus.updatable; + if (comp == 0) return AppInstallStatus.updated; + // else it's a downgrade, which is not installable + return AppInstallStatus.notInstallable; + } + + Future install() async { + if (!canInstall && !canUpdate) { + return; + } + + final hash = latestMetadata!.hash!; + final size = latestMetadata!.size ?? ''; + + final adapter = DataModel.adapterFor(this) as AppAdapter; + final notifier = + adapter.ref.read(installationProgressProvider(id!.toString()).notifier); + + final installPermission = await Permission.requestInstallPackages.status; + if (!installPermission.isGranted) { + final newStatus = await Permission.requestInstallPackages.request(); + if (newStatus.isDenied) { + throw Exception('Installation permission denied'); + } + } + + final dir = await getApplicationSupportDirectory(); + final file = File(path.join(dir.path, hash)); + + installOnDevice() async { + notifier.state = DeviceInstallProgress(); + + if (await _isHashMismatch(file.path, hash)) { + notifier.state = ErrorInstallProgress( + e: Exception('Hash mismatch, aborted installation')); + await file.delete(); + return; + } + + int? code = + await AndroidPackageInstaller.installApk(apkFilePath: file.path); + if (code != 0) { + notifier.state = ErrorInstallProgress( + e: Exception( + 'Install: ${code != null ? PackageInstallerStatus.byCode(code) : ''}')); + } + + await file.delete(); + await adapter.getInstalledAppsMap(); + saveLocal(); + notifier.state = IdleInstallProgress(); + } + + if (await file.exists()) { + await installOnDevice(); + } else { + final client = http.Client(); + final sink = file.openWrite(); + + final backupUrl = 'https://cdn.zap.store/$hash'; + final url = latestMetadata!.urls.firstOrNull ?? backupUrl; + var downloadedBytes = 0; + Uri uri; + + var response = + await client.send(http.Request('GET', uri = Uri.parse(url))); + if (response.statusCode != 200) { + uri = Uri.parse(backupUrl); + response = await client.send(http.Request('GET', uri)); + } + final totalBytes = response.contentLength ?? int.tryParse(size) ?? 1; + + response.stream.listen((chunk) { + final data = Uint8List.fromList(chunk); + sink.add(data); + downloadedBytes += data.length; + notifier.state = + DownloadingInstallProgress(downloadedBytes / totalBytes, uri.host); + }, onError: (e) { + notifier.state = ErrorInstallProgress(e: e); + }, onDone: () async { + await sink.close(); + client.close(); + await installOnDevice(); + }); + } + } } + +Future _isHashMismatch(String path, String hash) async { + return await Isolate.run(() async { + final bytes = await File(path).readAsBytes(); + final digest = sha256.convert(bytes); + return digest.toString() != hash; + }); +} + +// install support + +enum AppInstallStatus { updated, updatable, installable, notInstallable } + +// class + +sealed class AppInstallProgress {} + +class IdleInstallProgress extends AppInstallProgress {} + +class DownloadingInstallProgress extends AppInstallProgress { + final double progress; + final String host; + DownloadingInstallProgress(this.progress, this.host); +} + +class DeviceInstallProgress extends AppInstallProgress {} + +class ErrorInstallProgress extends AppInstallProgress { + final Exception e; + ErrorInstallProgress({required this.e}); +} + +final installedAppProvider = StateProvider>((_) => {}); + +final installationProgressProvider = + StateProvider.family( + (_, arg) => IdleInstallProgress()); diff --git a/lib/models/app.g.dart b/lib/models/app.g.dart index 05f37fe..2909a39 100644 --- a/lib/models/app.g.dart +++ b/lib/models/app.g.dart @@ -92,28 +92,26 @@ App _$AppFromJson(Map json) => App() ..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? + ..kind = (json['kind'] as num).toInt() ..releases = HasMany.fromJson(json['releases'] as Map) ..signer = BelongsTo.fromJson(json['signer'] as Map) ..developer = - BelongsTo.fromJson(json['developer'] as Map) - ..installedVersion = json['currentVersion'] as String?; + BelongsTo.fromJson(json['developer'] as Map); Map _$AppToJson(App instance) => { 'id': instance.id, 'pubkey': instance.pubkey, 'createdAt': instance.createdAt.toIso8601String(), 'content': instance.content, - 'kind': instance.kind, 'tags': instance.tags, 'signature': instance.signature, + 'kind': instance.kind, 'releases': instance.releases, 'signer': instance.signer, 'developer': instance.developer, - 'currentVersion': instance.installedVersion, }; diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart index 763152f..bf2826d 100644 --- a/lib/models/file_metadata.dart +++ b/lib/models/file_metadata.dart @@ -1,20 +1,9 @@ -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:android_package_installer/android_package_installer.dart'; -import 'package:crypto/crypto.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:purplebase/purplebase.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:zapstore/models/nostr_adapter.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; -import 'package:zapstore/screens/app_detail_screen.dart'; part 'file_metadata.g.dart'; @@ -24,85 +13,6 @@ class FileMetadata extends Event with BaseFileMetadata { late final BelongsTo author; late final BelongsTo release = BelongsTo(); late final BelongsTo signer; - - Future install() async { - final notifier = DataModel.adapterFor(this) - .ref - .read(installationProgressProvider(id!.toString()).notifier); - - final installPermission = await Permission.requestInstallPackages.status; - if (!installPermission.isGranted) { - final newStatus = await Permission.requestInstallPackages.request(); - if (newStatus.isDenied) { - throw Exception('Installation permission denied'); - } - } - - final dir = await getApplicationSupportDirectory(); - final file = File(path.join(dir.path, hash)); - - installOnDevice() async { - notifier.state = DeviceInstallProgress(); - - if (await _isHashMismatch(file.path, hash!)) { - notifier.state = FinishedInstallProgress( - e: Exception('Hash mismatch, aborted installation')); - return; - } - - int? code = - await AndroidPackageInstaller.installApk(apkFilePath: file.path); - if (code != 0) { - notifier.state = FinishedInstallProgress( - e: Exception( - 'Install: ${code != null ? PackageInstallerStatus.byCode(code) : ''}')); - } - - await file.delete(); - notifier.state = FinishedInstallProgress(); - // TODO should recalculate installedVersion and save app - } - - if (await file.exists()) { - await installOnDevice(); - } else { - final client = http.Client(); - final sink = file.openWrite(); - - final backupUrl = 'https://cdn.zap.store/$hash'; - final url = urls.firstOrNull ?? backupUrl; - var downloadedBytes = 0; - - var response = await client.send(http.Request('GET', Uri.parse(url))); - if (response.statusCode != 200) { - final uri = Uri.parse(backupUrl); - response = await client.send(http.Request('GET', uri)); - } - final totalBytes = - response.contentLength ?? int.tryParse(size ?? '1') ?? 1; - - response.stream.listen((chunk) { - final data = Uint8List.fromList(chunk); - sink.add(data); - downloadedBytes += data.length; - notifier.state = - DownloadingInstallProgress(downloadedBytes / totalBytes); - }, onError: (e) { - notifier.state = FinishedInstallProgress(e: e); - }, onDone: () async { - await sink.close(); - client.close(); - await installOnDevice(); - }); - } - } - - static Future _isHashMismatch(String path, String hash) async => - await Isolate.run(() async { - final bytes = await File(path).readAsBytes(); - final digest = sha256.convert(bytes); - return digest.toString() != hash; - }); } mixin FileMetadataAdapter on Adapter { diff --git a/lib/models/release.dart b/lib/models/release.dart index 0561c3a..44527ec 100644 --- a/lib/models/release.dart +++ b/lib/models/release.dart @@ -39,9 +39,3 @@ extension HasManyReleaseX on HasMany { Release? get latest => toList().sorted((a, b) => a.createdAt.compareTo(b.createdAt)).firstOrNull; } - -extension ReleaseX on Release { - Set get androidArtifacts => artifacts - .where((a) => a.mimeType == 'application/vnd.android.package-archive') - .toSet(); -} diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index f20f770..06c5c10 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,5 +1,6 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:external_app_launcher/external_app_launcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; @@ -9,16 +10,15 @@ import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/screens/search_screen.dart'; import 'package:zapstore/widgets/card.dart'; import 'package:zapstore/widgets/pill_widget.dart'; class AppDetailScreen extends HookConsumerWidget { - final App app; + final App model; AppDetailScreen({ - required this.app, + required this.model, super.key, }); @@ -26,8 +26,11 @@ class AppDetailScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final scrollController = ScrollController(); + final state = ref.apps.watchOne(model.id!, + alsoWatch: (_) => {_.releases, _.releases.artifacts}); + useFuture(useMemoized(() async { - final releases = await ref.releases.findAll(params: {'#a': app.aTag}); + final releases = await ref.releases.findAll(params: {'#a': model.aTag}); final metadataIds = releases.map((r) => r.tagMap['e']!).expand((_) => _); await ref.fileMetadata.findAll(params: { 'ids': metadataIds, @@ -35,6 +38,8 @@ class AppDetailScreen extends HookConsumerWidget { }); })); + final app = state.model ?? model; + return Column( children: [ Expanded( @@ -52,39 +57,7 @@ class AppDetailScreen extends HookConsumerWidget { SliverList( delegate: SliverChildListDelegate( [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - CircularImage( - url: app.icons.firstOrNull, - size: 80, - radius: 25, - ), - Gap(16), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - app.name!, - minFontSize: 16, - style: TextStyle( - fontSize: 28, fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Gap(8), - if (app.releases.isNotEmpty) - PillWidget( - text: app.releases.first.version, - color: Colors.grey[800]), - ], - ), - ), - ], - ), + VersionedAppHeader(app: app), Gap(16), if (app.images.isNotEmpty) Scrollbar( @@ -230,18 +203,81 @@ class AppDetailScreen extends HookConsumerWidget { ), SizedBox( height: 50, - child: app.releases.latest?.androidArtifacts.firstOrNull == null - ? Container() - : Center( - child: InstallButton( - metadata: app.releases.latest!.androidArtifacts.first), - ), + child: Center( + child: InstallButton(app: app), + ), ), ], ); } } +class VersionedAppHeader extends StatelessWidget { + const VersionedAppHeader({ + super.key, + required this.app, + this.showUpdate = false, + }); + + final bool showUpdate; + final App app; + + @override + Widget build(BuildContext context) { + final isUpdate = app.canUpdate && showUpdate; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + CircularImage( + url: app.icons.firstOrNull, + size: 80, + radius: 25, + ), + Gap(16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + app.name!, + minFontSize: 16, + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Gap(8), + Wrap( + children: [ + if (isUpdate) + PillWidget( + text: app.installedVersion!, color: Colors.grey[800]), + if (isUpdate) Icon(Icons.arrow_right), + if (app.latestMetadata != null) + PillWidget( + text: app.latestMetadata!.version!, + color: Colors.grey[800], + ), + ], + ), + ], + ), + ), + if (isUpdate) + SizedBox( + width: 90, + height: 40, + child: InstallButton( + app: app, + compact: true, + ), + ), + ], + ); + } +} + class ReleaseCard extends StatelessWidget { ReleaseCard({ super.key, @@ -253,7 +289,7 @@ class ReleaseCard extends StatelessWidget { @override Widget build(BuildContext context) { - final metadata = release.androidArtifacts.firstOrNull; + final metadata = release.app.value!.latestMetadata; return Card( margin: EdgeInsets.only(top: 8, bottom: 8), elevation: 0, @@ -310,62 +346,62 @@ class ReleaseCard extends StatelessWidget { class InstallButton extends ConsumerWidget { InstallButton({ super.key, - required this.metadata, + required this.app, + this.compact = false, }); - final FileMetadata metadata; - bool get canUpdate => metadata.release.value!.app.value!.canUpdate; + final bool compact; + final App app; @override Widget build(BuildContext context, WidgetRef ref) { - final progress = - ref.watch(installationProgressProvider(metadata.id!.toString())); + final progress = ref.watch(installationProgressProvider(app.identifier!)); return SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - metadata.install(); + onPressed: switch (app.status) { + AppInstallStatus.notInstallable => null, + AppInstallStatus.updated => () async { + await LaunchApp.openApp(androidPackageName: app.id!.toString()); + }, + _ => () => switch (progress) { + IdleInstallProgress() => app.install(), + _ => null, + } }, style: ElevatedButton.styleFrom( + disabledForegroundColor: Colors.white, + disabledBackgroundColor: Colors.blueGrey, foregroundColor: Colors.white, backgroundColor: switch (progress) { - DownloadingInstallProgress() => Colors.blue[800], - FinishedInstallProgress(e: final e) => - e != null ? Colors.red : Colors.orangeAccent, + DownloadingInstallProgress() => Colors.blue[900], + ErrorInstallProgress() => Colors.red, _ => Colors.blue, }, ), - child: switch (progress) { - IdleInstallProgress() => canUpdate ? Text('Update') : Text('Install'), - DownloadingInstallProgress(progress: final progress) => - Text('Downloading: ${(progress * 100).floor()}%'), - DeviceInstallProgress() => - Text('${canUpdate ? 'Updating' : 'Installing'} on device'), - FinishedInstallProgress(e: final e) => - e != null ? Text(e.toString()) : Text('Done'), + child: switch (app.status) { + AppInstallStatus.notInstallable => Text('Sorry, can\'t install'), + AppInstallStatus.updated => Text('Open'), + _ => switch (progress) { + IdleInstallProgress() => app.canUpdate + ? AutoSizeText( + 'Update ${compact ? '' : 'to ${app.latestMetadata!.version!}'}', + maxLines: 1) + : Text('Install'), + DownloadingInstallProgress(progress: final p, host: final h) => + Text( + '${compact ? '' : 'Downloading from $h '}${(p * 100).floor()}%'), + DeviceInstallProgress() => compact + ? SizedBox( + width: 14, height: 14, child: CircularProgressIndicator()) + : Text( + '${app.canUpdate ? 'Updating' : 'Installing'} on device'), + ErrorInstallProgress(e: final e) => + compact ? Icon(Icons.error) : Text(e.toString()), + } }, ), ); } } - -sealed class InstallProgress {} - -class IdleInstallProgress extends InstallProgress {} - -class DeviceInstallProgress extends InstallProgress {} - -class FinishedInstallProgress extends InstallProgress { - final Exception? e; - FinishedInstallProgress({this.e}); -} - -class DownloadingInstallProgress extends InstallProgress { - final double progress; - DownloadingInstallProgress(this.progress); -} - -final installationProgressProvider = - StateProvider.family( - (_, arg) => IdleInstallProgress()); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 045fdfe..826fc42 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -17,6 +17,7 @@ class SearchScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useTextEditingController(); + final focusNode = useFocusNode(); final state = ref.watch(searchStateProvider); return Column( @@ -31,6 +32,7 @@ class SearchScreen extends HookConsumerWidget { Expanded( child: SearchBar( controller: controller, + focusNode: focusNode, shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(28), @@ -59,7 +61,7 @@ class SearchScreen extends HookConsumerWidget { hoverColor: Colors.transparent, onPressed: () { controller.clear(); - // TODO request focus + focusNode.requestFocus(); }, icon: Icon(Icons.close), ), @@ -67,7 +69,7 @@ class SearchScreen extends HookConsumerWidget { hintText: 'Search for apps', hintStyle: MaterialStateProperty.all( TextStyle(color: Colors.grey[600])), - autoFocus: true, + autoFocus: state.model.isEmpty, elevation: MaterialStateProperty.all(2.2), onSubmitted: (query) async { ref.read(searchQueryProvider.notifier).state = query; diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 7077d41..7ae0b1d 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -1,14 +1,13 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/release.dart'; import 'package:zapstore/screens/app_detail_screen.dart'; -import 'package:zapstore/widgets/card.dart'; -import 'package:zapstore/widgets/pill_widget.dart'; + +// TODO check notifiers are disposed when pop()ing final installedAppsStateProvider = StateNotifierProvider.autoDispose< DataStateNotifier>, DataState>>((ref) { @@ -33,51 +32,63 @@ class UpdatesScreen extends HookConsumerWidget { final updatableApps = state.model.where((app) => app.canUpdate).toList(); final updatedApps = state.model.where((app) => app.isUpdated).toList(); - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Installed apps', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), - // Expanded( - // child: TextButton( - // style: TextButton.styleFrom( - // foregroundColor: Colors.lightBlue, - // ), - // onPressed: () {}, - // child: const Text('Update All'), - // ), - // ), - ], - ), - Gap(20), - if (updatableApps.isNotEmpty) - Text( - '${updatableApps.length} updates available'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), + return RefreshIndicator( + onRefresh: () => ref.apps.findAll(params: {'installed': true}), + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Column( + key: UniqueKey(), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Installed apps', + style: + TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), + // Expanded( + // child: TextButton( + // style: TextButton.styleFrom( + // foregroundColor: Colors.lightBlue, + // ), + // onPressed: () {}, + // child: const Text('Update All'), + // ), + // ), + ], ), - Gap(10), - for (final app in updatableApps) UpdatesAppCard(app: app), - Gap(40), - if (updatedApps.isNotEmpty) - Text( - 'Up to date'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, + if (updatableApps.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Gap(20), + Text( + '${updatableApps.length} updates available'.toUpperCase(), + style: TextStyle( + fontSize: 16, + letterSpacing: 3, + fontWeight: FontWeight.w300, + ), + ), + // + Gap(10), + for (final app in updatableApps) UpdatesAppCard(app: app), + ], ), - ), - Gap(10), - for (final app in updatedApps) UpdatesAppCard(app: app), - ], + Gap(20), + if (updatedApps.isNotEmpty) + Text( + 'Up to date'.toUpperCase(), + style: TextStyle( + fontSize: 16, + letterSpacing: 3, + fontWeight: FontWeight.w300, + ), + ), + Gap(10), + for (final app in updatedApps) UpdatesAppCard(app: app), + ], + ), ), ); } @@ -91,64 +102,16 @@ class UpdatesAppCard extends StatelessWidget { final App app; - bool get isUpdate => - app.installedVersion != - app.releases.latest!.androidArtifacts.first.version; - @override Widget build(BuildContext context) { return Card( margin: EdgeInsets.only(top: 8, bottom: 8), elevation: 0, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - CircularImage( - url: app.icons.first, - size: 80, - radius: 25, - ), - Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - app.name!, - minFontSize: 16, - style: TextStyle(fontSize: 23, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - Wrap( - children: [ - PillWidget( - text: app.installedVersion!, color: Colors.grey[800]), - if (isUpdate) Icon(Icons.arrow_right), - if (isUpdate) - PillWidget( - text: app - .releases.latest!.androidArtifacts.first.version!, - color: Colors.grey[800], - ), - ], - ) - ], - ), - ), - if (app.installedVersion != - app.releases.latest!.androidArtifacts.first.version) - SizedBox( - width: 100, - height: 40, - child: InstallButton( - metadata: app.releases.latest!.androidArtifacts.first, - ), - ), - ], + child: GestureDetector( + onTap: () => context.go('/details', extra: app), + child: Padding( + padding: const EdgeInsets.all(12), + child: VersionedAppHeader(app: app, showUpdate: true), ), ), ); diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 39786d3..e692d65 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -56,16 +56,17 @@ class AppDrawer extends HookConsumerWidget { autocorrect: false, controller: controller, decoration: InputDecoration( - labelText: 'Input your NIP-05 address', + labelText: 'NIP-05 address or npub (no nsec!)', ), ), Gap(5), if (user == null) AsyncButtonBuilder( loadingWidget: SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator()), + width: 14, + height: 14, + child: CircularProgressIndicator(), + ), onPressed: () async { ref.read(loggedInUser.notifier).state = await ref.users .findOne(controller.text.trim(), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f93e892..51643f9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import device_info_plus import path_provider_foundation import sqflite import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 585ee6e..2371c28 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,6 +274,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + url: "https://pub.dev" + source: hosted + version: "10.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" equatable: dependency: transitive description: @@ -282,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" + external_app_launcher: + dependency: "direct main" + description: + name: external_app_launcher + sha256: a80847a8e24cce37f1c953ec20a9600c3b12167e279b5868945e880cea717bf7 + url: "https://pub.dev" + source: hosted + version: "4.0.0" fake_async: dependency: transitive description: @@ -433,10 +457,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + sha256: "9e0f7d1a3e7dc5010903e330fbc5497872c4c3cf6626381d69083cc1d5113c1e" url: "https://pub.dev" source: hosted - version: "13.2.5" + version: "14.0.2" graphs: dependency: transitive description: @@ -1097,6 +1121,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.5.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + url: "https://pub.dev" + source: hosted + version: "1.1.3" ws: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 38743d1..0226aef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: path_provider: ^2.1.2 crypto: ^3.0.3 android_package_installer: ^0.0.2 - go_router: ^13.2.0 + go_router: ^14.0.2 permission_handler: ^11.3.1 path: ^1.9.0 http: ^1.2.1 @@ -50,6 +50,8 @@ dependencies: gap: ^3.0.1 auto_size_text: ^3.0.0 intl: ^0.18.1 + device_info_plus: ^10.1.0 + external_app_launcher: ^4.0.0 dependency_overrides: android_package_installer: