From d36b345f92aa645d438ef5e40bd5de7b2badaaac Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 6 Mar 2026 21:53:19 +0800 Subject: [PATCH] feat: extension store (#1301) --- ui/flutter/lib/api/api.dart | 6 +- ui/flutter/lib/api/gopeed_site_api.dart | 55 + ui/flutter/lib/api/model/store_extension.dart | 149 +++ .../controllers/extension_controller.dart | 342 +++++- .../extension/views/extension_card.dart | 273 +++++ .../views/extension_detail_view.dart | 324 ++++++ .../extension/views/extension_view.dart | 988 +++++++++++------- ui/flutter/lib/i18n/langs/en_us.dart | 21 + ui/flutter/lib/i18n/langs/zh_cn.dart | 21 + ui/flutter/lib/util/updater.dart | 4 +- ui/flutter/pubspec.lock | 32 +- ui/flutter/pubspec.yaml | 1 + 12 files changed, 1791 insertions(+), 425 deletions(-) create mode 100644 ui/flutter/lib/api/gopeed_site_api.dart create mode 100644 ui/flutter/lib/api/model/store_extension.dart create mode 100644 ui/flutter/lib/app/modules/extension/views/extension_card.dart create mode 100644 ui/flutter/lib/app/modules/extension/views/extension_detail_view.dart diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 17eb6a1e..a38aab65 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -200,10 +200,10 @@ Future putConfig(DownloaderConfig config) async { return _parse(() => _client.dio.put("api/v1/config", data: config), null); } -Future installExtension(InstallExtension installExtension) async { - return _parse( +Future installExtension(InstallExtension installExtension) async { + return _parse( () => _client.dio.post("api/v1/extensions", data: installExtension), - null); + (data) => data as String); } Future> getExtensions() async { diff --git a/ui/flutter/lib/api/gopeed_site_api.dart b/ui/flutter/lib/api/gopeed_site_api.dart new file mode 100644 index 00000000..4e4e2e30 --- /dev/null +++ b/ui/flutter/lib/api/gopeed_site_api.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import 'api.dart'; +import 'model/store_extension.dart'; + +class GopeedSiteApi { + GopeedSiteApi._(); + + static final instance = GopeedSiteApi._(); + + static const _host = 'gopeed.com'; + + Future> getRelease() async { + final json = await _getJson('/api/release'); + return json as Map; + } + + Future getExtensions({ + int page = 1, + int limit = 20, + StoreExtensionSort sort = StoreExtensionSort.stars, + StoreSortOrder order = StoreSortOrder.desc, + String? query, + }) async { + final json = await _getJson('/api/extensions', queryParameters: { + 'page': page.toString(), + 'limit': limit.clamp(1, 100).toString(), + 'sort': sort.name, + 'order': order.name, + if (query != null && query.trim().isNotEmpty) 'q': query.trim(), + }); + return StoreExtensionPage.fromJson(json as Map); + } + + Future reportExtensionInstall(String id) async { + final uri = Uri.https(_host, '/api/extensions/install'); + await proxyRequest( + uri.toString(), + data: {'id': id}, + options: Options(method: 'POST', contentType: Headers.jsonContentType), + ); + } + + Future _getJson(String path, + {Map? queryParameters}) async { + final uri = Uri.https(_host, path, queryParameters); + final Response response = await proxyRequest(uri.toString()); + if (response.data == null || response.data!.isEmpty) { + throw Exception('Empty response from $uri'); + } + return jsonDecode(response.data!); + } +} diff --git a/ui/flutter/lib/api/model/store_extension.dart b/ui/flutter/lib/api/model/store_extension.dart new file mode 100644 index 00000000..50803887 --- /dev/null +++ b/ui/flutter/lib/api/model/store_extension.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +class StoreExtensionPage { + final List data; + final StorePagination pagination; + + StoreExtensionPage({required this.data, required this.pagination}); + + factory StoreExtensionPage.fromJson(Map json) { + return StoreExtensionPage( + data: (json['data'] as List? ?? []) + .map((e) => StoreExtension.fromJson(e as Map)) + .toList(), + pagination: StorePagination.fromJson( + json['pagination'] as Map? ?? {}), + ); + } +} + +class StorePagination { + final int page; + final int limit; + final int total; + final int totalPages; + final bool hasNext; + final bool hasPrev; + + StorePagination({ + required this.page, + required this.limit, + required this.total, + required this.totalPages, + required this.hasNext, + required this.hasPrev, + }); + + factory StorePagination.fromJson(Map json) { + return StorePagination( + page: (json['page'] as num?)?.toInt() ?? 1, + limit: (json['limit'] as num?)?.toInt() ?? 20, + total: (json['total'] as num?)?.toInt() ?? 0, + totalPages: (json['totalPages'] as num?)?.toInt() ?? 1, + hasNext: json['hasNext'] as bool? ?? false, + hasPrev: json['hasPrev'] as bool? ?? false, + ); + } +} + +class StoreExtension { + final String id; + final String repoFullName; + final String repoUrl; + final String? directory; + final String? commitSha; + final String name; + final String author; + final String title; + final String description; + final String? icon; + final String version; + final String? homepage; + final String? readme; + final int installCount; + final int stars; + final List topics; + final DateTime? createdAt; + final DateTime? updatedAt; + + StoreExtension({ + required this.id, + required this.repoFullName, + required this.repoUrl, + this.directory, + this.commitSha, + required this.name, + required this.author, + required this.title, + required this.description, + this.icon, + required this.version, + this.homepage, + this.readme, + required this.installCount, + required this.stars, + required this.topics, + this.createdAt, + this.updatedAt, + }); + + factory StoreExtension.fromJson(Map json) { + return StoreExtension( + id: json['id'] as String? ?? '', + repoFullName: json['repoFullName'] as String? ?? '', + repoUrl: json['repoUrl'] as String? ?? '', + directory: json['directory'] as String?, + commitSha: json['commitSha'] as String?, + name: json['name'] as String? ?? '', + author: json['author'] as String? ?? '', + title: json['title'] as String? ?? '', + description: json['description'] as String? ?? '', + icon: json['icon'] as String?, + version: json['version'] as String? ?? '0.0.0', + homepage: json['homepage'] as String?, + readme: json['readme'] as String?, + installCount: (json['installCount'] as num?)?.toInt() ?? 0, + stars: (json['stars'] as num?)?.toInt() ?? 0, + topics: _parseTopics(json['topics']), + createdAt: _parseDate(json['createdAt']), + updatedAt: _parseDate(json['updatedAt']), + ); + } + + static List _parseTopics(dynamic value) { + if (value is List) { + return value.map((e) => e.toString()).toList(); + } + if (value is String && value.isNotEmpty) { + try { + final parsed = jsonDecode(value); + if (parsed is List) { + return parsed.map((e) => e.toString()).toList(); + } + } catch (_) {} + } + return const []; + } + + static DateTime? _parseDate(dynamic value) { + if (value == null) return null; + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value); + } + if (value is String && value.isNotEmpty) { + return DateTime.tryParse(value); + } + return null; + } +} + +enum StoreExtensionSort { + stars, + installs, + updated, +} + +enum StoreSortOrder { + asc, + desc, +} diff --git a/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart b/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart index c421980f..bdbc960e 100644 --- a/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart +++ b/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart @@ -1,38 +1,278 @@ -import 'package:get/get.dart'; -import 'package:gopeed/api/api.dart'; +import 'dart:async'; +import 'dart:collection'; +import 'package:get/get.dart'; + +import '../../../../api/api.dart'; +import '../../../../api/gopeed_site_api.dart'; import '../../../../api/model/extension.dart'; +import '../../../../api/model/install_extension.dart'; +import '../../../../api/model/store_extension.dart'; +import '../../../../api/model/switch_extension.dart'; + +enum ExtensionListFilter { + market, + installed, +} + +class ExtensionListItem { + final Extension? installed; + final StoreExtension? store; + + const ExtensionListItem({this.installed, this.store}); + + bool get isInstalled => installed != null; + + String get id => installed?.identity ?? store!.id; + + String get title => installed?.title ?? store?.title ?? ''; + + String get author => installed?.author ?? store?.author ?? ''; + + String get description => installed?.description ?? store?.description ?? ''; + + String get version => installed?.version ?? store?.version ?? '0.0.0'; + + String? get homepage { + final installedHomepage = installed?.homepage; + if (installedHomepage != null && installedHomepage.isNotEmpty) { + return installedHomepage; + } + return store?.homepage; + } + + String? get repoUrl { + final installedRepo = installed?.repository?.url; + if (installedRepo != null && installedRepo.isNotEmpty) { + return installedRepo; + } + return store?.repoUrl; + } + + int get stars => store?.stars ?? 0; + + int get installCount => store?.installCount ?? 0; + + String? get icon => store?.icon; +} class ExtensionController extends GetxController { - final extensions = [].obs; + static const manualInstallBusyKey = '__manual_install__'; + + final installedExtensions = [].obs; final updateFlags = {}.obs; + + final storeExtensions = [].obs; + final storePagination = Rxn(); + final storeQuery = ''.obs; + final storeSort = StoreExtensionSort.stars.obs; + + final listFilter = ExtensionListFilter.market.obs; + + final loadingInstalled = false.obs; + final loadingStore = false.obs; + final loadingMoreStore = false.obs; + final busyExtensionIds = {}.obs; + final showInstallTools = false.obs; + final devMode = false.obs; var _devModeCount = 0; - /// Flag to prevent handling pending install multiple times bool pendingInstallHandled = false; + UnmodifiableMapView get installedMap => + UnmodifiableMapView( + {for (final ext in installedExtensions) ext.identity: ext}); + + UnmodifiableMapView get storeMap => + UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext}); + + List get displayItems { + if (listFilter.value == ExtensionListFilter.installed) { + return installedExtensions + .map((ext) => + ExtensionListItem(installed: ext, store: storeMap[ext.identity])) + .toList(); + } + + return storeExtensions + .map((ext) => + ExtensionListItem(installed: installedMap[ext.id], store: ext)) + .toList(); + } + @override - void onInit() async { + Future onInit() async { super.onInit(); - await load(); - checkUpdate(); + await loadInitialData(); } - Future load() async { - extensions.value = await getExtensions(); + Future loadInitialData() async { + await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]); } - Future checkUpdate() async { - for (final ext in extensions) { - final resp = await upgradeCheckExtension(ext.identity); - if (resp.newVersion.isNotEmpty) { - updateFlags[ext.identity] = resp.newVersion; + Future loadInstalled({bool refreshUpdates = false}) async { + loadingInstalled.value = true; + try { + installedExtensions.value = await getExtensions(); + if (refreshUpdates) { + await checkUpdate(); } + } finally { + loadingInstalled.value = false; } } - // Try to open dev mode when install button is clicked 5 times in 2 seconds + Future checkUpdate() async { + final nextFlags = {}; + for (final ext in installedExtensions) { + try { + final resp = await upgradeCheckExtension(ext.identity); + if (resp.newVersion.isNotEmpty) { + nextFlags[ext.identity] = resp.newVersion; + } + } catch (_) { + // Ignore single extension check failures to avoid breaking the whole list. + } + } + updateFlags.assignAll(nextFlags); + } + + Future refreshStore() async { + loadingStore.value = true; + try { + final page = await GopeedSiteApi.instance.getExtensions( + page: 1, + limit: 20, + sort: storeSort.value, + query: storeQuery.value, + ); + storeExtensions.assignAll(page.data); + storePagination.value = page.pagination; + } finally { + loadingStore.value = false; + } + } + + Future loadMoreStore() async { + final pagination = storePagination.value; + if (pagination == null || !pagination.hasNext || loadingMoreStore.value) { + return; + } + loadingMoreStore.value = true; + try { + final page = await GopeedSiteApi.instance.getExtensions( + page: pagination.page + 1, + limit: pagination.limit, + sort: storeSort.value, + query: storeQuery.value, + ); + storeExtensions.addAll(page.data); + storePagination.value = page.pagination; + } finally { + loadingMoreStore.value = false; + } + } + + Future searchStore(String query) async { + storeQuery.value = query.trim(); + await refreshStore(); + } + + Future changeSort(StoreExtensionSort sort) async { + if (storeSort.value == sort) { + await refreshStore(); + return; + } + storeSort.value = sort; + await refreshStore(); + } + + void changeFilter(ExtensionListFilter filter) { + listFilter.value = filter; + } + + void toggleInstallTools() { + showInstallTools.value = !showInstallTools.value; + } + + Extension? findInstalled(StoreExtension extension) { + return installedMap[extension.id]; + } + + bool canUpdateFromStore(StoreExtension extension) { + final installed = findInstalled(extension); + if (installed == null) return false; + return _compareVersion(extension.version, installed.version) > 0; + } + + bool canUpdateItem(ExtensionListItem item) { + if (item.installed == null) return false; + if (listFilter.value == ExtensionListFilter.market && item.store != null) { + return canUpdateFromStore(item.store!); + } + return updateFlags.containsKey(item.installed!.identity); + } + + Future installFromStore(StoreExtension extension) async { + await _runBusy(extension.id, () async { + final installUrl = (extension.directory ?? '').trim().isEmpty + ? extension.repoUrl + : '${extension.repoUrl}#${extension.directory!.trim()}'; + + final installedId = + await installExtension(InstallExtension(url: installUrl)); + final statsId = installedId.isNotEmpty ? installedId : extension.id; + await loadInstalled(refreshUpdates: false); + _backgroundCheckUpdate(); + _bumpStoreInstallCount(statsId); + _reportInstallSafe(statsId); + }); + } + + Future installFromUrl(String url, + {bool devInstall = false, String? statsId}) async { + await _runBusy(manualInstallBusyKey, () async { + final installedId = await installExtension( + InstallExtension(devMode: devInstall, url: url)); + await loadInstalled(refreshUpdates: false); + _backgroundCheckUpdate(); + if (installedId.isNotEmpty) { + _bumpStoreInstallCount(installedId); + _reportInstallSafe(installedId); + } else if (statsId != null && statsId.isNotEmpty) { + _bumpStoreInstallCount(statsId); + _reportInstallSafe(statsId); + } + }); + } + + Future toggleExtension(Extension extension, bool enabled) async { + await _runBusy(extension.identity, () async { + await switchExtension( + extension.identity, SwitchExtension(status: enabled)); + await loadInstalled(refreshUpdates: false); + }); + } + + Future removeExtension(Extension extension) async { + await _runBusy(extension.identity, () async { + await deleteExtension(extension.identity); + await loadInstalled(refreshUpdates: false); + updateFlags.remove(extension.identity); + }); + } + + Future upgradeExtension(Extension extension) async { + await _runBusy(extension.identity, () async { + await updateExtension(extension.identity); + await loadInstalled(refreshUpdates: false); + _backgroundCheckUpdate(); + _bumpStoreInstallCount(extension.identity); + _reportInstallSafe(extension.identity); + }); + } + void tryOpenDevMode() { if (_devModeCount == 0) { Future.delayed(const Duration(seconds: 2), () { @@ -46,4 +286,76 @@ class ExtensionController extends GetxController { devMode.value = true; } } + + static int _compareVersion(String a, String b) { + final aNums = _toVersionNumbers(a); + final bNums = _toVersionNumbers(b); + final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length; + + for (var i = 0; i < maxLen; i++) { + final left = i < aNums.length ? aNums[i] : 0; + final right = i < bNums.length ? bNums[i] : 0; + if (left > right) return 1; + if (left < right) return -1; + } + return 0; + } + + static List _toVersionNumbers(String version) { + return version + .split(RegExp(r'[^0-9]+')) + .where((part) => part.isNotEmpty) + .map((part) => int.tryParse(part) ?? 0) + .toList(); + } + + Future _runBusy(String id, Future Function() action) async { + if (busyExtensionIds.contains(id)) return; + busyExtensionIds.add(id); + try { + await action(); + } finally { + busyExtensionIds.remove(id); + } + } + + void _backgroundCheckUpdate() { + unawaited(checkUpdate()); + } + + void _reportInstallSafe(String id) { + unawaited(() async { + try { + await GopeedSiteApi.instance.reportExtensionInstall(id); + } catch (_) { + // Ignore stats reporting failures. + } + }()); + } + + void _bumpStoreInstallCount(String id) { + final index = storeExtensions.indexWhere((e) => e.id == id); + if (index < 0) return; + final ext = storeExtensions[index]; + storeExtensions[index] = StoreExtension( + id: ext.id, + repoFullName: ext.repoFullName, + repoUrl: ext.repoUrl, + directory: ext.directory, + commitSha: ext.commitSha, + name: ext.name, + author: ext.author, + title: ext.title, + description: ext.description, + icon: ext.icon, + version: ext.version, + homepage: ext.homepage, + readme: ext.readme, + installCount: ext.installCount + 1, + stars: ext.stars, + topics: ext.topics, + createdAt: ext.createdAt, + updatedAt: ext.updatedAt, + ); + } } diff --git a/ui/flutter/lib/app/modules/extension/views/extension_card.dart b/ui/flutter/lib/app/modules/extension/views/extension_card.dart new file mode 100644 index 00000000..95cd798a --- /dev/null +++ b/ui/flutter/lib/app/modules/extension/views/extension_card.dart @@ -0,0 +1,273 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/api.dart'; +import '../../../../database/database.dart'; +import '../../../../util/util.dart'; +import '../controllers/extension_controller.dart'; + +class ExtensionCard extends StatelessWidget { + const ExtensionCard({ + super.key, + required this.item, + required this.busy, + required this.canUpdate, + this.onTap, + this.onToggle, + this.onOpenSetting, + this.onUpdate, + this.onDelete, + this.onInstall, + }); + + final ExtensionListItem item; + final bool busy; + final bool canUpdate; + final VoidCallback? onTap; + final Future Function(bool value)? onToggle; + final VoidCallback? onOpenSetting; + final VoidCallback? onUpdate; + final VoidCallback? onDelete; + final VoidCallback? onInstall; + + @override + Widget build(BuildContext context) { + final installed = item.installed; + final store = item.store; + final installedFlag = item.isInstalled; + + final content = Stack( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardIcon(item), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + '${item.author} · v${item.version}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (installed != null) ...[ + Transform.scale( + scale: 0.82, + child: Switch( + value: !installed.disabled, + onChanged: busy || onToggle == null + ? null + : (value) async { + await onToggle!(value); + }, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + item.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + if (store != null) ...[ + _metricItem( + context, Icons.star_rounded, item.stars.toString()), + const SizedBox(width: 10), + _metricItem(context, Icons.download_outlined, + item.installCount.toString()), + ], + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (installed != null && canUpdate) + IconButton.filledTonal( + tooltip: 'newVersionUpdate'.tr, + onPressed: busy ? null : onUpdate, + icon: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + ), + if (!installedFlag && store != null) + IconButton.filledTonal( + tooltip: 'extensionInstall'.tr, + onPressed: busy ? null : onInstall, + icon: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download), + ), + if ((item.homepage ?? '').isNotEmpty) + IconButton( + tooltip: 'homepage'.tr, + onPressed: () => launchUrl(Uri.parse(item.homepage!)), + icon: const Icon(Icons.home_outlined), + ), + if ((item.repoUrl ?? '').isNotEmpty) + IconButton( + tooltip: 'GitHub', + onPressed: () => launchUrl(Uri.parse(item.repoUrl!)), + icon: const Icon(Icons.code), + ), + if (installed != null && + installed.settings?.isNotEmpty == true) + IconButton( + tooltip: 'setting'.tr, + onPressed: busy ? null : onOpenSetting, + icon: const Icon(Icons.settings), + ), + if (installed != null) + IconButton( + tooltip: 'delete'.tr, + onPressed: busy ? null : onDelete, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ], + ), + ], + ), + ), + if (installedFlag && canUpdate) + Positioned( + top: 10, + right: 10, + child: _updateDot(), + ), + ], + ); + + if (onTap == null) return content; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: content, + ); + } + + Widget _buildCardIcon(ExtensionListItem item) { + final storeIcon = item.icon; + if (storeIcon != null && storeIcon.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + storeIcon, + width: 42, + height: 42, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Image.asset( + 'assets/extension/default_icon.png', + width: 42, + height: 42), + ), + ); + } + + final extension = item.installed; + if (extension == null) { + return Image.asset('assets/extension/default_icon.png', + width: 42, height: 42); + } + + final image = extension.icon.isEmpty + ? Image.asset('assets/extension/default_icon.png', + width: 42, height: 42) + : Util.isWeb() + ? Image.network( + join('/fs/extensions/${extension.identity}/${extension.icon}'), + width: 42, + height: 42, + headers: { + 'Authorization': 'Bearer ${Database.instance.getWebToken()}' + }, + errorBuilder: (_, __, ___) => Image.asset( + 'assets/extension/default_icon.png', + width: 42, + height: 42), + ) + : Image.file( + extension.devMode + ? File(path.join(extension.devPath, extension.icon)) + : File(path.join(Util.getStorageDir(), 'extensions', + extension.identity, extension.icon)), + width: 42, + height: 42, + errorBuilder: (_, __, ___) => Image.asset( + 'assets/extension/default_icon.png', + width: 42, + height: 42), + ); + + return ClipRRect(borderRadius: BorderRadius.circular(8), child: image); + } + + Widget _metricItem(BuildContext context, IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: Theme.of(context).hintColor), + const SizedBox(width: 4), + Text(text, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } + + Widget _updateDot() { + return Container( + width: 9, + height: 9, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/ui/flutter/lib/app/modules/extension/views/extension_detail_view.dart b/ui/flutter/lib/app/modules/extension/views/extension_detail_view.dart new file mode 100644 index 00000000..62430dc2 --- /dev/null +++ b/ui/flutter/lib/app/modules/extension/views/extension_detail_view.dart @@ -0,0 +1,324 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:get/get.dart'; +import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../api/model/extension.dart'; +import '../../../../api/model/store_extension.dart'; +import '../../../../util/message.dart'; +import '../../../../util/util.dart'; +import '../controllers/extension_controller.dart'; + +class ExtensionDetailDrawer extends GetView { + const ExtensionDetailDrawer({ + super.key, + required this.extension, + required this.onClose, + this.installed, + }); + + final StoreExtension extension; + final Extension? installed; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 10), + child: Row( + children: [ + Expanded( + child: Text( + extension.title, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close), + ) + ], + ), + ), + const Divider(height: 1), + Expanded( + child: Obx(() { + final localInstalled = + installed ?? controller.findInstalled(extension); + final canUpdate = controller.canUpdateFromStore(extension); + final busy = + controller.busyExtensionIds.contains(extension.id) || + (localInstalled != null && + controller.busyExtensionIds + .contains(localInstalled.identity)); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIcon(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${extension.author} • v${extension.version}'), + const SizedBox(height: 6), + Text( + extension.description, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (localInstalled == null) + ElevatedButton.icon( + onPressed: busy + ? null + : () async { + try { + await controller + .installFromStore(extension); + showMessage('tip'.tr, + 'extensionInstallSuccess'.tr); + } catch (e) { + showErrorMessage(e); + } + }, + icon: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.download), + label: Text('extensionInstall'.tr), + ), + if (localInstalled != null && canUpdate) + ElevatedButton.icon( + onPressed: busy + ? null + : () async { + try { + await controller + .upgradeExtension(localInstalled); + showMessage('tip'.tr, + 'extensionUpdateSuccess'.tr); + } catch (e) { + showErrorMessage(e); + } + }, + icon: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + label: Text('newVersionUpdate'.tr), + ), + if ((extension.homepage ?? '').isNotEmpty) + OutlinedButton.icon( + onPressed: () => + launchUrl(Uri.parse(extension.homepage!)), + icon: const Icon(Icons.home_outlined), + label: Text('homepage'.tr), + ), + OutlinedButton.icon( + onPressed: () => + launchUrl(Uri.parse(extension.repoUrl)), + icon: const Icon(Icons.code), + label: const Text('GitHub'), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1), + const SizedBox(height: 12), + _buildReadme(context, localInstalled), + ], + ); + }), + ), + ], + ), + ), + ); + } + + Widget _buildReadme(BuildContext context, Extension? installed) { + return FutureBuilder<_ReadmeInfo>( + future: _loadReadme(installed), + builder: (context, snapshot) { + final info = snapshot.data; + final markdown = info?.content ?? extension.readme ?? ''; + if (markdown.trim().isEmpty) { + return Text('No README', + style: Theme.of(context).textTheme.bodyMedium); + } + + return MarkdownBody( + data: markdown, + selectable: true, + onTapLink: (text, href, title) { + if (href == null || href.isEmpty) return; + final resolved = _resolvePath(href, info, forImage: false); + if (resolved == null) return; + launchUrl(Uri.parse(resolved), + mode: LaunchMode.externalApplication); + }, + imageBuilder: (uri, title, alt) { + final resolved = _resolvePath(uri.toString(), info, forImage: true); + if (resolved == null) return const SizedBox.shrink(); + if (resolved.startsWith('file://')) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Image.file( + File(Uri.parse(resolved).toFilePath()), + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Image.network( + resolved, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + }, + ); + }, + ); + } + + Future<_ReadmeInfo> _loadReadme(Extension? installed) async { + if (installed == null) { + return _ReadmeInfo( + content: extension.readme ?? '', + mode: _ReadmeMode.remote, + localReadmePath: null, + ); + } + + final rootDir = installed.devMode + ? installed.devPath + : path.join(Util.getStorageDir(), 'extensions', installed.identity); + final candidates = [ + path.join(rootDir, 'README.md'), + path.join(rootDir, 'readme.md'), + path.join(rootDir, 'README.MD'), + ]; + for (final filePath in candidates) { + final file = File(filePath); + if (await file.exists()) { + return _ReadmeInfo( + content: await file.readAsString(), + mode: _ReadmeMode.local, + localReadmePath: filePath, + ); + } + } + + return _ReadmeInfo( + content: extension.readme ?? '', + mode: _ReadmeMode.remote, + localReadmePath: null, + ); + } + + String? _resolvePath(String raw, _ReadmeInfo? info, + {required bool forImage}) { + final value = raw.trim(); + if (value.isEmpty) return null; + final uri = Uri.tryParse(value); + if (uri != null && uri.hasScheme) { + return uri.toString(); + } + + if (info?.mode == _ReadmeMode.local && + info?.localReadmePath != null && + forImage) { + final readmeDir = path.dirname(info!.localReadmePath!); + final clean = value.split('#').first; + final absolute = path.normalize(path.join(readmeDir, clean)); + return Uri.file(absolute).toString(); + } + + final ref = + extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD'; + final dir = (extension.directory ?? '').trim(); + final baseSegments = [ + if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty), + '', + ]; + + final base = forImage + ? Uri.https('raw.githubusercontent.com', + '/${extension.repoFullName}/$ref/${baseSegments.join('/')}') + : Uri.https('github.com', + '/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}'); + return base.resolve(value).toString(); + } + + Widget _buildIcon() { + if ((extension.icon ?? '').isEmpty) { + return Image.asset('assets/extension/default_icon.png', + width: 56, height: 56); + } + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + extension.icon!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) { + return Image.asset('assets/extension/default_icon.png', + width: 56, height: 56); + }, + ), + ); + } +} + +enum _ReadmeMode { + remote, + local, +} + +class _ReadmeInfo { + final String content; + final _ReadmeMode mode; + final String? localReadmePath; + + _ReadmeInfo({ + required this.content, + required this.mode, + required this.localReadmePath, + }); +} diff --git a/ui/flutter/lib/app/modules/extension/views/extension_view.dart b/ui/flutter/lib/app/modules/extension/views/extension_view.dart index a21ec075..b27c8ca7 100644 --- a/ui/flutter/lib/app/modules/extension/views/extension_view.dart +++ b/ui/flutter/lib/app/modules/extension/views/extension_view.dart @@ -1,45 +1,46 @@ -import 'dart:io'; - -import 'package:badges/badges.dart' as badges; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:get/get.dart'; -import 'package:path/path.dart' as path; import 'package:rounded_loading_button_plus/rounded_loading_button.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/api.dart'; import '../../../../api/model/extension.dart'; import '../../../../api/model/install_extension.dart'; -import '../../../../api/model/switch_extension.dart'; +import '../../../../api/model/store_extension.dart'; import '../../../../api/model/update_extension_settings.dart'; -import '../../../../database/database.dart'; import '../../../../util/message.dart'; import '../../../../util/util.dart'; import '../../../views/icon_button_loading.dart'; import '../../../views/responsive_builder.dart'; -import '../../../views/text_button_loading.dart'; import '../controllers/extension_controller.dart'; +import 'extension_card.dart'; +import 'extension_detail_view.dart'; class ExtensionView extends GetView { ExtensionView({Key? key}) : super(key: key); final _installUrlController = TextEditingController(); + final _searchController = TextEditingController(); final _installBtnController = IconButtonLoadingController(); Future _doInstall() async { - if (_installUrlController.text.isEmpty) { + final url = _installUrlController.text.trim(); + if (url.isEmpty) { controller.tryOpenDevMode(); return; } + if (controller.busyExtensionIds + .contains(ExtensionController.manualInstallBusyKey)) { + return; + } _installBtnController.start(); try { - await installExtension(InstallExtension(url: _installUrlController.text)); + await controller.installFromUrl(url); showMessage('tip'.tr, 'extensionInstallSuccess'.tr); - await controller.load(); } catch (e) { showErrorMessage(e); } finally { @@ -47,239 +48,476 @@ class ExtensionView extends GetView { } } + Future _installFromFolder() async { + if (controller.busyExtensionIds + .contains(ExtensionController.manualInstallBusyKey)) { + return; + } + final dir = await FilePicker.platform.getDirectoryPath(); + if (dir == null) return; + try { + await controller.installFromUrl(dir, devInstall: true); + showMessage('tip'.tr, 'extensionInstallSuccess'.tr); + } catch (e) { + showErrorMessage(e); + } + } + @override Widget build(BuildContext context) { - // Handle pending install extension from deep link final args = Get.rootDelegate.arguments(); if (args is InstallExtension && !controller.pendingInstallHandled) { controller.pendingInstallHandled = true; _installUrlController.text = args.url; - WidgetsBinding.instance.addPostFrameCallback((_) { - _doInstall(); - }); + WidgetsBinding.instance.addPostFrameCallback((_) => _doInstall()); } + return Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton.icon( - onPressed: () { - launchUrl(Uri.parse( - 'https://github.com/search?q=topic%3Agopeed-extension&type=repositories')); - }, - icon: const Icon(Icons.search), - label: Text('extensionFind'.tr), + body: SafeArea( + child: Obx( + () => RefreshIndicator( + onRefresh: controller.loadInitialData, + child: ListView( + padding: EdgeInsets.symmetric( + horizontal: ResponsiveBuilder.isNarrow(context) ? 16 : 24, + vertical: 20, ), - const SizedBox(width: 16), - TextButton.icon( - onPressed: () { - launchUrl( - Uri.parse('https://docs.gopeed.com/dev-extension.html')); - }, - icon: const Icon(Icons.edit), - label: Text('extensionDevelop'.tr), - ), - ], - ), - Obx(() => Row( - children: [ - Expanded( - child: TextField( - controller: _installUrlController, - decoration: InputDecoration( - labelText: 'extensionInstallUrl'.tr, - ), + children: [ + _buildMarketToolbar(context), + const SizedBox(height: 12), + _buildFilterBar(context), + if (controller.showInstallTools.value) ...[ + const SizedBox(height: 10), + _buildInstallPanel(context), + ], + const SizedBox(height: 12), + _buildUnifiedGrid(context), + if (controller.listFilter.value == ExtensionListFilter.market && + controller.storePagination.value?.hasNext == true) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.center, + child: OutlinedButton.icon( + onPressed: controller.loadingMoreStore.value + ? null + : controller.loadMoreStore, + icon: controller.loadingMoreStore.value + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text('extensionLoadMore'.tr), ), ), - const SizedBox(width: 10), - IconButtonLoading( - controller: _installBtnController, - onPressed: _doInstall, - icon: const Icon(Icons.download)), - controller.devMode.value && Util.isDesktop() - ? IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () async { - var dir = - await FilePicker.platform.getDirectoryPath(); - if (dir != null) { - try { - await installExtension( - InstallExtension(devMode: true, url: dir)); - showMessage( - 'tip'.tr, 'extensionInstallSuccess'.tr); - await controller.load(); - } catch (e) { - showErrorMessage(e); - } - } - }) - : Container() ], - )), - const SizedBox(height: 16), - Expanded( - child: Obx(() => ListView.builder( - itemCount: controller.extensions.length, - itemBuilder: (context, index) { - final extension = controller.extensions[index]; - return SizedBox( - height: ResponsiveBuilder.isNarrow(context) ? 152 : 112, - child: Card( - elevation: 4.0, - child: Column( - children: [ - Expanded( - child: ListTile( - leading: extension.icon.isEmpty - ? Image.asset( - "assets/extension/default_icon.png", - width: 48, - height: 48, - ) - : Util.isWeb() - ? Image.network( - join( - '/fs/extensions/${extension.identity}/${extension.icon}'), - width: 48, - height: 48, - headers: { - 'Authorization': - 'Bearer ${Database.instance.getWebToken()}' - }, - ) - : Image.file( - extension.devMode - ? File(path.join( - extension.devPath, - extension.icon)) - : File(path.join( - Util.getStorageDir(), - "extensions", - extension.identity, - extension.icon)), - width: 48, - height: 48, - ), - trailing: Switch( - value: !extension.disabled, - onChanged: (value) async { - try { - await switchExtension( - extension.identity, - SwitchExtension(status: value)); - await controller.load(); - } catch (e) { - showErrorMessage(e); - } - }, - ), - title: Row( - children: () { - final list = [ - ResponsiveBuilder.isNarrow(context) - ? Expanded( - child: Text( - extension.title, - overflow: - TextOverflow.ellipsis, - ), - ) - : Text(extension.title), - const SizedBox(width: 8), - buildChip('v${extension.version}'), - ]; - if (extension.devMode) { - list.add(const SizedBox(width: 8)); - list.add(buildChip( - 'dev', - bgColor: Colors.blue.shade300, - )); - } - return list; - }(), - ), - subtitle: ResponsiveBuilder.isNarrow(context) - ? Text( - extension.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - : Text(extension.description), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - extension.homepage.isNotEmpty == true - ? IconButton( - onPressed: () { - launchUrl( - Uri.parse(extension.homepage)); - }, - icon: const Icon(Icons.home)) - : null, - extension.repository?.url.isNotEmpty == true - ? IconButton( - onPressed: () { - launchUrl(Uri.parse( - extension.repository!.url)); - }, - icon: const Icon(Icons.code)) - : null, - extension.settings?.isNotEmpty == true - ? IconButton( - onPressed: () { - _showSettingDialog(extension); - }, - icon: const Icon(Icons.settings)) - : null, - IconButton( - onPressed: () { - _showDeleteDialog(extension); - }, - icon: const Icon(Icons.delete)), - Obx(() => controller.updateFlags - .containsKey(extension.identity) - ? badges.Badge( - position: - badges.BadgePosition.topStart( - start: 36), - child: IconButton( - onPressed: () { - _showUpdateDialog(extension); - }, - icon: const Icon(Icons.refresh))) - : IconButton( - onPressed: () { - showMessage('tip'.tr, - 'extensionAlreadyLatest'.tr); - }, - icon: const Icon(Icons.refresh))), - ] - .where((e) => e != null) - .map((e) => e!) - .toList(), - ).paddingOnly(right: 16) - ], - ), - ), - ).paddingOnly(top: 8); - }, - ))), - ], - ).paddingAll(32), + if (controller.listFilter.value == ExtensionListFilter.market && + controller.storePagination.value != null && + controller.storeExtensions.isNotEmpty && + controller.storePagination.value!.hasNext == false) ...[ + const SizedBox(height: 12), + Center( + child: Text( + 'extensionNoMore'.tr, + style: Get.textTheme.bodySmall + ?.copyWith(color: Get.theme.hintColor), + ), + ), + ], + ], + ), + ), + ), + ), ); } - Widget buildChip(String text, {Color? bgColor}) { - return Chip( - padding: const EdgeInsets.all(0), - backgroundColor: bgColor, - label: Text(text, style: const TextStyle(fontSize: 12)), + Widget _buildInstallPanel(BuildContext context) { + final isNarrow = ResponsiveBuilder.isNarrow(context); + + return isNarrow + ? Column( + children: [ + TextField( + controller: _installUrlController, + decoration: InputDecoration( + isDense: true, + labelText: 'extensionInstallUrl'.tr, + hintText: 'https://github.com/author/repo', + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButtonLoading( + controller: _installBtnController, + onPressed: _doInstall, + icon: const Icon(Icons.download), + ), + if (controller.devMode.value && Util.isDesktop()) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'extensionLoadLocal'.tr, + onPressed: _installFromFolder, + icon: const Icon(Icons.folder_open), + ), + ] + ], + ), + ], + ) + : Row( + children: [ + Expanded( + child: TextField( + controller: _installUrlController, + decoration: InputDecoration( + isDense: true, + labelText: 'extensionInstallUrl'.tr, + hintText: 'https://github.com/author/repo', + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + IconButtonLoading( + controller: _installBtnController, + onPressed: _doInstall, + icon: const Icon(Icons.download), + ), + if (controller.devMode.value && Util.isDesktop()) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'extensionLoadLocal'.tr, + onPressed: _installFromFolder, + icon: const Icon(Icons.folder_open), + ), + ], + ], + ); + } + + Widget _buildMarketToolbar(BuildContext context) { + final narrow = ResponsiveBuilder.isNarrow(context); + InputDecoration decoration() { + return const InputDecoration( + hintText: '搜索扩展...', + hintStyle: TextStyle(fontSize: 13), + ); + } + + return narrow + ? Column( + children: [ + TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + onSubmitted: controller.searchStore, + decoration: decoration().copyWith( + suffixIcon: IconButton( + onPressed: () => + controller.searchStore(_searchController.text), + icon: const Icon(Icons.search_rounded), + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible(child: _buildSortTabs(context)), + const SizedBox(width: 6), + IconButton( + tooltip: 'update'.tr, + onPressed: controller.refreshStore, + icon: const Icon(Icons.refresh), + ), + ], + ), + ], + ) + : Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + onSubmitted: controller.searchStore, + decoration: decoration().copyWith( + suffixIcon: IconButton( + onPressed: () => + controller.searchStore(_searchController.text), + icon: const Icon(Icons.search_rounded), + ), + ), + ), + ), + const SizedBox(width: 8), + _buildSortTabs(context), + IconButton( + tooltip: 'update'.tr, + onPressed: controller.refreshStore, + icon: const Icon(Icons.refresh), + ), + ], + ), + ], + ); + } + + Widget _buildSortTabs(BuildContext context) { + Widget tab(StoreExtensionSort sort, String label) { + final selected = controller.storeSort.value == sort; + return InkWell( + borderRadius: BorderRadius.circular(7), + onTap: () => controller.changeSort(sort), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + color: selected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + fontSize: 12, + height: 1.0, + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withValues(alpha: 0.82), + ), + ), + ), + ); + } + + return Container( + constraints: const BoxConstraints(maxWidth: 310), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.35), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.45), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + tab(StoreExtensionSort.stars, 'extensionSortStars'.tr), + tab(StoreExtensionSort.installs, 'extensionSortInstalls'.tr), + tab(StoreExtensionSort.updated, 'extensionSortUpdated'.tr), + ], + ), + ); + } + + Widget _buildFilterBar(BuildContext context) { + final narrow = ResponsiveBuilder.isNarrow(context); + Widget option(ExtensionListFilter filter, String text) { + final selected = controller.listFilter.value == filter; + return InkWell( + onTap: () => controller.changeFilter(filter), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: selected + ? Get.theme.colorScheme.primary.withValues(alpha: 0.14) + : Colors.transparent, + ), + child: Text( + text, + style: TextStyle( + color: selected ? Get.theme.colorScheme.primary : null, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ); + } + + final left = Row( + mainAxisSize: MainAxisSize.min, + children: [ + option(ExtensionListFilter.market, 'extensionFilterMarket'.tr), + const SizedBox(width: 8), + option(ExtensionListFilter.installed, 'extensionFilterInstalled'.tr), + ], + ); + + final right = Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: controller.toggleInstallTools, + icon: const Icon(Icons.link), + label: Text('extensionManualInstall'.tr), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => + launchUrl(Uri.parse('https://gopeed.com/docs/dev-extension')), + icon: const Icon(Icons.menu_book_outlined), + label: Text('extensionDevelop'.tr), + ), + ], + ); + + if (narrow) { + return Wrap( + spacing: 8, + runSpacing: 6, + alignment: WrapAlignment.spaceBetween, + children: [left, right], + ); + } + + return Row( + children: [ + left, + const Spacer(), + right, + ], + ); + } + + Widget _buildUnifiedGrid(BuildContext context) { + final items = controller.displayItems; + final loading = + controller.loadingInstalled.value || controller.loadingStore.value; + if (loading && items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (items.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text('extensionStoreEmpty'.tr), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final crossAxisCount = width >= 1320 + ? 4 + : width >= 980 + ? 3 + : width >= 700 + ? 2 + : 1; + const cardHeight = 180.0; + final childAspectRatio = (width / crossAxisCount) / cardHeight; + + return GridView.builder( + itemCount: items.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: childAspectRatio, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemBuilder: (context, index) => _buildUnifiedCard(items[index]), + ); + }, + ); + } + + Widget _buildUnifiedCard(ExtensionListItem item) { + final installed = item.installed; + final store = item.store; + final canUpdate = controller.canUpdateItem(item); + final busy = controller.busyExtensionIds.contains(item.id); + + return ExtensionCard( + item: item, + busy: busy, + canUpdate: canUpdate, + onTap: store != null ? () => _showExtensionDrawer(item) : null, + onToggle: installed == null + ? null + : (value) async { + try { + await controller.toggleExtension(installed, value); + } catch (e) { + showErrorMessage(e); + } + }, + onOpenSetting: installed != null && installed.settings?.isNotEmpty == true + ? () => _showSettingDialog(installed) + : null, + onUpdate: installed != null && canUpdate + ? () async { + try { + await controller.upgradeExtension(installed); + showMessage('tip'.tr, 'extensionUpdateSuccess'.tr); + } catch (e) { + showErrorMessage(e); + } + } + : null, + onDelete: installed != null ? () => _showDeleteDialog(installed) : null, + onInstall: !item.isInstalled && store != null + ? () async { + try { + await controller.installFromStore(store); + showMessage('tip'.tr, 'extensionInstallSuccess'.tr); + } catch (e) { + showErrorMessage(e); + } + } + : null, + ); + } + + Future _showExtensionDrawer(ExtensionListItem item) async { + final store = item.store; + if (store == null) return; + await showGeneralDialog( + context: Get.context!, + barrierDismissible: true, + barrierLabel: 'close', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (context, animation, secondaryAnimation) { + return Align( + alignment: Alignment.centerRight, + child: Material( + child: SizedBox( + width: (MediaQuery.of(context).size.width * 0.5) + .clamp(320.0, 1200.0), + child: ExtensionDetailDrawer( + extension: store, + installed: item.installed, + onClose: () => Navigator.of(context).pop(), + ), + ), + ), + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) { + final offset = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(animation); + return SlideTransition(position: offset, child: child); + }, ); } @@ -288,107 +526,104 @@ class ExtensionView extends GetView { final confrimController = RoundedLoadingButtonController(); return showDialog( - context: Get.context!, - barrierDismissible: false, - builder: (dialogContext) => AlertDialog( - content: Builder(builder: (context) { - final height = MediaQuery.of(context).size.height; - final width = MediaQuery.of(context).size.width; + context: Get.context!, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + content: Builder(builder: (context) { + final height = MediaQuery.of(context).size.height; + final width = MediaQuery.of(context).size.width; - return SizedBox( - height: height * 0.75, - width: width, - child: FormBuilder( - key: formKey, - // autovalidateMode: AutovalidateMode.always, - child: Column(children: [ - Text('setting'.tr), - Expanded( - child: SingleChildScrollView( - child: Column( - children: extension.settings!.map((e) { - final settingItem = _buildSettingItem(e); + return SizedBox( + height: height * 0.75, + width: width, + child: FormBuilder( + key: formKey, + child: Column(children: [ + Text('setting'.tr), + Expanded( + child: SingleChildScrollView( + child: Column( + children: extension.settings!.map((e) { + final settingItem = _buildSettingItem(e); - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: 20, - child: e.description.isEmpty - ? null - : Tooltip( - message: e.description, - child: const CircleAvatar( - radius: 10, - backgroundColor: - Colors.grey, - child: Icon( - Icons.question_mark, - size: 10, - )), - )) - .paddingOnly(right: 10), - Expanded(child: settingItem), - ], - ); - }).toList()), - ), - ), - ]), + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: 20, + child: e.description.isEmpty + ? null + : Tooltip( + message: e.description, + child: const CircleAvatar( + radius: 10, + backgroundColor: Colors.grey, + child: + Icon(Icons.question_mark, size: 10), + ), + ), + ).paddingOnly(right: 10), + Expanded(child: settingItem), + ], + ); + }).toList(), + ), ), - ); - }), - actions: [ - ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: Get.theme.buttonTheme.minWidth, - height: Get.theme.buttonTheme.height, - ), - child: ElevatedButton( - style: - ElevatedButton.styleFrom(shape: const StadiumBorder()) - .copyWith( - backgroundColor: MaterialStateProperty.all( - Get.theme.colorScheme.background)), - onPressed: () { + ), + ]), + ), + ); + }), + actions: [ + ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: Get.theme.buttonTheme.minWidth, + height: Get.theme.buttonTheme.height, + ), + child: ElevatedButton( + style: ElevatedButton.styleFrom(shape: const StadiumBorder()) + .copyWith( + backgroundColor: + WidgetStateProperty.all(Get.theme.colorScheme.surface), + ), + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('cancel'.tr), + ), + ), + ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: Get.theme.buttonTheme.minWidth, + height: Get.theme.buttonTheme.height, + ), + child: RoundedLoadingButton( + color: Get.theme.colorScheme.secondary, + onPressed: () async { + try { + confrimController.start(); + if (formKey.currentState?.saveAndValidate() == true) { + await updateExtensionSettings( + extension.identity, + UpdateExtensionSettings( + settings: formKey.currentState!.value), + ); + await controller.loadInstalled(refreshUpdates: false); + if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); - }, - child: Text('cancel'.tr), - ), - ), - ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: Get.theme.buttonTheme.minWidth, - height: Get.theme.buttonTheme.height, - ), - child: RoundedLoadingButton( - color: Get.theme.colorScheme.secondary, - onPressed: () async { - try { - confrimController.start(); - if (formKey.currentState?.saveAndValidate() == true) { - await updateExtensionSettings( - extension.identity, - UpdateExtensionSettings( - settings: formKey.currentState!.value)); - await controller.load(); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - } - } catch (e) { - showErrorMessage(e); - } finally { - confrimController.reset(); - } - }, - controller: confrimController, - child: Text( - 'confirm'.tr, - )), - ), - ], - )); + } + } + } catch (e) { + showErrorMessage(e); + } finally { + confrimController.reset(); + } + }, + controller: confrimController, + child: Text('confirm'.tr), + ), + ), + ], + ), + ); } Widget _buildSettingItem(Setting setting) { @@ -413,9 +648,7 @@ class ExtensionView extends GetView { Widget buildDropdown() { return FormBuilderDropdown( name: setting.name, - decoration: InputDecoration( - labelText: setting.title, - ), + decoration: InputDecoration(labelText: setting.title), initialValue: setting.value?.toString(), validator: FormBuilderValidators.compose([ requiredValidator, @@ -440,7 +673,8 @@ class ExtensionView extends GetView { : buildTextField( FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d*')), FormBuilderValidators.numeric(), - const TextInputType.numberWithOptions(decimal: true)); + const TextInputType.numberWithOptions(decimal: true), + ); case SettingType.boolean: return FormBuilderSwitch( name: setting.name, @@ -453,73 +687,33 @@ class ExtensionView extends GetView { void _showDeleteDialog(Extension extension) { showDialog( - context: Get.context!, - barrierDismissible: false, - builder: (dialogContext) => AlertDialog( - title: Text('extensionDelete'.tr), - actions: [ - TextButton( - child: Text('cancel'.tr), - onPressed: () => Navigator.of(dialogContext).pop(), - ), - TextButton( - child: Text( - 'confirm'.tr, - style: const TextStyle(color: Colors.redAccent), - ), - onPressed: () async { - try { - await deleteExtension(extension.identity); - await controller.load(); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - } catch (e) { - showErrorMessage(e); - } - }, - ), - ], - )); - } - - void _showUpdateDialog(Extension extension) { - final confrimController = TextButtonLoadingController(); - - showDialog( - context: Get.context!, - builder: (dialogContext) => AlertDialog( - content: Text('newVersionTitle'.trParams({ - 'version': 'v${controller.updateFlags[extension.identity]!}' - })), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - }, - child: Text('newVersionLater'.tr), - ), - TextButtonLoading( - controller: confrimController, - onPressed: () async { - confrimController.start(); - try { - await updateExtension(extension.identity); - await controller.load(); - controller.updateFlags.remove(extension.identity); - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - showMessage('tip'.tr, 'extensionUpdateSuccess'.tr); - } catch (e) { - showErrorMessage(e); - } finally { - confrimController.stop(); - } - }, - child: Text('newVersionUpdate'.tr), - ), - ], - )); + context: Get.context!, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text('extensionDelete'.tr), + actions: [ + TextButton( + child: Text('cancel'.tr), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + TextButton( + child: Text( + 'confirm'.tr, + style: const TextStyle(color: Colors.redAccent), + ), + onPressed: () async { + try { + await controller.removeExtension(extension); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + showErrorMessage(e); + } + }, + ), + ], + ), + ); } } diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 8d2c30f2..dd99bd5d 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -107,6 +107,27 @@ const enUS = { 'extensionAlreadyLatest': 'It\'s already the latest version', 'extensionFind': 'Find Extensions', 'extensionDevelop': 'Develop Extensions', + 'extensionStoreTitle': 'Extension Store', + 'extensionStoreSection': 'Store Extensions', + 'extensionInstalledSection': 'Installed Extensions', + 'extensionInstalledEmpty': 'No extensions installed yet', + 'extensionStoreEmpty': 'No matching extensions found', + 'extensionSearchHint': 'Search by name, title, description or author', + 'extensionSortStars': 'Most Stars', + 'extensionSortInstalls': 'Most Installs', + 'extensionSortUpdated': 'Recently Updated', + 'extensionSortToggle': 'Toggle sort direction', + 'extensionLoadMore': 'Load More', + 'extensionNoMore': 'No more extensions', + 'extensionInstalled': 'Installed', + 'extensionCanUpdate': 'Update Available', + 'extensionInstall': 'Install', + 'extensionLoadLocal': 'Install Local Folder', + 'extensionFilterAll': 'All', + 'extensionFilterMarket': 'Market', + 'extensionFilterInstalled': 'Installed', + 'extensionManualInstall': 'Manual Install', + 'extensionInstallTools': 'Install Options', 'history': 'History', 'clearHistory': 'Clear History', 'noHistoryFound': 'No History Found', diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index 97b0182f..e26b0c94 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -105,6 +105,27 @@ const zhCN = { 'extensionAlreadyLatest': '已经是最新版本', 'extensionFind': '获取扩展', 'extensionDevelop': '开发扩展', + 'extensionStoreTitle': '扩展商店', + 'extensionStoreSection': '在线扩展', + 'extensionInstalledSection': '已安装扩展', + 'extensionInstalledEmpty': '暂未安装扩展', + 'extensionStoreEmpty': '没有匹配的扩展', + 'extensionSearchHint': '按名称、标题、描述或作者搜索', + 'extensionSortStars': '最多 Star', + 'extensionSortInstalls': '最多安装', + 'extensionSortUpdated': '最近更新', + 'extensionSortToggle': '切换排序方向', + 'extensionLoadMore': '加载更多', + 'extensionNoMore': '已经到底了', + 'extensionInstalled': '已安装', + 'extensionCanUpdate': '可更新', + 'extensionInstall': '安装', + 'extensionLoadLocal': '安装本地目录', + 'extensionFilterAll': '全部', + 'extensionFilterMarket': '市场', + 'extensionFilterInstalled': '已安装', + 'extensionManualInstall': '手动安装', + 'extensionInstallTools': '安装方式', 'history': '历史记录', 'clearHistory': '清空历史记录', 'noHistoryFound': '暂无历史记录', diff --git a/ui/flutter/lib/util/updater.dart b/ui/flutter/lib/util/updater.dart index b0d0c674..514e3ff3 100644 --- a/ui/flutter/lib/util/updater.dart +++ b/ui/flutter/lib/util/updater.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../api/api.dart'; +import '../api/gopeed_site_api.dart'; import '../app/views/outlined_button_loading.dart'; import 'arch/arch.dart'; import 'github_mirror.dart'; @@ -70,8 +71,7 @@ Future checkUpdate() async { "https://api.github.com/repos/GopeedLab/gopeed/releases/latest")) .data; } catch (e) { - releaseDataStr = - (await proxyRequest("https://gopeed.com/api/release")).data; + releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease()); } if (releaseDataStr == null) { return null; diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index cfb257d3..b30dfe0e 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checkable_treeview: dependency: "direct main" description: @@ -451,6 +451,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown_plus: + dependency: "direct main" + description: + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" + url: "https://pub.dev" + source: hosted + version: "1.0.7" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -726,22 +734,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" menu_base: dependency: transitive description: @@ -1248,10 +1264,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timezone: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index bb59c80f..d2b32dfc 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: url: https://github.com/hui-z/flutter_install_plugin.git ref: cf08af829f4a4145634f8a047108f505fdbe5eaa flutter_svg: ^2.0.17 + flutter_markdown_plus: ^1.0.3 dart_ipc: ^1.0.1 flutter_local_notifications: 20.1.0 dependency_overrides: