mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 23:57:54 +00:00
feat: extension store (#1301)
This commit is contained in:
12 files changed
+1791
-425
No files matched your search
@@ -200,10 +200,10 @@ Future<void> putConfig(DownloaderConfig config) async {
|
||||
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
||||
}
|
||||
|
||||
Future<void> installExtension(InstallExtension installExtension) async {
|
||||
return _parse(
|
||||
Future<String> installExtension(InstallExtension installExtension) async {
|
||||
return _parse<String>(
|
||||
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
||||
null);
|
||||
(data) => data as String);
|
||||
}
|
||||
|
||||
Future<List<Extension>> getExtensions() async {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'model/store_extension.dart';
|
||||
|
||||
class GopeedSiteApi {
|
||||
GopeedSiteApi._();
|
||||
|
||||
static final instance = GopeedSiteApi._();
|
||||
|
||||
static const _host = 'gopeed.com';
|
||||
|
||||
Future<Map<String, dynamic>> getRelease() async {
|
||||
final json = await _getJson('/api/release');
|
||||
return json as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<StoreExtensionPage> getExtensions({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||
StoreSortOrder order = StoreSortOrder.desc,
|
||||
String? query,
|
||||
}) async {
|
||||
final json = await _getJson('/api/extensions', queryParameters: {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
});
|
||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> reportExtensionInstall(String id) async {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
uri.toString(),
|
||||
data: {'id': id},
|
||||
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path,
|
||||
{Map<String, String>? queryParameters}) async {
|
||||
final uri = Uri.https(_host, path, queryParameters);
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
}
|
||||
return jsonDecode(response.data!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class StoreExtensionPage {
|
||||
final List<StoreExtension> data;
|
||||
final StorePagination pagination;
|
||||
|
||||
StoreExtensionPage({required this.data, required this.pagination});
|
||||
|
||||
factory StoreExtensionPage.fromJson(Map<String, dynamic> json) {
|
||||
return StoreExtensionPage(
|
||||
data: (json['data'] as List<dynamic>? ?? [])
|
||||
.map((e) => StoreExtension.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pagination: StorePagination.fromJson(
|
||||
json['pagination'] as Map<String, dynamic>? ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StorePagination {
|
||||
final int page;
|
||||
final int limit;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool hasNext;
|
||||
final bool hasPrev;
|
||||
|
||||
StorePagination({
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.total,
|
||||
required this.totalPages,
|
||||
required this.hasNext,
|
||||
required this.hasPrev,
|
||||
});
|
||||
|
||||
factory StorePagination.fromJson(Map<String, dynamic> json) {
|
||||
return StorePagination(
|
||||
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||
limit: (json['limit'] as num?)?.toInt() ?? 20,
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
totalPages: (json['totalPages'] as num?)?.toInt() ?? 1,
|
||||
hasNext: json['hasNext'] as bool? ?? false,
|
||||
hasPrev: json['hasPrev'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String repoFullName;
|
||||
final String repoUrl;
|
||||
final String? directory;
|
||||
final String? commitSha;
|
||||
final String name;
|
||||
final String author;
|
||||
final String title;
|
||||
final String description;
|
||||
final String? icon;
|
||||
final String version;
|
||||
final String? homepage;
|
||||
final String? readme;
|
||||
final int installCount;
|
||||
final int stars;
|
||||
final List<String> topics;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
StoreExtension({
|
||||
required this.id,
|
||||
required this.repoFullName,
|
||||
required this.repoUrl,
|
||||
this.directory,
|
||||
this.commitSha,
|
||||
required this.name,
|
||||
required this.author,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.icon,
|
||||
required this.version,
|
||||
this.homepage,
|
||||
this.readme,
|
||||
required this.installCount,
|
||||
required this.stars,
|
||||
required this.topics,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
repoFullName: json['repoFullName'] as String? ?? '',
|
||||
repoUrl: json['repoUrl'] as String? ?? '',
|
||||
directory: json['directory'] as String?,
|
||||
commitSha: json['commitSha'] as String?,
|
||||
name: json['name'] as String? ?? '',
|
||||
author: json['author'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
icon: json['icon'] as String?,
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
homepage: json['homepage'] as String?,
|
||||
readme: json['readme'] as String?,
|
||||
installCount: (json['installCount'] as num?)?.toInt() ?? 0,
|
||||
stars: (json['stars'] as num?)?.toInt() ?? 0,
|
||||
topics: _parseTopics(json['topics']),
|
||||
createdAt: _parseDate(json['createdAt']),
|
||||
updatedAt: _parseDate(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseTopics(dynamic value) {
|
||||
if (value is List) {
|
||||
return value.map((e) => e.toString()).toList();
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) {
|
||||
return parsed.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value);
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreExtensionSort {
|
||||
stars,
|
||||
installs,
|
||||
updated,
|
||||
}
|
||||
|
||||
enum StoreSortOrder {
|
||||
asc,
|
||||
desc,
|
||||
}
|
||||
@@ -1,38 +1,278 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:gopeed/api/api.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../../api/api.dart';
|
||||
import '../../../../api/gopeed_site_api.dart';
|
||||
import '../../../../api/model/extension.dart';
|
||||
import '../../../../api/model/install_extension.dart';
|
||||
import '../../../../api/model/store_extension.dart';
|
||||
import '../../../../api/model/switch_extension.dart';
|
||||
|
||||
enum ExtensionListFilter {
|
||||
market,
|
||||
installed,
|
||||
}
|
||||
|
||||
class ExtensionListItem {
|
||||
final Extension? installed;
|
||||
final StoreExtension? store;
|
||||
|
||||
const ExtensionListItem({this.installed, this.store});
|
||||
|
||||
bool get isInstalled => installed != null;
|
||||
|
||||
String get id => installed?.identity ?? store!.id;
|
||||
|
||||
String get title => installed?.title ?? store?.title ?? '';
|
||||
|
||||
String get author => installed?.author ?? store?.author ?? '';
|
||||
|
||||
String get description => installed?.description ?? store?.description ?? '';
|
||||
|
||||
String get version => installed?.version ?? store?.version ?? '0.0.0';
|
||||
|
||||
String? get homepage {
|
||||
final installedHomepage = installed?.homepage;
|
||||
if (installedHomepage != null && installedHomepage.isNotEmpty) {
|
||||
return installedHomepage;
|
||||
}
|
||||
return store?.homepage;
|
||||
}
|
||||
|
||||
String? get repoUrl {
|
||||
final installedRepo = installed?.repository?.url;
|
||||
if (installedRepo != null && installedRepo.isNotEmpty) {
|
||||
return installedRepo;
|
||||
}
|
||||
return store?.repoUrl;
|
||||
}
|
||||
|
||||
int get stars => store?.stars ?? 0;
|
||||
|
||||
int get installCount => store?.installCount ?? 0;
|
||||
|
||||
String? get icon => store?.icon;
|
||||
}
|
||||
|
||||
class ExtensionController extends GetxController {
|
||||
final extensions = <Extension>[].obs;
|
||||
static const manualInstallBusyKey = '__manual_install__';
|
||||
|
||||
final installedExtensions = <Extension>[].obs;
|
||||
final updateFlags = <String, String>{}.obs;
|
||||
|
||||
final storeExtensions = <StoreExtension>[].obs;
|
||||
final storePagination = Rxn<StorePagination>();
|
||||
final storeQuery = ''.obs;
|
||||
final storeSort = StoreExtensionSort.stars.obs;
|
||||
|
||||
final listFilter = ExtensionListFilter.market.obs;
|
||||
|
||||
final loadingInstalled = false.obs;
|
||||
final loadingStore = false.obs;
|
||||
final loadingMoreStore = false.obs;
|
||||
final busyExtensionIds = <String>{}.obs;
|
||||
final showInstallTools = false.obs;
|
||||
|
||||
final devMode = false.obs;
|
||||
var _devModeCount = 0;
|
||||
|
||||
/// Flag to prevent handling pending install multiple times
|
||||
bool pendingInstallHandled = false;
|
||||
|
||||
UnmodifiableMapView<String, Extension> get installedMap =>
|
||||
UnmodifiableMapView(
|
||||
{for (final ext in installedExtensions) ext.identity: ext});
|
||||
|
||||
UnmodifiableMapView<String, StoreExtension> get storeMap =>
|
||||
UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});
|
||||
|
||||
List<ExtensionListItem> get displayItems {
|
||||
if (listFilter.value == ExtensionListFilter.installed) {
|
||||
return installedExtensions
|
||||
.map((ext) =>
|
||||
ExtensionListItem(installed: ext, store: storeMap[ext.identity]))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return storeExtensions
|
||||
.map((ext) =>
|
||||
ExtensionListItem(installed: installedMap[ext.id], store: ext))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
await load();
|
||||
checkUpdate();
|
||||
await loadInitialData();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
extensions.value = await getExtensions();
|
||||
Future<void> loadInitialData() async {
|
||||
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
|
||||
}
|
||||
|
||||
Future<void> checkUpdate() async {
|
||||
for (final ext in extensions) {
|
||||
final resp = await upgradeCheckExtension(ext.identity);
|
||||
if (resp.newVersion.isNotEmpty) {
|
||||
updateFlags[ext.identity] = resp.newVersion;
|
||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||
loadingInstalled.value = true;
|
||||
try {
|
||||
installedExtensions.value = await getExtensions();
|
||||
if (refreshUpdates) {
|
||||
await checkUpdate();
|
||||
}
|
||||
} finally {
|
||||
loadingInstalled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to open dev mode when install button is clicked 5 times in 2 seconds
|
||||
Future<void> checkUpdate() async {
|
||||
final nextFlags = <String, String>{};
|
||||
for (final ext in installedExtensions) {
|
||||
try {
|
||||
final resp = await upgradeCheckExtension(ext.identity);
|
||||
if (resp.newVersion.isNotEmpty) {
|
||||
nextFlags[ext.identity] = resp.newVersion;
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore single extension check failures to avoid breaking the whole list.
|
||||
}
|
||||
}
|
||||
updateFlags.assignAll(nextFlags);
|
||||
}
|
||||
|
||||
Future<void> refreshStore() async {
|
||||
loadingStore.value = true;
|
||||
try {
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sort: storeSort.value,
|
||||
query: storeQuery.value,
|
||||
);
|
||||
storeExtensions.assignAll(page.data);
|
||||
storePagination.value = page.pagination;
|
||||
} finally {
|
||||
loadingStore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMoreStore() async {
|
||||
final pagination = storePagination.value;
|
||||
if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {
|
||||
return;
|
||||
}
|
||||
loadingMoreStore.value = true;
|
||||
try {
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: pagination.page + 1,
|
||||
limit: pagination.limit,
|
||||
sort: storeSort.value,
|
||||
query: storeQuery.value,
|
||||
);
|
||||
storeExtensions.addAll(page.data);
|
||||
storePagination.value = page.pagination;
|
||||
} finally {
|
||||
loadingMoreStore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchStore(String query) async {
|
||||
storeQuery.value = query.trim();
|
||||
await refreshStore();
|
||||
}
|
||||
|
||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||
if (storeSort.value == sort) {
|
||||
await refreshStore();
|
||||
return;
|
||||
}
|
||||
storeSort.value = sort;
|
||||
await refreshStore();
|
||||
}
|
||||
|
||||
void changeFilter(ExtensionListFilter filter) {
|
||||
listFilter.value = filter;
|
||||
}
|
||||
|
||||
void toggleInstallTools() {
|
||||
showInstallTools.value = !showInstallTools.value;
|
||||
}
|
||||
|
||||
Extension? findInstalled(StoreExtension extension) {
|
||||
return installedMap[extension.id];
|
||||
}
|
||||
|
||||
bool canUpdateFromStore(StoreExtension extension) {
|
||||
final installed = findInstalled(extension);
|
||||
if (installed == null) return false;
|
||||
return _compareVersion(extension.version, installed.version) > 0;
|
||||
}
|
||||
|
||||
bool canUpdateItem(ExtensionListItem item) {
|
||||
if (item.installed == null) return false;
|
||||
if (listFilter.value == ExtensionListFilter.market && item.store != null) {
|
||||
return canUpdateFromStore(item.store!);
|
||||
}
|
||||
return updateFlags.containsKey(item.installed!.identity);
|
||||
}
|
||||
|
||||
Future<void> installFromStore(StoreExtension extension) async {
|
||||
await _runBusy(extension.id, () async {
|
||||
final installUrl = (extension.directory ?? '').trim().isEmpty
|
||||
? extension.repoUrl
|
||||
: '${extension.repoUrl}#${extension.directory!.trim()}';
|
||||
|
||||
final installedId =
|
||||
await installExtension(InstallExtension(url: installUrl));
|
||||
final statsId = installedId.isNotEmpty ? installedId : extension.id;
|
||||
await loadInstalled(refreshUpdates: false);
|
||||
_backgroundCheckUpdate();
|
||||
_bumpStoreInstallCount(statsId);
|
||||
_reportInstallSafe(statsId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> installFromUrl(String url,
|
||||
{bool devInstall = false, String? statsId}) async {
|
||||
await _runBusy(manualInstallBusyKey, () async {
|
||||
final installedId = await installExtension(
|
||||
InstallExtension(devMode: devInstall, url: url));
|
||||
await loadInstalled(refreshUpdates: false);
|
||||
_backgroundCheckUpdate();
|
||||
if (installedId.isNotEmpty) {
|
||||
_bumpStoreInstallCount(installedId);
|
||||
_reportInstallSafe(installedId);
|
||||
} else if (statsId != null && statsId.isNotEmpty) {
|
||||
_bumpStoreInstallCount(statsId);
|
||||
_reportInstallSafe(statsId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> toggleExtension(Extension extension, bool enabled) async {
|
||||
await _runBusy(extension.identity, () async {
|
||||
await switchExtension(
|
||||
extension.identity, SwitchExtension(status: enabled));
|
||||
await loadInstalled(refreshUpdates: false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeExtension(Extension extension) async {
|
||||
await _runBusy(extension.identity, () async {
|
||||
await deleteExtension(extension.identity);
|
||||
await loadInstalled(refreshUpdates: false);
|
||||
updateFlags.remove(extension.identity);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upgradeExtension(Extension extension) async {
|
||||
await _runBusy(extension.identity, () async {
|
||||
await updateExtension(extension.identity);
|
||||
await loadInstalled(refreshUpdates: false);
|
||||
_backgroundCheckUpdate();
|
||||
_bumpStoreInstallCount(extension.identity);
|
||||
_reportInstallSafe(extension.identity);
|
||||
});
|
||||
}
|
||||
|
||||
void tryOpenDevMode() {
|
||||
if (_devModeCount == 0) {
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
|
||||
devMode.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||
if (busyExtensionIds.contains(id)) return;
|
||||
busyExtensionIds.add(id);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
busyExtensionIds.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
void _backgroundCheckUpdate() {
|
||||
unawaited(checkUpdate());
|
||||
}
|
||||
|
||||
void _reportInstallSafe(String id) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||
} catch (_) {
|
||||
// Ignore stats reporting failures.
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void _bumpStoreInstallCount(String id) {
|
||||
final index = storeExtensions.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
final ext = storeExtensions[index];
|
||||
storeExtensions[index] = StoreExtension(
|
||||
id: ext.id,
|
||||
repoFullName: ext.repoFullName,
|
||||
repoUrl: ext.repoUrl,
|
||||
directory: ext.directory,
|
||||
commitSha: ext.commitSha,
|
||||
name: ext.name,
|
||||
author: ext.author,
|
||||
title: ext.title,
|
||||
description: ext.description,
|
||||
icon: ext.icon,
|
||||
version: ext.version,
|
||||
homepage: ext.homepage,
|
||||
readme: ext.readme,
|
||||
installCount: ext.installCount + 1,
|
||||
stars: ext.stars,
|
||||
topics: ext.topics,
|
||||
createdAt: ext.createdAt,
|
||||
updatedAt: ext.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -107,6 +107,27 @@ const enUS = {
|
||||
'extensionAlreadyLatest': 'It\'s already the latest version',
|
||||
'extensionFind': 'Find Extensions',
|
||||
'extensionDevelop': 'Develop Extensions',
|
||||
'extensionStoreTitle': 'Extension Store',
|
||||
'extensionStoreSection': 'Store Extensions',
|
||||
'extensionInstalledSection': 'Installed Extensions',
|
||||
'extensionInstalledEmpty': 'No extensions installed yet',
|
||||
'extensionStoreEmpty': 'No matching extensions found',
|
||||
'extensionSearchHint': 'Search by name, title, description or author',
|
||||
'extensionSortStars': 'Most Stars',
|
||||
'extensionSortInstalls': 'Most Installs',
|
||||
'extensionSortUpdated': 'Recently Updated',
|
||||
'extensionSortToggle': 'Toggle sort direction',
|
||||
'extensionLoadMore': 'Load More',
|
||||
'extensionNoMore': 'No more extensions',
|
||||
'extensionInstalled': 'Installed',
|
||||
'extensionCanUpdate': 'Update Available',
|
||||
'extensionInstall': 'Install',
|
||||
'extensionLoadLocal': 'Install Local Folder',
|
||||
'extensionFilterAll': 'All',
|
||||
'extensionFilterMarket': 'Market',
|
||||
'extensionFilterInstalled': 'Installed',
|
||||
'extensionManualInstall': 'Manual Install',
|
||||
'extensionInstallTools': 'Install Options',
|
||||
'history': 'History',
|
||||
'clearHistory': 'Clear History',
|
||||
'noHistoryFound': 'No History Found',
|
||||
|
||||
@@ -105,6 +105,27 @@ const zhCN = {
|
||||
'extensionAlreadyLatest': '已经是最新版本',
|
||||
'extensionFind': '获取扩展',
|
||||
'extensionDevelop': '开发扩展',
|
||||
'extensionStoreTitle': '扩展商店',
|
||||
'extensionStoreSection': '在线扩展',
|
||||
'extensionInstalledSection': '已安装扩展',
|
||||
'extensionInstalledEmpty': '暂未安装扩展',
|
||||
'extensionStoreEmpty': '没有匹配的扩展',
|
||||
'extensionSearchHint': '按名称、标题、描述或作者搜索',
|
||||
'extensionSortStars': '最多 Star',
|
||||
'extensionSortInstalls': '最多安装',
|
||||
'extensionSortUpdated': '最近更新',
|
||||
'extensionSortToggle': '切换排序方向',
|
||||
'extensionLoadMore': '加载更多',
|
||||
'extensionNoMore': '已经到底了',
|
||||
'extensionInstalled': '已安装',
|
||||
'extensionCanUpdate': '可更新',
|
||||
'extensionInstall': '安装',
|
||||
'extensionLoadLocal': '安装本地目录',
|
||||
'extensionFilterAll': '全部',
|
||||
'extensionFilterMarket': '市场',
|
||||
'extensionFilterInstalled': '已安装',
|
||||
'extensionManualInstall': '手动安装',
|
||||
'extensionInstallTools': '安装方式',
|
||||
'history': '历史记录',
|
||||
'clearHistory': '清空历史记录',
|
||||
'noHistoryFound': '暂无历史记录',
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../api/api.dart';
|
||||
import '../api/gopeed_site_api.dart';
|
||||
import '../app/views/outlined_button_loading.dart';
|
||||
import 'arch/arch.dart';
|
||||
import 'github_mirror.dart';
|
||||
@@ -70,8 +71,7 @@ Future<VersionInfo?> checkUpdate() async {
|
||||
"https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
|
||||
.data;
|
||||
} catch (e) {
|
||||
releaseDataStr =
|
||||
(await proxyRequest("https://gopeed.com/api/release")).data;
|
||||
releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease());
|
||||
}
|
||||
if (releaseDataStr == null) {
|
||||
return null;
|
||||
|
||||
+24
-8
@@ -157,10 +157,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checkable_treeview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -451,6 +451,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown_plus
|
||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -726,22 +734,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
markdown:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: markdown
|
||||
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
menu_base:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1248,10 +1264,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -81,6 +81,7 @@ dependencies:
|
||||
url: https://github.com/hui-z/flutter_install_plugin.git
|
||||
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
|
||||
flutter_svg: ^2.0.17
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
dart_ipc: ^1.0.1
|
||||
flutter_local_notifications: 20.1.0
|
||||
dependency_overrides:
|
||||
|
||||
Reference in new issue
Block a user