more installation wip

This commit is contained in:
franzap
2024-05-06 19:56:22 -03:00
parent 9e69fd3534
commit e7d7295412
12 changed files with 448 additions and 327 deletions
+1 -1
View File
@@ -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),
),
],
),
+215 -34
View File
@@ -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<App> with BaseApp {
late final HasMany<Release> releases;
late final BelongsTo<User> signer;
late final BelongsTo<User> 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<App> {
AndroidPackageManager? _packageManager;
ProviderSubscription? _sub;
AppLifecycleListener? _lifecycleListener;
@override
Future<void> onInitialized() async {
if (!ref.read(localStorageProvider).inIsolate) {
_sub = ref.listen(installedAppProvider, (_, __) {
triggerNotify();
});
_lifecycleListener = AppLifecycleListener(
onStateChange: (state) async {
if (state == AppLifecycleState.resumed) {
await getInstalledAppsMap();
triggerNotify();
}
},
);
}
super.onInitialized();
}
@override
void dispose() {
_sub?.close();
_lifecycleListener?.dispose();
super.dispose();
}
@override
Future<List<App>> findAll(
@@ -33,26 +84,29 @@ mixin AppAdapter on Adapter<App> {
OnSuccessAll<App>? onSuccess,
OnErrorAll<App>? 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<Set<(String, String)>> getInstalledAppIdVersions() async {
static AndroidPackageManager? _packageManager;
Future<Map<String, String>> getInstalledAppsMap() async {
late List<PackageInfo>? infos;
if (Platform.isAndroid) {
_packageManager ??= AndroidPackageManager();
@@ -60,28 +114,155 @@ mixin AppAdapter on Adapter<App> {
} 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<void> 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<bool> _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<Map<String, String>>((_) => {});
final installationProgressProvider =
StateProvider.family<AppInstallProgress, String>(
(_, arg) => IdleInstallProgress());
+3 -5
View File
@@ -92,28 +92,26 @@ App _$AppFromJson(Map<String, dynamic> 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<dynamic>)
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
.toList()
..signature = json['signature'] as String?
..kind = (json['kind'] as num).toInt()
..releases =
HasMany<Release>.fromJson(json['releases'] as Map<String, dynamic>)
..signer = BelongsTo<User>.fromJson(json['signer'] as Map<String, dynamic>)
..developer =
BelongsTo<User>.fromJson(json['developer'] as Map<String, dynamic>)
..installedVersion = json['currentVersion'] as String?;
BelongsTo<User>.fromJson(json['developer'] as Map<String, dynamic>);
Map<String, dynamic> _$AppToJson(App instance) => <String, dynamic>{
'id': instance.id,
'pubkey': instance.pubkey,
'createdAt': instance.createdAt.toIso8601String(),
'content': instance.content,
'kind': instance.kind,
'tags': instance.tags,
'signature': instance.signature,
'kind': instance.kind,
'releases': instance.releases,
'signer': instance.signer,
'developer': instance.developer,
'currentVersion': instance.installedVersion,
};
-90
View File
@@ -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<FileMetadata> with BaseFileMetadata {
late final BelongsTo<User> author;
late final BelongsTo<Release> release = BelongsTo();
late final BelongsTo<User> signer;
Future<void> 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<bool> _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<FileMetadata> {
-6
View File
@@ -39,9 +39,3 @@ extension HasManyReleaseX on HasMany<Release> {
Release? get latest =>
toList().sorted((a, b) => a.createdAt.compareTo(b.createdAt)).firstOrNull;
}
extension ReleaseX on Release {
Set<FileMetadata> get androidArtifacts => artifacts
.where((a) => a.mimeType == 'application/vnd.android.package-archive')
.toSet();
}
+118 -82
View File
@@ -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<InstallProgress, String>(
(_, arg) => IdleInstallProgress());
+4 -2
View File
@@ -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;
+63 -100
View File
@@ -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<List<App>>, DataState<List<App>>>((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),
),
),
);
+5 -4
View File
@@ -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(),
@@ -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"))
+34 -2
View File
@@ -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:
+3 -1
View File
@@ -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: