mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
Merge PR #1583: optimize extension API loading
# Conflicts: # ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart
This commit is contained in:
commit
cc96cff2ea
8 files changed
+605
-58
No files matched your search
@@ -11,6 +11,8 @@ class GopeedSiteApi {
|
|||||||
static final instance = GopeedSiteApi._();
|
static final instance = GopeedSiteApi._();
|
||||||
|
|
||||||
static const _host = 'gopeed.com';
|
static const _host = 'gopeed.com';
|
||||||
|
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
|
||||||
|
final _storeRequests = <String, Future<StoreExtensionPage>>{};
|
||||||
|
|
||||||
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||||
@@ -26,20 +28,52 @@ class GopeedSiteApi {
|
|||||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
StoreSortOrder order = StoreSortOrder.desc,
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
String? query,
|
String? query,
|
||||||
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _getJson(
|
final params = {
|
||||||
'/api/extensions',
|
'page': page.toString(),
|
||||||
queryParameters: {
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
'page': page.toString(),
|
'sort': sort.name,
|
||||||
'limit': limit.clamp(1, 100).toString(),
|
'order': order.name,
|
||||||
'sort': sort.name,
|
'view': 'summary',
|
||||||
'order': order.name,
|
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
};
|
||||||
},
|
final key = Uri.https(_host, '/api/extensions', params).toString();
|
||||||
);
|
final now = DateTime.now();
|
||||||
|
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
|
||||||
|
final cached = _storePages[key];
|
||||||
|
if (!forceRefresh && cached != null) return cached.page;
|
||||||
|
final pending = _storeRequests[key];
|
||||||
|
if (pending != null) return pending;
|
||||||
|
final request = _fetchStorePage(params);
|
||||||
|
_storeRequests[key] = request;
|
||||||
|
try {
|
||||||
|
final result = await request;
|
||||||
|
if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first);
|
||||||
|
_storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2)));
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
_storeRequests.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
|
||||||
|
final json = await _getJson('/api/extensions', queryParameters: params);
|
||||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) async {
|
||||||
|
final json = await _getJsonUri(
|
||||||
|
Uri(
|
||||||
|
scheme: 'https',
|
||||||
|
host: _host,
|
||||||
|
pathSegments: ['api', 'extensions', id],
|
||||||
|
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return StoreExtension.fromJson(json as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> reportExtensionInstall(String id) async {
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
final uri = Uri.https(_host, '/api/extensions/install');
|
final uri = Uri.https(_host, '/api/extensions/install');
|
||||||
await proxyRequest(
|
await proxyRequest(
|
||||||
@@ -49,8 +83,11 @@ class GopeedSiteApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||||
final uri = Uri.https(_host, path, queryParameters);
|
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getJsonUri(Uri uri) async {
|
||||||
final Response<String> response = await proxyRequest(uri.toString());
|
final Response<String> response = await proxyRequest(uri.toString());
|
||||||
if (response.data == null || response.data!.isEmpty) {
|
if (response.data == null || response.data!.isEmpty) {
|
||||||
throw Exception('Empty response from $uri');
|
throw Exception('Empty response from $uri');
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class StoreExtension {
|
|||||||
final List<String> topics;
|
final List<String> topics;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
|
final bool hasDetails;
|
||||||
|
|
||||||
StoreExtension({
|
StoreExtension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -84,6 +85,7 @@ class StoreExtension {
|
|||||||
required this.topics,
|
required this.topics,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
|
this.hasDetails = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -106,6 +108,7 @@ class StoreExtension {
|
|||||||
topics: _parseTopics(json['topics']),
|
topics: _parseTopics(json['topics']),
|
||||||
createdAt: _parseDate(json['createdAt']),
|
createdAt: _parseDate(json['createdAt']),
|
||||||
updatedAt: _parseDate(json['updatedAt']),
|
updatedAt: _parseDate(json['updatedAt']),
|
||||||
|
hasDetails: json.containsKey('readme'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import 'dart:collection';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../api/gopeed_site_api.dart';
|
|
||||||
import '../../../api/model/extension.dart';
|
import '../../../api/model/extension.dart';
|
||||||
import '../../../api/model/install_extension.dart';
|
import '../../../api/model/install_extension.dart';
|
||||||
import '../../../api/model/store_extension.dart';
|
import '../../../api/model/store_extension.dart';
|
||||||
import '../../../api/model/switch_extension.dart';
|
import '../../../api/model/switch_extension.dart';
|
||||||
import '../../../api/model/update_extension_settings.dart';
|
import '../../../api/model/update_extension_settings.dart';
|
||||||
import '../../../core/capabilities/app_capabilities.dart';
|
import '../../../core/capabilities/app_capabilities.dart';
|
||||||
|
import 'store_extension_details_provider.dart';
|
||||||
|
|
||||||
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
||||||
ExtensionsController.new,
|
ExtensionsController.new,
|
||||||
@@ -121,63 +121,81 @@ class ExtensionsState {
|
|||||||
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||||
static const manualInstallBusyKey = '__manual_install__';
|
static const manualInstallBusyKey = '__manual_install__';
|
||||||
int _devModeCount = 0;
|
int _devModeCount = 0;
|
||||||
|
int _installedRevision = 0;
|
||||||
|
int _storeRevision = 0;
|
||||||
|
Future<void>? _updateCheck;
|
||||||
|
bool _checkUpdatesAgain = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ExtensionsState> build() async {
|
Future<ExtensionsState> build() => _loadInitialData();
|
||||||
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
|
|
||||||
|
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
||||||
|
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
||||||
state = AsyncValue.data(next);
|
state = AsyncValue.data(next);
|
||||||
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
await Future.wait([
|
||||||
next = state.value ?? installed;
|
_loadInstalled(refreshUpdates: true, current: next),
|
||||||
await _refreshStore(current: next);
|
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
||||||
return state.value ?? next;
|
]);
|
||||||
|
return ref.mounted ? _current : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInitialData() async {
|
Future<void> loadInitialData() async {
|
||||||
state = const AsyncValue.loading();
|
await _loadInitialData(forceRefresh: true);
|
||||||
state = AsyncValue.data(await build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||||
|
if (!ref.mounted) return;
|
||||||
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshStore() async {
|
Future<void> refreshStore() async {
|
||||||
await _refreshStore(current: _current.copyWith(loadingStore: true));
|
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadMoreStore() async {
|
Future<void> loadMoreStore() async {
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final pagination = current.storePagination;
|
final pagination = current.storePagination;
|
||||||
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
||||||
|
final revision = _storeRevision;
|
||||||
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
||||||
try {
|
try {
|
||||||
final page = await GopeedSiteApi.instance.getExtensions(
|
final page = await ref
|
||||||
page: pagination.page + 1,
|
.read(gopeedSiteApiProvider)
|
||||||
limit: pagination.limit,
|
.getExtensions(
|
||||||
sort: current.storeSort,
|
page: pagination.page + 1,
|
||||||
query: current.storeQuery,
|
limit: pagination.limit,
|
||||||
);
|
sort: current.storeSort,
|
||||||
|
query: current.storeQuery,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
_current.copyWith(
|
_current.copyWith(
|
||||||
storeExtensions: [..._current.storeExtensions, ...page.data],
|
// Cached pages can briefly overlap when stars/install counts change.
|
||||||
|
storeExtensions: {
|
||||||
|
for (final extension in _current.storeExtensions) extension.id: extension,
|
||||||
|
for (final extension in page.data) extension.id: extension,
|
||||||
|
}.values.toList(),
|
||||||
storePagination: page.pagination,
|
storePagination: page.pagination,
|
||||||
loadingMoreStore: false,
|
loadingMoreStore: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> searchStore(String query) async {
|
Future<void> searchStore(String query) async {
|
||||||
|
if (query.trim() == _current.storeQuery) return;
|
||||||
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
||||||
await refreshStore();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||||
|
if (sort == _current.storeSort) return;
|
||||||
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
||||||
await refreshStore();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeFilter(ExtensionListFilter filter) {
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
@@ -257,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() async {
|
Future<void> checkUpdate() {
|
||||||
final flags = <String, String>{};
|
if (!ref.mounted) return Future.value();
|
||||||
for (final ext in _current.installedExtensions) {
|
_checkUpdatesAgain = true;
|
||||||
try {
|
// Installation/refresh can request a fresh scan while one is running.
|
||||||
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
// Coalesce those requests instead of launching overlapping Git operations.
|
||||||
if (resp.newVersion.isNotEmpty) {
|
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
||||||
flags[ext.identity] = resp.newVersion;
|
}
|
||||||
|
|
||||||
|
Future<void> _runUpdateChecks() async {
|
||||||
|
final service = ref.read(gopeedServiceProvider);
|
||||||
|
while (ref.mounted && _checkUpdatesAgain) {
|
||||||
|
_checkUpdatesAgain = false;
|
||||||
|
final revision = _installedRevision;
|
||||||
|
final installed = List<Extension>.of(_current.installedExtensions);
|
||||||
|
final flags = <String, String>{};
|
||||||
|
for (final ext in installed) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
if (revision != _installedRevision) break;
|
||||||
|
try {
|
||||||
|
final resp = await service.upgradeCheckExtension(ext.identity);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
if (revision != _installedRevision) break;
|
||||||
|
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
|
||||||
|
} catch (_) {
|
||||||
|
// A failed update check must never prevent loading the store.
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
}
|
||||||
|
if (ref.mounted && revision == _installedRevision) {
|
||||||
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void tryOpenDevMode() {
|
void tryOpenDevMode() {
|
||||||
if (_devModeCount == 0) {
|
if (_devModeCount == 0) {
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
if (_current.devMode) return;
|
if (!ref.mounted || _current.devMode) return;
|
||||||
_devModeCount = 0;
|
_devModeCount = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
||||||
|
|
||||||
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
||||||
|
final revision = ++_installedRevision;
|
||||||
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
||||||
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
||||||
|
if (!ref.mounted) return current;
|
||||||
|
if (revision != _installedRevision) return _current;
|
||||||
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
||||||
if (refreshUpdates) {
|
if (refreshUpdates) {
|
||||||
await checkUpdate();
|
unawaited(checkUpdate());
|
||||||
}
|
}
|
||||||
return _current;
|
return _current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshStore({required ExtensionsState current}) async {
|
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
||||||
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
final revision = ++_storeRevision;
|
||||||
final page = await GopeedSiteApi.instance.getExtensions(
|
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
||||||
page: 1,
|
try {
|
||||||
limit: 20,
|
final page = await ref
|
||||||
sort: _current.storeSort,
|
.read(gopeedSiteApiProvider)
|
||||||
query: _current.storeQuery,
|
.getExtensions(
|
||||||
);
|
page: 1,
|
||||||
state = AsyncValue.data(
|
limit: 20,
|
||||||
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
sort: current.storeSort,
|
||||||
);
|
query: current.storeQuery,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(
|
||||||
|
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(_current.copyWith(loadingStore: false));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
@@ -314,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
if (ref.mounted) {
|
||||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||||
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _bumpStoreInstallCount(String id) {
|
void _bumpStoreInstallCount(String id) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
@@ -344,14 +399,17 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
topics: ext.topics,
|
topics: ext.topics,
|
||||||
createdAt: ext.createdAt,
|
createdAt: ext.createdAt,
|
||||||
updatedAt: ext.updatedAt,
|
updatedAt: ext.updatedAt,
|
||||||
|
hasDetails: ext.hasDetails,
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportInstallSafe(String id) {
|
void _reportInstallSafe(String id) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
final siteApi = ref.read(gopeedSiteApiProvider);
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
await siteApi.reportExtensionInstall(id);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../api/gopeed_site_api.dart';
|
||||||
|
import '../../../api/model/store_extension.dart';
|
||||||
|
|
||||||
|
final gopeedSiteApiProvider = Provider<GopeedSiteApi>((ref) => GopeedSiteApi.instance);
|
||||||
|
|
||||||
|
typedef StoreExtensionDetailsKey = ({String id, String version});
|
||||||
|
|
||||||
|
// A shared future deduplicates drawer/page requests. Successful details survive
|
||||||
|
// closing the view, while failures can be retried and new versions get a new key.
|
||||||
|
final storeExtensionDetailsProvider = FutureProvider.autoDispose.family<StoreExtension, StoreExtensionDetailsKey>((
|
||||||
|
ref,
|
||||||
|
key,
|
||||||
|
) async {
|
||||||
|
final extension = await ref.watch(gopeedSiteApiProvider).getExtension(key.id, version: key.version);
|
||||||
|
if (ref.mounted) {
|
||||||
|
final keepAlive = ref.keepAlive();
|
||||||
|
final expiry = Timer(const Duration(minutes: 30), keepAlive.close);
|
||||||
|
ref.onDispose(expiry.cancel);
|
||||||
|
}
|
||||||
|
return extension;
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import '../../../../shared/widgets/app_toast.dart';
|
|||||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
import '../../application/extensions_controller.dart';
|
import '../../application/extensions_controller.dart';
|
||||||
|
import '../../application/store_extension_details_provider.dart';
|
||||||
import 'extension_icon.dart';
|
import 'extension_icon.dart';
|
||||||
import 'extension_update_dialog.dart';
|
import 'extension_update_dialog.dart';
|
||||||
import 'extension_update_status.dart';
|
import 'extension_update_status.dart';
|
||||||
@@ -63,7 +64,13 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final state = ref.watch(extensionsControllerProvider).value;
|
final state = ref.watch(extensionsControllerProvider).value;
|
||||||
final current = state?.findItem(item.id) ?? item;
|
final summary = state?.findItem(item.id) ?? item;
|
||||||
|
final summaryStore = summary.store;
|
||||||
|
final detailKey = summaryStore == null || summaryStore.hasDetails
|
||||||
|
? null
|
||||||
|
: (id: summaryStore.id, version: summaryStore.version);
|
||||||
|
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
|
||||||
|
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
|
||||||
final installed = current.installed;
|
final installed = current.installed;
|
||||||
final store = current.store;
|
final store = current.store;
|
||||||
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
||||||
@@ -116,7 +123,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
'v${current.version}',
|
'v${current.version}',
|
||||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
if (store != null) _ExtensionStats(store: store),
|
if (summaryStore != null) _ExtensionStats(store: summaryStore),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -164,6 +171,21 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (details?.isLoading == true)
|
||||||
|
const Padding(
|
||||||
|
key: ValueKey('extension-details-loading'),
|
||||||
|
padding: EdgeInsets.only(top: 24),
|
||||||
|
child: Center(child: shad.CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
if (details?.hasError == true && detailKey != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: shad.SecondaryButton(
|
||||||
|
key: const ValueKey('extension-details-retry'),
|
||||||
|
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
|
||||||
|
child: Text(context.l10n.retry),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -206,6 +228,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
|||||||
final previous = oldWidget.item.installed;
|
final previous = oldWidget.item.installed;
|
||||||
final current = widget.item.installed;
|
final current = widget.item.installed;
|
||||||
if (previous?.identity != current?.identity ||
|
if (previous?.identity != current?.identity ||
|
||||||
|
oldWidget.item.id != widget.item.id ||
|
||||||
previous?.devMode != current?.devMode ||
|
previous?.devMode != current?.devMode ||
|
||||||
previous?.devPath != current?.devPath ||
|
previous?.devPath != current?.devPath ||
|
||||||
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/api.dart' as api;
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
|
||||||
|
final pending = Completer<Response<String>>();
|
||||||
|
final transport = _Transport()..onProxy = (_) => pending.future;
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final site = GopeedSiteApi.instance;
|
||||||
|
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||||
|
final second = site.getExtensions(query: 'cache-test');
|
||||||
|
expect(transport.urls.length, 1);
|
||||||
|
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
|
||||||
|
pending.complete(
|
||||||
|
_response({
|
||||||
|
'data': [
|
||||||
|
{'id': 'test'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await first).data.single.hasDetails, isFalse);
|
||||||
|
await second;
|
||||||
|
await site.getExtensions(query: 'cache-test');
|
||||||
|
expect(transport.urls.length, 1);
|
||||||
|
await site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||||
|
expect(transport.urls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed lists are not cached', () async {
|
||||||
|
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final site = GopeedSiteApi.instance;
|
||||||
|
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
|
||||||
|
transport.onProxy = (_) async => _response({
|
||||||
|
'data': [
|
||||||
|
{'id': 'recovered'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
|
||||||
|
expect(transport.urls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail IDs are encoded once and a null README is still loaded detail', () async {
|
||||||
|
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
|
||||||
|
final uri = Uri.parse(transport.urls.single);
|
||||||
|
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
|
||||||
|
expect(uri.queryParameters['version'], '1.2.3');
|
||||||
|
expect(result.hasDetails, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Response<String> _response(Map<String, dynamic> json) => Response(
|
||||||
|
requestOptions: RequestOptions(path: '/test'),
|
||||||
|
data: jsonEncode(json),
|
||||||
|
statusCode: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
class _Transport implements GopeedTransport {
|
||||||
|
final urls = <String>[];
|
||||||
|
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||||
|
urls.add(uri);
|
||||||
|
return onProxy(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/api/model/extension.dart';
|
||||||
|
import 'package:gopeed/api/model/store_extension.dart';
|
||||||
|
import 'package:gopeed/api/model/update_check_extension_resp.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('store loads while installed data and update checks are still pending', () async {
|
||||||
|
final installed = Completer<List<Extension>>();
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
var updateCalls = 0;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => installed.future)
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) {
|
||||||
|
updateCalls++;
|
||||||
|
return update.future;
|
||||||
|
});
|
||||||
|
final site = _SiteApi();
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final subscription = container.listen(extensionsControllerProvider, (_, _) {});
|
||||||
|
addTearDown(subscription.close);
|
||||||
|
|
||||||
|
await _settle();
|
||||||
|
expect(site.listCalls, 1);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.loadingStore, isFalse);
|
||||||
|
expect(updateCalls, 0);
|
||||||
|
|
||||||
|
installed.complete([_installed()]);
|
||||||
|
await _settle();
|
||||||
|
expect(updateCalls, 1);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.loadingInstalled, isFalse);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await _settle();
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.updateFlags, {'gopeed@test': '2.0.0'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale update scan cannot restore flags after installed data changes', () async {
|
||||||
|
var installed = [_installed()];
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
var updateCalls = 0;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => installed)
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) {
|
||||||
|
updateCalls++;
|
||||||
|
return update.future;
|
||||||
|
});
|
||||||
|
final container = _container(registry, _SiteApi());
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final running = controller.checkUpdate();
|
||||||
|
expect(updateCalls, 1);
|
||||||
|
installed = [];
|
||||||
|
await controller.loadInstalled();
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await running;
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.installedExtensions, isEmpty);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.updateFlags, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an update scan can finish after the controller is disposed', () async {
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => [_installed()])
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) => update.future);
|
||||||
|
final container = _container(registry, _SiteApi());
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final running = controller.checkUpdate();
|
||||||
|
container.dispose();
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await running;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old search results cannot replace a newer search', () async {
|
||||||
|
final old = Completer<StoreExtensionPage>();
|
||||||
|
final latest = Completer<StoreExtensionPage>();
|
||||||
|
final site = _SiteApi()
|
||||||
|
..onList = (query) => switch (query) {
|
||||||
|
'old' => old.future,
|
||||||
|
'new' => latest.future,
|
||||||
|
_ => Future.value(_page('market')),
|
||||||
|
};
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final first = controller.searchStore('old');
|
||||||
|
final second = controller.searchStore('new');
|
||||||
|
latest.complete(_page('new-result'));
|
||||||
|
await second;
|
||||||
|
old.complete(_page('old-result'));
|
||||||
|
await first;
|
||||||
|
final state = container.read(extensionsControllerProvider).value!;
|
||||||
|
expect(state.storeQuery, 'new');
|
||||||
|
expect(state.storeExtensions.single.id, 'new-result');
|
||||||
|
expect(state.loadingStore, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping cached pages do not duplicate an extension', () async {
|
||||||
|
final site = _SiteApi();
|
||||||
|
site.onList = (_) async => StoreExtensionPage.fromJson({
|
||||||
|
'data': [
|
||||||
|
{'id': 'market', 'version': '1.0.0'},
|
||||||
|
if (site.listCalls > 1) {'id': 'second', 'version': '1.0.0'},
|
||||||
|
],
|
||||||
|
'pagination': {'page': site.listCalls, 'limit': 20, 'hasNext': site.listCalls == 1},
|
||||||
|
});
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
await controller.loadMoreStore();
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.map((e) => e.id), ['market', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail requests are shared, cache empty READMEs, and distinguish versions', () async {
|
||||||
|
final detail = Completer<StoreExtension>();
|
||||||
|
final site = _SiteApi()..onDetail = (_, _) => detail.future;
|
||||||
|
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
const key = (id: 'market', version: '1.0.0');
|
||||||
|
final provider = storeExtensionDetailsProvider(key);
|
||||||
|
final subscription = container.listen(provider, (_, _) {});
|
||||||
|
final first = container.read(provider.future);
|
||||||
|
final second = container.read(provider.future);
|
||||||
|
expect(site.detailCalls, 1);
|
||||||
|
detail.complete(StoreExtension.fromJson({'id': 'market', 'version': '1.0.0', 'readme': null}));
|
||||||
|
expect((await first).hasDetails, isTrue);
|
||||||
|
await second;
|
||||||
|
subscription.close();
|
||||||
|
await _settle();
|
||||||
|
await container.read(provider.future);
|
||||||
|
expect(site.detailCalls, 1);
|
||||||
|
await container.read(storeExtensionDetailsProvider((id: 'market', version: '2.0.0')).future);
|
||||||
|
expect(site.detailCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed details can be retried', () async {
|
||||||
|
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
|
||||||
|
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||||
|
final subscription = container.listen(provider, (_, _) {});
|
||||||
|
addTearDown(subscription.close);
|
||||||
|
await expectLater(container.read(provider.future), throwsStateError);
|
||||||
|
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
|
||||||
|
container.invalidate(provider);
|
||||||
|
expect((await container.read(provider.future)).readme, 'loaded');
|
||||||
|
expect(site.detailCalls, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||||
|
gopeedSiteApiProvider.overrideWithValue(site),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _settle() async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreExtensionPage _page(String id) => StoreExtensionPage.fromJson({
|
||||||
|
'data': [
|
||||||
|
{'id': id, 'version': '1.0.0'},
|
||||||
|
],
|
||||||
|
'pagination': {'page': 1, 'limit': 20, 'total': 1, 'totalPages': 1},
|
||||||
|
});
|
||||||
|
|
||||||
|
Extension _installed() => Extension(
|
||||||
|
identity: 'gopeed@test',
|
||||||
|
name: 'test',
|
||||||
|
author: 'gopeed',
|
||||||
|
title: 'Test',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
version: '1.0.0',
|
||||||
|
homepage: '',
|
||||||
|
repository: null,
|
||||||
|
disabled: false,
|
||||||
|
devMode: false,
|
||||||
|
devPath: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
class _SiteApi implements GopeedSiteApi {
|
||||||
|
var listCalls = 0;
|
||||||
|
var detailCalls = 0;
|
||||||
|
Future<StoreExtensionPage> Function(String?) onList = (_) async => _page('market');
|
||||||
|
Future<StoreExtension> Function(String, String?) onDetail = (id, _) async =>
|
||||||
|
StoreExtension.fromJson({'id': id, 'readme': null});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtensionPage> getExtensions({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
|
String? query,
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) {
|
||||||
|
listCalls++;
|
||||||
|
return onList(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) {
|
||||||
|
detailCalls++;
|
||||||
|
return onDetail(id, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/api/model/store_extension.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
|
||||||
|
import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final width in [390.0, 1024.0]) {
|
||||||
|
testWidgets('summary details load and can retry at width $width', (tester) async {
|
||||||
|
tester.view.physicalSize = Size(width, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final pending = Completer<StoreExtension>();
|
||||||
|
final site = _DetailApi()..request = pending.future;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
extensionsControllerProvider.overrideWith(_SummaryController.new),
|
||||||
|
gopeedSiteApiProvider.overrideWithValue(site),
|
||||||
|
],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: shad.Scaffold(
|
||||||
|
child: ExtensionDetailView(
|
||||||
|
item: ExtensionListItem(store: _summary()),
|
||||||
|
mobile: width < 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
|
||||||
|
pending.completeError(StateError('offline'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
|
||||||
|
site.request = Future.value(
|
||||||
|
StoreExtension.fromJson({
|
||||||
|
'id': 'market',
|
||||||
|
'title': 'Market',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'readme': '# Loaded details',
|
||||||
|
'homepage': 'https://example.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Loaded details'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreExtension _summary() => StoreExtension.fromJson({
|
||||||
|
'id': 'market',
|
||||||
|
'title': 'Market',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'repoUrl': 'https://github.com/test/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
class _SummaryController extends ExtensionsController {
|
||||||
|
@override
|
||||||
|
Future<ExtensionsState> build() async => ExtensionsState(storeExtensions: [_summary()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailApi implements GopeedSiteApi {
|
||||||
|
late Future<StoreExtension> request;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) => request;
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user