mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 19:33:41 +00:00
fix: extension install by deeplink (#1510)
This commit is contained in:
36 files changed
+869
-143
No files matched your search
@@ -26,6 +26,7 @@ import '../../application/pending_extension_install.dart';
|
||||
import '../widgets/extension_detail_view.dart';
|
||||
import '../widgets/extension_icon.dart';
|
||||
import '../widgets/extension_setting_field.dart';
|
||||
import '../widgets/extension_update_dialog.dart';
|
||||
|
||||
const _extensionCardMinWidth = 290.0;
|
||||
const _extensionGridSpacing = 10.0;
|
||||
@@ -47,6 +48,9 @@ class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _installController = TextEditingController();
|
||||
final _installAnchorKey = GlobalKey();
|
||||
final _installPopoverKey = GlobalKey<_InstallPopoverState>();
|
||||
bool _installDevMode = false;
|
||||
final _listScrollController = ScrollController();
|
||||
final Map<String, TextEditingController> _settingControllers = {};
|
||||
ExtensionListItem? _detailItem;
|
||||
@@ -78,15 +82,28 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
||||
ref.listen(pendingExtensionInstallProvider, (previous, next) {
|
||||
if (next == null) return;
|
||||
ref.read(pendingExtensionInstallProvider.notifier).clear();
|
||||
unawaited(
|
||||
_runAction(
|
||||
() => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode),
|
||||
),
|
||||
);
|
||||
});
|
||||
final pendingInstall = ref.watch(pendingExtensionInstallProvider);
|
||||
if (pendingInstall != null) {
|
||||
// The request can arrive before this page is mounted. Consume it only
|
||||
// after the toolbar is laid out, so cold and warm links share the same UI.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return;
|
||||
if (_installPopoverKey.currentState?._installing == true) return;
|
||||
final anchorContext = _installAnchorKey.currentContext;
|
||||
if (anchorContext == null) return;
|
||||
_installController.text = pendingInstall.url;
|
||||
_installDevMode = pendingInstall.devMode;
|
||||
_showInstallPopover(anchorContext);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return;
|
||||
final popover = _installPopoverKey.currentState;
|
||||
if (popover == null || popover._installing) return;
|
||||
ref.read(pendingExtensionInstallProvider.notifier).clear();
|
||||
unawaited(popover._install());
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
});
|
||||
}
|
||||
final stateAsync = ref.watch(extensionsControllerProvider);
|
||||
final body = Stack(
|
||||
children: [
|
||||
@@ -154,6 +171,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)),
|
||||
onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)),
|
||||
onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter,
|
||||
installAnchorKey: _installAnchorKey,
|
||||
onOpenInstall: _openInstallPopover,
|
||||
onDevelopExtension: _openExtensionDevelopmentDocs,
|
||||
onInstallFolder: _installFromFolder,
|
||||
@@ -169,7 +187,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
Future<void> _installFromUrl() async {
|
||||
final url = _installController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url));
|
||||
await _runAction(
|
||||
() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url, devInstall: _installDevMode),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadNextPageIfNeeded() async {
|
||||
@@ -192,7 +212,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
}
|
||||
|
||||
void _openInstallPopover(BuildContext anchorContext) {
|
||||
ref.read(extensionsControllerProvider.notifier).tryOpenDevMode();
|
||||
if (AppWindowChrome.isDesktopWindow) {
|
||||
ref.read(extensionsControllerProvider.notifier).tryOpenDevMode();
|
||||
}
|
||||
_installDevMode = false;
|
||||
_showInstallPopover(anchorContext);
|
||||
}
|
||||
|
||||
void _showInstallPopover(BuildContext anchorContext) {
|
||||
if (_installPopover != null) return;
|
||||
final overlay = const shad.PopoverOverlayHandler().show<void>(
|
||||
context: anchorContext,
|
||||
@@ -201,7 +228,17 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
offset: const Offset(0, 8),
|
||||
modal: false,
|
||||
consumeOutsideTaps: false,
|
||||
builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl),
|
||||
// Flutter's native selection toolbar uses the EditableText tap group.
|
||||
// Pasting from it must not dismiss the form and restore search focus.
|
||||
regionGroupId: EditableText,
|
||||
builder: (context) => _InstallPopover(
|
||||
key: _installPopoverKey,
|
||||
controller: _installController,
|
||||
onInstallUrl: _installFromUrl,
|
||||
onFinished: () {
|
||||
if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
_installPopover = overlay;
|
||||
unawaited(
|
||||
@@ -284,6 +321,7 @@ class _Content extends StatelessWidget {
|
||||
required this.onSearch,
|
||||
required this.onSort,
|
||||
required this.onFilter,
|
||||
required this.installAnchorKey,
|
||||
required this.onOpenInstall,
|
||||
required this.onDevelopExtension,
|
||||
required this.onInstallFolder,
|
||||
@@ -301,6 +339,7 @@ class _Content extends StatelessWidget {
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<StoreExtensionSort> onSort;
|
||||
final ValueChanged<ExtensionListFilter> onFilter;
|
||||
final GlobalKey installAnchorKey;
|
||||
final ValueChanged<BuildContext> onOpenInstall;
|
||||
final VoidCallback onDevelopExtension;
|
||||
final VoidCallback onInstallFolder;
|
||||
@@ -322,6 +361,7 @@ class _Content extends StatelessWidget {
|
||||
onSearch: onSearch,
|
||||
onSort: onSort,
|
||||
onRefresh: onRefresh,
|
||||
installAnchorKey: installAnchorKey,
|
||||
onOpenInstall: onOpenInstall,
|
||||
onDevelopExtension: onDevelopExtension,
|
||||
onInstallFolder: onInstallFolder,
|
||||
@@ -614,6 +654,7 @@ class _Toolbar extends StatelessWidget {
|
||||
required this.onSearch,
|
||||
required this.onSort,
|
||||
required this.onRefresh,
|
||||
required this.installAnchorKey,
|
||||
required this.onOpenInstall,
|
||||
required this.onDevelopExtension,
|
||||
required this.onInstallFolder,
|
||||
@@ -624,6 +665,7 @@ class _Toolbar extends StatelessWidget {
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<StoreExtensionSort> onSort;
|
||||
final VoidCallback onRefresh;
|
||||
final GlobalKey installAnchorKey;
|
||||
final ValueChanged<BuildContext> onOpenInstall;
|
||||
final VoidCallback onDevelopExtension;
|
||||
final VoidCallback onInstallFolder;
|
||||
@@ -631,7 +673,6 @@ class _Toolbar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey);
|
||||
final search = AppTextField(
|
||||
key: const ValueKey('extension-search-input'),
|
||||
controller: searchController,
|
||||
@@ -673,22 +714,21 @@ class _Toolbar extends StatelessWidget {
|
||||
onPressed: onDevelopExtension,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (state.devMode) ...[
|
||||
if (AppWindowChrome.isDesktopWindow && state.devMode) ...[
|
||||
_OutlineToolbarIconButton(
|
||||
key: const ValueKey('load-local-extension-button'),
|
||||
tooltip: context.l10n.extensionLoadLocal,
|
||||
icon: Icons.folder_open_outlined,
|
||||
loading: manualInstallBusy,
|
||||
onPressed: onInstallFolder,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Builder(
|
||||
key: installAnchorKey,
|
||||
builder: (buttonContext) => _OutlineToolbarIconButton(
|
||||
key: const ValueKey('install-extension-button'),
|
||||
tooltip: context.l10n.extensionInstallFromUrl,
|
||||
icon: Icons.add_link,
|
||||
loading: manualInstallBusy,
|
||||
onPressed: () => onOpenInstall(buttonContext),
|
||||
),
|
||||
),
|
||||
@@ -764,18 +804,11 @@ class _FilterBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _OutlineToolbarIconButton extends StatelessWidget {
|
||||
const _OutlineToolbarIconButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.loading = false,
|
||||
});
|
||||
const _OutlineToolbarIconButton({super.key, required this.tooltip, required this.icon, required this.onPressed});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -783,23 +816,18 @@ class _OutlineToolbarIconButton extends StatelessWidget {
|
||||
message: tooltip,
|
||||
child: SizedBox.square(
|
||||
dimension: 32,
|
||||
child: shad.IconButton.outline(
|
||||
size: shad.ButtonSize.xSmall,
|
||||
onPressed: loading ? null : onPressed,
|
||||
icon: loading
|
||||
? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator())
|
||||
: Icon(icon, size: 17),
|
||||
),
|
||||
child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 17)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallPopover extends StatefulWidget {
|
||||
const _InstallPopover({required this.controller, required this.onInstallUrl});
|
||||
const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished});
|
||||
|
||||
final TextEditingController controller;
|
||||
final Future<void> Function() onInstallUrl;
|
||||
final VoidCallback onFinished;
|
||||
|
||||
@override
|
||||
State<_InstallPopover> createState() => _InstallPopoverState();
|
||||
@@ -861,8 +889,8 @@ class _InstallPopoverState extends State<_InstallPopover> {
|
||||
setState(() => _installing = true);
|
||||
await widget.onInstallUrl();
|
||||
if (!mounted) return;
|
||||
setState(() => _installing = false);
|
||||
await shad.closeOverlay(context);
|
||||
widget.onFinished();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,11 +1063,8 @@ class _ExtensionCard extends ConsumerWidget {
|
||||
if (canUpdate && installed != null)
|
||||
shad.GhostButton(
|
||||
density: shad.ButtonDensity.icon,
|
||||
onPressed: busy
|
||||
? null
|
||||
: () => onAction(
|
||||
() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed),
|
||||
),
|
||||
key: ValueKey('update-extension-${installed.identity}'),
|
||||
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
||||
child: const Icon(Icons.refresh),
|
||||
),
|
||||
if (installed == null && item.store != null)
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../application/extensions_controller.dart';
|
||||
import 'extension_icon.dart';
|
||||
import 'extension_update_dialog.dart';
|
||||
|
||||
class ExtensionDetailDrawer extends StatelessWidget {
|
||||
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
|
||||
@@ -132,14 +133,10 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
if (installed != null && canUpdate)
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('extension-details-update'),
|
||||
loading: busy,
|
||||
loading: false,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
onPressed: () => _runAction(
|
||||
context,
|
||||
() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed),
|
||||
successMessage: context.l10n.extensionUpdateSuccess,
|
||||
),
|
||||
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
||||
child: Text(context.l10n.newVersionUpdate),
|
||||
),
|
||||
if (installed != null && !canUpdate)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../api/model/extension.dart' as api;
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../application/extensions_controller.dart';
|
||||
|
||||
Future<void> showExtensionUpdateDialog(BuildContext context, api.Extension extension) async {
|
||||
final overlay = const DialogOverlayHandler().show<bool>(
|
||||
context: context,
|
||||
alignment: Alignment.center,
|
||||
barrierDismissable: false,
|
||||
builder: (_) => _ExtensionUpdateDialog(extension: extension),
|
||||
);
|
||||
final updated = await overlay.future;
|
||||
if (updated == true && context.mounted) {
|
||||
showAppToast(context, context.l10n.extensionUpdateSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionUpdateDialog extends ConsumerStatefulWidget {
|
||||
const _ExtensionUpdateDialog({required this.extension});
|
||||
|
||||
final api.Extension extension;
|
||||
|
||||
@override
|
||||
ConsumerState<_ExtensionUpdateDialog> createState() => _ExtensionUpdateDialogState();
|
||||
}
|
||||
|
||||
class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog> {
|
||||
bool _updating = false;
|
||||
bool _failed = false;
|
||||
|
||||
Future<void> _update() async {
|
||||
if (_updating) return;
|
||||
setState(() {
|
||||
_updating = true;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
await ref.read(extensionsControllerProvider.notifier).upgradeExtension(widget.extension);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_updating = false;
|
||||
_failed = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mounted) await closeOverlay(context, true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_updating,
|
||||
child: AlertDialog(
|
||||
key: const ValueKey('extension-update-dialog'),
|
||||
title: Text(context.l10n.extensionCanUpdate),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.extensionUpdateConfirm(widget.extension.title)),
|
||||
if (_failed) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(context.l10n.updateFailed, key: const ValueKey('extension-update-error')),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
SecondaryButton(
|
||||
key: const ValueKey('cancel-update-extension-button'),
|
||||
onPressed: _updating ? null : () => closeOverlay(context, false),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('confirm-update-extension-button'),
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
loading: _updating,
|
||||
onPressed: _update,
|
||||
child: Text(context.l10n.newVersionUpdate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user