fix(ui): unify extension update check and improve update affordance

- Update availability is now decided solely by the backend live GitHub
  check (updateFlags); the store index may lag and previously masked
  new versions in the market view.
- Replace the ambiguous refresh icon on extension cards with a labeled
  update button, and add a red dot badge on extension icons (card and
  detail view) when an update is available.
- Surface a toast when the update check fails instead of failing silently.
This commit is contained in:
Levi committed 2026-09-19 17:32:44 +08:00
1 parent 694b43e3f9
commit 172f4a3650
5 files changed
+81 -58

No files matched your search

@@ -49,6 +49,7 @@ class ExtensionsState {
this.loadingMoreStore = false,
this.busyExtensionIds = const {},
this.devMode = false,
this.updateCheckFailed = false,
});
final List<Extension> installedExtensions;
@@ -63,6 +64,7 @@ class ExtensionsState {
final bool loadingMoreStore;
final Set<String> busyExtensionIds;
final bool devMode;
final bool updateCheckFailed;
UnmodifiableMapView<String, Extension> get installedMap =>
UnmodifiableMapView({for (final ext in installedExtensions) ext.identity: ext});
@@ -100,6 +102,7 @@ class ExtensionsState {
bool? loadingMoreStore,
Set<String>? busyExtensionIds,
bool? devMode,
bool? updateCheckFailed,
}) {
return ExtensionsState(
installedExtensions: installedExtensions ?? this.installedExtensions,
@@ -114,6 +117,7 @@ class ExtensionsState {
loadingMoreStore: loadingMoreStore ?? this.loadingMoreStore,
busyExtensionIds: busyExtensionIds ?? this.busyExtensionIds,
devMode: devMode ?? this.devMode,
updateCheckFailed: updateCheckFailed ?? this.updateCheckFailed,
);
}
}
@@ -185,12 +189,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}
bool canUpdateItem(ExtensionListItem item) {
final current = _current;
if (item.installed == null) return false;
if (current.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return current.updateFlags.containsKey(item.installed!.identity);
final installed = item.installed;
if (installed == null) return false;
// The backend checks GitHub live for every installed extension; the store
// index may lag behind and must not decide update visibility.
return _current.updateFlags.containsKey(installed.identity);
}
Future<void> installFromStore(StoreExtension extension) async {
@@ -257,15 +260,23 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
Future<void> checkUpdate() async {
final flags = <String, String>{};
var failed = false;
for (final ext in _current.installedExtensions) {
try {
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
flags[ext.identity] = resp.newVersion;
}
} catch (_) {}
} catch (_) {
failed = true;
}
}
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
state = AsyncValue.data(
_current.copyWith(
updateFlags: flags,
updateCheckFailed: failed && _current.installedExtensions.isNotEmpty,
),
);
}
void tryOpenDevMode() {
@@ -354,24 +365,4 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}());
}
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<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
}
@@ -106,6 +106,12 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
});
}
final stateAsync = ref.watch(extensionsControllerProvider);
ref.listen(extensionsControllerProvider, (previous, next) {
final failed = next.value?.updateCheckFailed ?? false;
if (failed && previous?.value?.updateCheckFailed != true) {
showAppToast(context, context.l10n.extensionUpdateCheckFailed);
}
});
final body = Stack(
children: [
ColoredBox(
@@ -485,25 +491,7 @@ class _Content extends StatelessWidget {
}
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
if (item.installed == null) return false;
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
}
}
@@ -988,7 +976,27 @@ class _ExtensionCard extends ConsumerWidget {
children: [
Row(
children: [
ExtensionIcon(item: item, size: 40, borderRadius: BorderRadius.circular(6), fallbackFit: BoxFit.cover),
Stack(
clipBehavior: Clip.none,
children: [
ExtensionIcon(item: item, size: 40, borderRadius: BorderRadius.circular(6), fallbackFit: BoxFit.cover),
if (canUpdate)
Positioned(
top: -3,
right: -3,
child: Container(
key: ValueKey('extension-card-update-dot-${item.id}'),
width: 11,
height: 11,
decoration: BoxDecoration(
color: palette.error,
shape: BoxShape.circle,
border: Border.all(color: palette.cardBg, width: 2),
),
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -1068,11 +1076,11 @@ class _ExtensionCard extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
shad.PrimaryButton(
key: ValueKey('update-extension-${installed.identity}'),
density: shad.ButtonDensity.dense,
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
child: Text(context.l10n.extensionUpdateAction),
),
if (installed == null && item.store != null)
shad.GhostButton(
@@ -65,12 +65,32 @@ class ExtensionDetailView extends ConsumerWidget {
key: const ValueKey('extension-details-hero'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExtensionIcon(
item: current,
size: mobile ? 60 : 68,
borderRadius: BorderRadius.circular(14),
fallbackPadding: EdgeInsets.all((mobile ? 60 : 68) * 0.16),
fallbackBackgroundColor: palette.surfaceSoft,
Stack(
clipBehavior: Clip.none,
children: [
ExtensionIcon(
item: current,
size: mobile ? 60 : 68,
borderRadius: BorderRadius.circular(14),
fallbackPadding: EdgeInsets.all((mobile ? 60 : 68) * 0.16),
fallbackBackgroundColor: palette.surfaceSoft,
),
if (canUpdate)
Positioned(
top: -2,
right: -2,
child: Container(
key: const ValueKey('extension-details-update-dot'),
width: 13,
height: 13,
decoration: BoxDecoration(
color: palette.error,
shape: BoxShape.circle,
border: Border.all(color: palette.bg, width: 2.5),
),
),
),
],
),
SizedBox(width: mobile ? 16 : 20),
Expanded(
@@ -135,7 +155,7 @@ class ExtensionDetailView extends ConsumerWidget {
key: const ValueKey('extension-details-update'),
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
icon: const Icon(Icons.upgrade, size: 16),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
+2
View File
@@ -120,6 +120,8 @@
}
},
"extensionUpdateSuccess": "Updated successfully",
"extensionUpdateAction": "Update",
"extensionUpdateCheckFailed": "Failed to check for updates. Please check your network",
"extensionDelete": "Delete Extension",
"extensionAlreadyLatest": "It's already the latest version",
"extensionFind": "Find Extensions",
+2
View File
@@ -106,6 +106,8 @@
"extensionInstallSuccess": "安装成功",
"extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?",
"extensionUpdateSuccess": "更新成功",
"extensionUpdateAction": "更新",
"extensionUpdateCheckFailed": "检查更新失败,请检查网络后重试",
"extensionDelete": "删除扩展",
"extensionAlreadyLatest": "已经是最新版本",
"extensionFind": "获取扩展",