mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 04:13:43 +00:00
feat: extension store (#1301)
This commit is contained in:
12 files changed
+1791
-425
No files matched your search
@@ -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<void> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ExtensionController> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user