feat: confirm extension updates with progress and retry

This commit is contained in:
Levi committed 2026-09-13 17:25:31 +08:00
1 parent 4802802828
commit 2e8672273c
26 files changed
+209 -10

No files matched your search

@@ -24,6 +24,7 @@ import '../../application/extensions_controller.dart';
import '../../application/pending_extension_install.dart';
import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart';
import '../widgets/extension_update_dialog.dart';
const _extensionCardMinWidth = 280.0;
const _extensionGridSpacing = 10.0;
@@ -1034,10 +1035,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)
@@ -19,6 +19,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});
@@ -131,14 +132,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),
),
],
),
);
}
}
+1
View File
@@ -103,6 +103,7 @@
"extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت",
"extensionInstallSuccess": "تم التثبيت بنجاح",
"extensionUpdateConfirm": "هل تريد تحديث «{name}» إلى أحدث إصدار؟",
"extensionUpdateSuccess": "تم التحديث بنجاح",
"extensionDelete": "حذف الإضافة",
"extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل",
+1
View File
@@ -110,6 +110,7 @@
"extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i",
"extensionInstallSuccess": "Uğurla quraşdırıldı",
"extensionUpdateConfirm": "“{name}” ən son versiyaya yenilənsin?",
"extensionUpdateSuccess": "Uğurla yeniləndi",
"extensionDelete": "Uzantını Sil",
"extensionAlreadyLatest": "Artıq son versiyadır",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació",
"extensionInstallSuccess": "S'ha instal·lat amb èxit",
"extensionUpdateConfirm": "Vols actualitzar «{name}» a la versió més recent?",
"extensionUpdateSuccess": "S'ha actualitzat amb èxit",
"extensionDelete": "Suprimeix l'extensió",
"extensionAlreadyLatest": "Ja està actualitzada",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL",
"extensionInstallSuccess": "Erfolgreich installiert",
"extensionUpdateConfirm": "„{name}“ auf die neueste Version aktualisieren?",
"extensionUpdateSuccess": "Erfolgreich aktualisiert",
"extensionDelete": "Erweiterung löschen",
"extensionAlreadyLatest": "Es ist bereits die neueste Version",
+8
View File
@@ -110,6 +110,14 @@
"extensions": "Extensions",
"extensionInstallUrl": "Install URL",
"extensionInstallSuccess": "Installed successfully",
"extensionUpdateConfirm": "Update “{name}” to the latest version?",
"@extensionUpdateConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"extensionUpdateSuccess": "Updated successfully",
"extensionDelete": "Delete Extension",
"extensionAlreadyLatest": "It's already the latest version",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación",
"extensionInstallSuccess": "Instalado con éxito",
"extensionUpdateConfirm": "¿Actualizar «{name}» a la última versión?",
"extensionUpdateSuccess": "Actualizado con éxito",
"extensionDelete": "Eliminar Extensión",
"extensionAlreadyLatest": "Ya es la versión más reciente",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب",
"extensionInstallSuccess": "نصب شده با موفقیت",
"extensionUpdateConfirm": "«{name}» به آخرین نسخه به‌روزرسانی شود؟",
"extensionUpdateSuccess": "به روز رسانی موفقیت آمیز",
"extensionDelete": "حذف افزونه",
"extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensions",
"extensionInstallUrl": "URL d'installation",
"extensionInstallSuccess": "Installé avec succès",
"extensionUpdateConfirm": "Mettre à jour « {name} » vers la dernière version ?",
"extensionUpdateSuccess": "Mis à jour avec succès",
"extensionDelete": "Supprimer l'extension",
"extensionAlreadyLatest": "C'est déjà la dernière version",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL",
"extensionInstallSuccess": "Sikeresen telepítve",
"extensionUpdateConfirm": "Frissíti a(z) „{name}” bővítményt a legújabb verzióra?",
"extensionUpdateSuccess": "Sikeresen frissítve",
"extensionDelete": "Bővítmény törlése",
"extensionAlreadyLatest": "Ez már a legfrissebb verzió",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal",
"extensionInstallSuccess": "Diinstal dengan sukses",
"extensionUpdateConfirm": "Perbarui “{name}” ke versi terbaru?",
"extensionUpdateSuccess": "Diperbaruhi dengan sukses",
"extensionDelete": "Hapus Ekstensi",
"extensionAlreadyLatest": "Ini sudah versi terbaru",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Estensioni",
"extensionInstallUrl": "Installa URL",
"extensionInstallSuccess": "Installato con successo",
"extensionUpdateConfirm": "Aggiornare «{name}» alla versione più recente?",
"extensionUpdateSuccess": "Aggiornato con successo",
"extensionDelete": "Elimina estensione",
"extensionAlreadyLatest": "È già l'ultima versione",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "拡張機能",
"extensionInstallUrl": "インストールURL",
"extensionInstallSuccess": "正常にインストールされました",
"extensionUpdateConfirm": "「{name}」を最新バージョンに更新しますか?",
"extensionUpdateSuccess": "正常に更新されました",
"extensionDelete": "拡張機能を削除",
"extensionAlreadyLatest": "既に最新バージョンです",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL",
"extensionInstallSuccess": "설치되었습니다",
"extensionUpdateConfirm": "“{name}”을(를) 최신 버전으로 업데이트할까요?",
"extensionUpdateSuccess": "업데이트되었습니다",
"extensionDelete": "확장 프로그램 삭제",
"extensionAlreadyLatest": "이미 최신 버전입니다",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url",
"extensionInstallSuccess": "Instalacja zakończona",
"extensionUpdateConfirm": "Zaktualizować „{name}” do najnowszej wersji?",
"extensionUpdateSuccess": "Aktualizacja zakończona",
"extensionDelete": "Usuń wtyczkę",
"extensionAlreadyLatest": "Wtyczka w aktualnej wersji",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação",
"extensionInstallSuccess": "Instalado com Sucesso",
"extensionUpdateConfirm": "Atualizar “{name}” para a versão mais recente?",
"extensionUpdateSuccess": "Atualizado com Sucesso",
"extensionDelete": "Excluir Extensão",
"extensionAlreadyLatest": "Já é a última versão",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Расширения",
"extensionInstallUrl": "URL для установки",
"extensionInstallSuccess": "Установка завершена",
"extensionUpdateConfirm": "Обновить «{name}» до последней версии?",
"extensionUpdateSuccess": "Обновление завершено",
"extensionDelete": "Удалить расширение",
"extensionAlreadyLatest": "Это последняя версия",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL",
"extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது",
"extensionUpdateConfirm": "“{name}” ஐ சமீபத்திய பதிப்பிற்குப் புதுப்பிக்கவா?",
"extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது",
"extensionDelete": "நீட்டிப்பை நீக்கு",
"extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si",
"extensionInstallSuccess": "Başarıyla kuruldu",
"extensionUpdateConfirm": "“{name}” en son sürüme güncellensin mi?",
"extensionUpdateSuccess": "Başarıyla güncellendi",
"extensionDelete": "Eklentiyi Sil",
"extensionAlreadyLatest": "Zaten en son sürüm",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Розширення",
"extensionInstallUrl": "URL для встановлення",
"extensionInstallSuccess": "Встановлено успішно",
"extensionUpdateConfirm": "Оновити «{name}» до останньої версії?",
"extensionUpdateSuccess": "Оновлено успішно",
"extensionDelete": "Видалити розширення",
"extensionAlreadyLatest": "Вже остання версія",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "Tiện ích mở rộng",
"extensionInstallUrl": "Liên kết cài đặt",
"extensionInstallSuccess": "Cài đặt thành công",
"extensionUpdateConfirm": "Cập nhật “{name}” lên phiên bản mới nhất?",
"extensionUpdateSuccess": "Cập nhật thành công",
"extensionDelete": "Xóa tiện ích mở rộng",
"extensionAlreadyLatest": "Đây đã là phiên bản mới nhất",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "扩展",
"extensionInstallUrl": "安装链接",
"extensionInstallSuccess": "安装成功",
"extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?",
"extensionUpdateSuccess": "更新成功",
"extensionDelete": "删除扩展",
"extensionAlreadyLatest": "已经是最新版本",
+1
View File
@@ -103,6 +103,7 @@
"extensions": "擴充功能",
"extensionInstallUrl": "安裝 URL",
"extensionInstallSuccess": "安裝成功",
"extensionUpdateConfirm": "確定將「{name}」更新到最新版本嗎?",
"extensionUpdateSuccess": "更新成功",
"extensionDelete": "刪除擴充功能",
"extensionAlreadyLatest": "已是最新版本",
+84
View File
@@ -648,6 +648,73 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final (width, details) in [(390.0, false), (1100.0, false), (1100.0, true)]) {
testWidgets('extension update confirms, shows progress and retries at $width details=$details', (tester) async {
await _setTestSize(tester, Size(width, 900));
final controller = UpdateableExtensionsController();
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(() => controller)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
if (details) {
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle();
}
final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0'));
final dialog = find.byKey(const ValueKey('extension-update-dialog'));
final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
final cancel = find.byKey(const ValueKey('cancel-update-extension-button'));
await tester.tap(entry);
await tester.pumpAndSettle();
expect(dialog, findsOneWidget);
expect(find.text('Update “Extension 0” to the latest version?'), findsOneWidget);
expect(controller.updateCalls, 0);
await tester.tap(cancel);
await tester.pumpAndSettle();
expect(dialog, findsNothing);
expect(controller.updateCalls, 0);
await tester.tap(entry);
await tester.pumpAndSettle();
controller.failUpdate = true;
await tester.tap(confirm);
await tester.pump();
expect(controller.updateCalls, 1);
expect(tester.widget<AppLoadingButton>(confirm).loading, isTrue);
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
await tester.tap(confirm);
await tester.tapAt(const Offset(5, 5));
await tester.binding.handlePopRoute();
await tester.pump();
expect(controller.updateCalls, 1);
expect(dialog, findsOneWidget);
controller.updateGate.complete();
await tester.pumpAndSettle();
expect(dialog, findsOneWidget);
expect(find.byKey(const ValueKey('extension-update-error')), findsOneWidget);
expect(tester.widget<AppLoadingButton>(confirm).loading, isFalse);
controller.failUpdate = false;
controller.updateGate = Completer<void>();
await tester.tap(confirm);
await tester.pump();
expect(controller.updateCalls, 2);
controller.updateGate.complete();
await tester.pumpAndSettle();
expect(dialog, findsNothing);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
testWidgets('installed extension details use a disabled installed button without a hero status', (
WidgetTester tester,
) async {
@@ -5304,6 +5371,23 @@ class PaginatedExtensionsController extends FakeExtensionsController {
}
}
class UpdateableExtensionsController extends FakeExtensionsController {
int updateCalls = 0;
bool failUpdate = false;
Completer<void> updateGate = Completer<void>();
@override
Future<ExtensionsState> build() async =>
(await super.build()).copyWith(listFilter: ExtensionListFilter.installed, updateFlags: {'extension-0': '2.0.0'});
@override
Future<void> upgradeExtension(api_extension.Extension extension) async {
updateCalls++;
await updateGate.future;
if (failUpdate) throw StateError('update failed');
}
}
class BusyInstallExtensionsController extends FakeExtensionsController {
@override
Future<ExtensionsState> build() async {