mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 19:33:41 +00:00
feat(ui): rework extension update detection and entry point (#1535)
This commit is contained in:
31 files changed
+434
-228
No files matched your search
+63
-116
@@ -21,20 +21,33 @@ import '../../../../util/util.dart';
|
||||
import '../../application/extensions_controller.dart';
|
||||
import 'extension_icon.dart';
|
||||
import 'extension_update_dialog.dart';
|
||||
import 'extension_update_status.dart';
|
||||
|
||||
class ExtensionDetailDrawer extends StatelessWidget {
|
||||
class ExtensionDetailDrawer extends ConsumerWidget {
|
||||
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
|
||||
|
||||
final ExtensionListItem? item;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(extensionsControllerProvider).value;
|
||||
final installed = item?.installed;
|
||||
final canUpdate =
|
||||
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
|
||||
final updateChip = canUpdate
|
||||
? ExtensionUpdateStatus(
|
||||
key: const ValueKey('extension-details-update'),
|
||||
label: context.l10n.extensionCanUpdate,
|
||||
onTap: () => showExtensionUpdateDialog(context, installed),
|
||||
)
|
||||
: null;
|
||||
return AppDetailDrawer(
|
||||
open: item != null,
|
||||
title: item?.title ?? '',
|
||||
onClose: onClose,
|
||||
drawerKey: const ValueKey('extension-details-drawer'),
|
||||
titleTrailing: updateChip,
|
||||
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
|
||||
);
|
||||
}
|
||||
@@ -104,8 +117,6 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (store != null) _ExtensionStats(store: store),
|
||||
if (installed != null && canUpdate)
|
||||
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -130,15 +141,6 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
|
||||
child: Text(context.l10n.extensionInstall),
|
||||
),
|
||||
if (installed != null && canUpdate)
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('extension-details-update'),
|
||||
loading: false,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
||||
child: Text(context.l10n.newVersionUpdate),
|
||||
),
|
||||
if (installed != null && !canUpdate)
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('extension-details-installed'),
|
||||
@@ -162,7 +164,13 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
||||
SizedBox(height: mobile ? 24 : 28),
|
||||
_ExtensionReadme(
|
||||
// Snapshot the version so upgrades reload the installed README.
|
||||
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
|
||||
item: current,
|
||||
mobile: mobile,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -180,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _ExtensionReadme extends StatefulWidget {
|
||||
const _ExtensionReadme({required this.item, required this.mobile});
|
||||
const _ExtensionReadme({super.key, required this.item, required this.mobile});
|
||||
|
||||
final ExtensionListItem item;
|
||||
final bool mobile;
|
||||
@@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
late Future<_ReadmeInfo> _readme;
|
||||
late final _ReadmeInfo _readme;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -198,109 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
_readme = _loadReadme(widget.item);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ExtensionReadme oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final previous = oldWidget.item.installed;
|
||||
final current = widget.item.installed;
|
||||
if (previous?.identity != current?.identity ||
|
||||
previous?.devMode != current?.devMode ||
|
||||
previous?.devPath != current?.devPath ||
|
||||
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
||||
_readme = _loadReadme(widget.item);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return FutureBuilder<_ReadmeInfo>(
|
||||
future: _readme,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
if (info == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (info.content.trim().isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
key: const ValueKey('extension-details-readme'),
|
||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'README',
|
||||
style: TextStyle(
|
||||
color: palette.textMuted,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MarkdownBody(
|
||||
data: info.content,
|
||||
selectable: true,
|
||||
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||
onTapLink: (_, href, _) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
||||
if (resolved != null) unawaited(_openUrl(resolved));
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
||||
if (resolved == null) return const SizedBox.shrink();
|
||||
final image = resolved.startsWith('file:')
|
||||
? Image.file(
|
||||
File(Uri.parse(resolved).toFilePath()),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
)
|
||||
: Image.network(
|
||||
resolved,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
final info = _readme;
|
||||
if (info.content.trim().isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
key: const ValueKey('extension-details-readme'),
|
||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'README',
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionStatus extends StatelessWidget {
|
||||
const _ExtensionStatus({required this.label, required this.emphasized});
|
||||
|
||||
final String label;
|
||||
final bool emphasized;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final dotColor = emphasized ? palette.brand : palette.success;
|
||||
final textColor = emphasized ? palette.textPrimary : palette.success;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 5,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
MarkdownBody(
|
||||
data: info.content,
|
||||
selectable: true,
|
||||
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||
onTapLink: (_, href, _) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
||||
if (resolved != null) unawaited(_openUrl(resolved));
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
||||
if (resolved == null) return const SizedBox.shrink();
|
||||
final image = resolved.startsWith('file:')
|
||||
? Image.file(
|
||||
File(Uri.parse(resolved).toFilePath()),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
)
|
||||
: Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink());
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +354,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) {
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async {
|
||||
_ReadmeInfo _loadReadme(ExtensionListItem item) {
|
||||
final installed = item.installed;
|
||||
final remote = item.store?.readme ?? '';
|
||||
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
|
||||
canPop: !_updating,
|
||||
child: AlertDialog(
|
||||
key: const ValueKey('extension-update-dialog'),
|
||||
title: Text(context.l10n.extensionCanUpdate),
|
||||
title: Text(context.l10n.newVersionAvailable),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
|
||||
/// Green status dot with an optional label, marking an available update.
|
||||
/// Pass [onTap] to make it open the update prompt.
|
||||
class ExtensionUpdateStatus extends StatelessWidget {
|
||||
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
|
||||
|
||||
final String? label;
|
||||
final VoidCallback? onTap;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final content = Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.brandSoft,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
|
||||
),
|
||||
if (label != null) ...[
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
if (onTap == null) return content;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user