mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
more installation wip
This commit is contained in:
+1
-1
@@ -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
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user