mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
perf(extensions): load store summaries and check updates asynchronously (#1583)
This commit is contained in:
41 files changed
+1567
-257
No files matched your search
@@ -11,6 +11,8 @@ class GopeedSiteApi {
|
||||
static final instance = GopeedSiteApi._();
|
||||
|
||||
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 {
|
||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||
@@ -26,20 +28,52 @@ class GopeedSiteApi {
|
||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||
StoreSortOrder order = StoreSortOrder.desc,
|
||||
String? query,
|
||||
bool forceRefresh = false,
|
||||
}) 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(),
|
||||
},
|
||||
);
|
||||
final params = {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
'view': 'summary',
|
||||
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>);
|
||||
}
|
||||
|
||||
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 {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
@@ -49,8 +83,11 @@ class GopeedSiteApi {
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
||||
final uri = Uri.https(_host, path, queryParameters);
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
||||
}
|
||||
|
||||
Future<dynamic> _getJsonUri(Uri uri) async {
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
|
||||
@@ -64,6 +64,7 @@ class StoreExtension {
|
||||
final List<String> topics;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final bool hasDetails;
|
||||
|
||||
StoreExtension({
|
||||
required this.id,
|
||||
@@ -84,6 +85,7 @@ class StoreExtension {
|
||||
required this.topics,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.hasDetails = true,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
@@ -106,6 +108,7 @@ class StoreExtension {
|
||||
topics: _parseTopics(json['topics']),
|
||||
createdAt: _parseDate(json['createdAt']),
|
||||
updatedAt: _parseDate(json['updatedAt']),
|
||||
hasDetails: json.containsKey('readme'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import 'dart:collection';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||
import '../../../api/model/update_extension_settings.dart';
|
||||
import '../../../core/capabilities/app_capabilities.dart';
|
||||
import 'store_extension_details_provider.dart';
|
||||
|
||||
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
||||
ExtensionsController.new,
|
||||
@@ -121,63 +121,81 @@ class ExtensionsState {
|
||||
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
static const manualInstallBusyKey = '__manual_install__';
|
||||
int _devModeCount = 0;
|
||||
int _installedRevision = 0;
|
||||
int _storeRevision = 0;
|
||||
Future<void>? _updateCheck;
|
||||
bool _checkUpdatesAgain = false;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async {
|
||||
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
|
||||
Future<ExtensionsState> build() => _loadInitialData();
|
||||
|
||||
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
||||
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
||||
state = AsyncValue.data(next);
|
||||
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
||||
next = state.value ?? installed;
|
||||
await _refreshStore(current: next);
|
||||
return state.value ?? next;
|
||||
await Future.wait([
|
||||
_loadInstalled(refreshUpdates: true, current: next),
|
||||
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
||||
]);
|
||||
return ref.mounted ? _current : next;
|
||||
}
|
||||
|
||||
Future<void> loadInitialData() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await build());
|
||||
await _loadInitialData(forceRefresh: true);
|
||||
}
|
||||
|
||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||
if (!ref.mounted) return;
|
||||
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
||||
}
|
||||
|
||||
Future<void> refreshStore() async {
|
||||
await _refreshStore(current: _current.copyWith(loadingStore: true));
|
||||
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
||||
}
|
||||
|
||||
Future<void> loadMoreStore() async {
|
||||
final current = _current;
|
||||
final pagination = current.storePagination;
|
||||
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
||||
final revision = _storeRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
||||
try {
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: pagination.page + 1,
|
||||
limit: pagination.limit,
|
||||
sort: current.storeSort,
|
||||
query: current.storeQuery,
|
||||
);
|
||||
final page = await ref
|
||||
.read(gopeedSiteApiProvider)
|
||||
.getExtensions(
|
||||
page: pagination.page + 1,
|
||||
limit: pagination.limit,
|
||||
sort: current.storeSort,
|
||||
query: current.storeQuery,
|
||||
);
|
||||
if (!ref.mounted || revision != _storeRevision) return;
|
||||
state = AsyncValue.data(
|
||||
_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,
|
||||
loadingMoreStore: false,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!ref.mounted || revision != _storeRevision) return;
|
||||
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchStore(String query) async {
|
||||
if (query.trim() == _current.storeQuery) return;
|
||||
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
||||
await refreshStore();
|
||||
await _refreshStore(current: _current);
|
||||
}
|
||||
|
||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||
if (sort == _current.storeSort) return;
|
||||
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
||||
await refreshStore();
|
||||
await _refreshStore(current: _current);
|
||||
}
|
||||
|
||||
void changeFilter(ExtensionListFilter filter) {
|
||||
@@ -257,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkUpdate() async {
|
||||
final flags = <String, String>{};
|
||||
for (final ext in _current.installedExtensions) {
|
||||
try {
|
||||
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
||||
if (resp.newVersion.isNotEmpty) {
|
||||
flags[ext.identity] = resp.newVersion;
|
||||
Future<void> checkUpdate() {
|
||||
if (!ref.mounted) return Future.value();
|
||||
_checkUpdatesAgain = true;
|
||||
// Installation/refresh can request a fresh scan while one is running.
|
||||
// Coalesce those requests instead of launching overlapping Git operations.
|
||||
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (_devModeCount == 0) {
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (_current.devMode) return;
|
||||
if (!ref.mounted || _current.devMode) return;
|
||||
_devModeCount = 0;
|
||||
});
|
||||
}
|
||||
@@ -286,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
||||
|
||||
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
||||
final revision = ++_installedRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
||||
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));
|
||||
if (refreshUpdates) {
|
||||
await checkUpdate();
|
||||
unawaited(checkUpdate());
|
||||
}
|
||||
return _current;
|
||||
}
|
||||
|
||||
Future<void> _refreshStore({required ExtensionsState current}) async {
|
||||
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sort: _current.storeSort,
|
||||
query: _current.storeQuery,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||
);
|
||||
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
||||
final revision = ++_storeRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
||||
try {
|
||||
final page = await ref
|
||||
.read(gopeedSiteApiProvider)
|
||||
.getExtensions(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
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 {
|
||||
@@ -314,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||
if (ref.mounted) {
|
||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _bumpStoreInstallCount(String id) {
|
||||
if (!ref.mounted) return;
|
||||
final current = _current;
|
||||
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
@@ -344,14 +399,17 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
topics: ext.topics,
|
||||
createdAt: ext.createdAt,
|
||||
updatedAt: ext.updatedAt,
|
||||
hasDetails: ext.hasDetails,
|
||||
);
|
||||
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
||||
}
|
||||
|
||||
void _reportInstallSafe(String id) {
|
||||
if (!ref.mounted) return;
|
||||
final siteApi = ref.read(gopeedSiteApiProvider);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||
await siteApi.reportExtensionInstall(id);
|
||||
} catch (_) {}
|
||||
}());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 api = ref.watch(gopeedSiteApiProvider);
|
||||
// Keep the whole three-attempt cycle loading; expose only the final error.
|
||||
for (var attempt = 0; ; attempt++) {
|
||||
try {
|
||||
final extension = await api.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;
|
||||
} catch (_) {
|
||||
if (attempt == 2 || !ref.mounted) rethrow;
|
||||
await Future<void>.delayed(Duration(milliseconds: 300 * (attempt + 1)));
|
||||
if (!ref.mounted) rethrow;
|
||||
}
|
||||
}
|
||||
},
|
||||
// The bounded cycle above owns retries, including after a manual reload.
|
||||
retry: (retryCount, error) => null,
|
||||
);
|
||||
@@ -17,7 +17,9 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_swipe_tabs.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
@@ -124,10 +126,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
child: stateAsync.when(
|
||||
loading: () => _buildContent(const ExtensionsState(loadingInstalled: true, loadingStore: true)),
|
||||
error: (error, _) => _ErrorState(
|
||||
error: error,
|
||||
onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData(),
|
||||
),
|
||||
error: (error, _) =>
|
||||
_ErrorState(onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData()),
|
||||
data: (state) {
|
||||
if (state.listFilter == ExtensionListFilter.market &&
|
||||
state.storePagination?.hasNext == true &&
|
||||
@@ -409,11 +409,17 @@ class _Content extends StatelessWidget {
|
||||
)
|
||||
else if (state.displayItems.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.noExtensions,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 14),
|
||||
),
|
||||
hasScrollBody: false,
|
||||
child: AppEmptyState(
|
||||
key: const ValueKey('extensions-empty-state'),
|
||||
illustration:
|
||||
state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty
|
||||
? AppEmptyIllustrationType.search
|
||||
: AppEmptyIllustrationType.puzzle,
|
||||
illustrationKey: const ValueKey('extensions-empty-illustration'),
|
||||
message: state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty
|
||||
? context.l10n.extensionStoreEmpty
|
||||
: context.l10n.noExtensions,
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -663,12 +669,11 @@ class _Toolbar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final search = AppTextField(
|
||||
final search = AppSearchField(
|
||||
key: const ValueKey('extension-search-input'),
|
||||
controller: searchController,
|
||||
padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null,
|
||||
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
|
||||
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
|
||||
onSubmitted: onSearch,
|
||||
);
|
||||
final sorting = Row(
|
||||
@@ -1255,36 +1260,23 @@ class _ExtensionSettingsPanel extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({required this.error, required this.onRetry});
|
||||
const _ErrorState({required this.onRetry});
|
||||
|
||||
final Object error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.extension_off_outlined, size: 32, color: palette.error),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.unableLoadExtensions,
|
||||
style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: palette.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)),
|
||||
],
|
||||
),
|
||||
return AppEmptyState(
|
||||
key: const ValueKey('extensions-error-state'),
|
||||
illustration: AppEmptyIllustrationType.connectionError,
|
||||
illustrationKey: const ValueKey('extensions-error-illustration'),
|
||||
message: context.l10n.unableLoadExtensions,
|
||||
action: AppEmptyStateAction(
|
||||
buttonKey: const ValueKey('extensions-error-retry'),
|
||||
icon: Icons.refresh_rounded,
|
||||
label: context.l10n.retry,
|
||||
hint: context.l10n.emptyStateRetryHint,
|
||||
onPressed: onRetry,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../application/extensions_controller.dart';
|
||||
import '../../application/store_extension_details_provider.dart';
|
||||
import 'extension_icon.dart';
|
||||
import 'extension_update_dialog.dart';
|
||||
import 'extension_update_status.dart';
|
||||
@@ -63,7 +65,16 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final palette = AppPalette.of(context);
|
||||
final state = ref.watch(extensionsControllerProvider).value;
|
||||
final current = state?.findItem(item.id) ?? item;
|
||||
final summary = state?.findItem(item.id) ?? item;
|
||||
final summaryStore = summary.store;
|
||||
// Native installed extensions provide their manifest and local README.
|
||||
// Web cannot read the local files and still needs the store README.
|
||||
final useLocalDetails = summary.isInstalled && !Util.isWeb();
|
||||
final detailKey = useLocalDetails || 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 store = current.store;
|
||||
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
||||
@@ -116,7 +127,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
'v${current.version}',
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (store != null) _ExtensionStats(store: store),
|
||||
if (summaryStore != null) _ExtensionStats(store: summaryStore),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -164,7 +175,31 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: mobile ? 24 : 28),
|
||||
if (details?.isLoading == true)
|
||||
const Padding(
|
||||
key: ValueKey('extension-details-loading'),
|
||||
padding: EdgeInsets.only(top: 24),
|
||||
child: Center(
|
||||
child: SizedBox.square(dimension: 44, child: Center(child: shad.CircularProgressIndicator())),
|
||||
),
|
||||
),
|
||||
if (details?.hasError == true && details?.isLoading != true && detailKey != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: AppEmptyState(
|
||||
key: const ValueKey('extension-details-error-state'),
|
||||
illustration: AppEmptyIllustrationType.documentError,
|
||||
message: context.l10n.unableLoadExtensionDetails,
|
||||
illustrationKey: const ValueKey('extension-details-error-illustration'),
|
||||
action: AppEmptyStateAction(
|
||||
buttonKey: const ValueKey('extension-details-retry'),
|
||||
icon: Icons.refresh_rounded,
|
||||
label: context.l10n.retry,
|
||||
hint: context.l10n.emptyStateRetryHint,
|
||||
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
|
||||
),
|
||||
),
|
||||
),
|
||||
_ExtensionReadme(
|
||||
// Snapshot the version so upgrades reload the installed README.
|
||||
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
|
||||
@@ -213,7 +248,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
if (info.content.trim().isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
key: const ValueKey('extension-details-readme'),
|
||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||
padding: const EdgeInsets.only(top: AppDesignTokens.space24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../tasks/presentation/widgets/task_create_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
|
||||
class TasksTopBar extends StatelessWidget {
|
||||
@@ -111,14 +111,14 @@ class _SearchField extends StatelessWidget {
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: AppTextField(
|
||||
child: AppSearchField(
|
||||
controller: controller,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1),
|
||||
placeholder: Text(
|
||||
context.l10n.searchDownloads,
|
||||
style: TextStyle(color: palette.searchHint, fontSize: 14, height: 1),
|
||||
),
|
||||
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 14, color: palette.textMuted))],
|
||||
iconSize: 14,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.inputBg,
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
|
||||
class HistorySearchField extends StatelessWidget {
|
||||
const HistorySearchField({super.key, required this.controller, required this.onChanged});
|
||||
@@ -18,7 +18,7 @@ class HistorySearchField extends StatelessWidget {
|
||||
return ConstrainedBox(
|
||||
// Allow large accessibility text to grow instead of clipping the line.
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
child: AppTextField(
|
||||
child: AppSearchField(
|
||||
key: const ValueKey('create-history-filter'),
|
||||
controller: controller,
|
||||
style: textStyle,
|
||||
@@ -30,7 +30,7 @@ class HistorySearchField extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
features: [InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted))],
|
||||
iconSize: 15,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
|
||||
/// Keeps the action inside the localized sentence, including RTL word order.
|
||||
class TaskEmptyCreateHint extends StatelessWidget {
|
||||
const TaskEmptyCreateHint({super.key, required this.onCreateTask});
|
||||
|
||||
@@ -12,38 +10,12 @@ class TaskEmptyCreateHint extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const actionMarker = '\uFFFC';
|
||||
final parts = context.l10n.emptyTaskCreateHint(actionMarker).split(actionMarker);
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: parts.first.trimRight()),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: AppTooltip(
|
||||
message: context.l10n.create,
|
||||
child: Semantics(
|
||||
label: context.l10n.create,
|
||||
child: SizedBox.square(
|
||||
dimension: 30,
|
||||
child: IconButton.outline(
|
||||
key: const ValueKey('tasks-empty-create-button'),
|
||||
density: ButtonDensity.compact,
|
||||
onPressed: onCreateTask,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: parts.last.trimLeft()),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13),
|
||||
return AppEmptyStateAction(
|
||||
buttonKey: const ValueKey('tasks-empty-create-button'),
|
||||
icon: Icons.add,
|
||||
label: context.l10n.create,
|
||||
hint: context.l10n.emptyTaskCreateHint,
|
||||
onPressed: onCreateTask,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import 'task_empty_create_hint.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import 'task_empty_create_hint.dart';
|
||||
|
||||
class TaskEmptyState extends StatelessWidget {
|
||||
const TaskEmptyState({super.key, this.message, this.onCreateTask});
|
||||
@@ -13,101 +12,11 @@ class TaskEmptyState extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomPaint(
|
||||
key: const ValueKey('task-empty-illustration'),
|
||||
size: const Size(148, 108),
|
||||
painter: _TaskEmptyIllustrationPainter(palette: palette),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
message ?? context.l10n.emptyTaskList,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (onCreateTask != null) ...[
|
||||
const SizedBox(height: AppDesignTokens.space24),
|
||||
TaskEmptyCreateHint(onCreateTask: onCreateTask!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return AppEmptyState(
|
||||
message: message ?? context.l10n.emptyTaskList,
|
||||
illustration: AppEmptyIllustrationType.inbox,
|
||||
illustrationKey: const ValueKey('task-empty-illustration'),
|
||||
action: onCreateTask == null ? null : TaskEmptyCreateHint(onCreateTask: onCreateTask!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskEmptyIllustrationPainter extends CustomPainter {
|
||||
const _TaskEmptyIllustrationPainter({required this.palette});
|
||||
|
||||
final AppPalette palette;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final borderPaint = Paint()
|
||||
..color = palette.border
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4;
|
||||
final sheetRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 34, 6, 68, 72), const Radius.circular(7));
|
||||
|
||||
canvas.drawRRect(sheetRect.shift(const Offset(0, 3)), Paint()..color = palette.textMuted.withValues(alpha: 0.06));
|
||||
canvas.drawRRect(sheetRect, Paint()..color = palette.cardBg);
|
||||
canvas.drawRRect(sheetRect, borderPaint);
|
||||
|
||||
final rowPaint = Paint()
|
||||
..color = palette.textMuted.withValues(alpha: 0.34)
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round;
|
||||
final centerX = size.width / 2;
|
||||
for (var row = 0; row < 2; row++) {
|
||||
final y = 20.0 + row * 14;
|
||||
canvas.drawCircle(Offset(centerX - 22, y), 2.5, Paint()..color = palette.surfaceSoft);
|
||||
canvas.drawLine(Offset(centerX - 14, y), Offset(centerX + 21, y), rowPaint);
|
||||
}
|
||||
|
||||
final emptyBadgePaint = Paint()
|
||||
..color = palette.surfaceSoft
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(Offset(centerX, 56), 11, emptyBadgePaint);
|
||||
canvas.drawCircle(Offset(centerX, 56), 11, borderPaint);
|
||||
canvas.drawLine(Offset(centerX - 4, 56), Offset(centerX + 4, 56), rowPaint);
|
||||
|
||||
final trayPath = Path()
|
||||
..moveTo(26, 63)
|
||||
..lineTo(49, 63)
|
||||
..lineTo(57, 75)
|
||||
..quadraticBezierTo(59, 78, 63, 78)
|
||||
..lineTo(85, 78)
|
||||
..quadraticBezierTo(89, 78, 91, 75)
|
||||
..lineTo(99, 63)
|
||||
..lineTo(122, 63)
|
||||
..lineTo(115, 96)
|
||||
..quadraticBezierTo(114, 101, 108, 101)
|
||||
..lineTo(40, 101)
|
||||
..quadraticBezierTo(34, 101, 33, 96)
|
||||
..close();
|
||||
canvas.drawPath(trayPath, Paint()..color = palette.surfaceSoft);
|
||||
final trayPaint = Paint()
|
||||
..color = palette.textSecondary.withValues(alpha: 0.72)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.8
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
canvas.drawPath(trayPath, trayPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TaskEmptyIllustrationPainter oldDelegate) {
|
||||
return oldDelegate.palette != palette;
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "هل أنت متأكد أنك تريد إلغاء تثبيت \"{name}\"؟",
|
||||
"uninstall": "إلغاء التثبيت",
|
||||
"unableLoadExtensions": "غير قادر على تمديد الشحنات",
|
||||
"unableLoadExtensionDetails": "تعذّر تحميل تفاصيل الإضافة",
|
||||
"filters": "عوامل التصفية",
|
||||
"allTasks": "جميع المهام",
|
||||
"searchDownloads": "ابحثوا عن التحميلات",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "إذا قمت بتكوين رمز API، فيجب تمرير معلومات المصادقة عند ربط الوكيل",
|
||||
"useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة",
|
||||
"mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP.",
|
||||
"emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل"
|
||||
"emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل",
|
||||
"emptyStateRetryHint": "اضغط على {action} لإعادة المحاولة"
|
||||
}
|
||||
@@ -334,6 +334,7 @@
|
||||
"removeExtensionConfirm": "“{name}” uzantısını sistemdən silmək istədiyinizə əminsiniz?",
|
||||
"uninstall": "Sistemdən sil",
|
||||
"unableLoadExtensions": "Uzantıları yükləmək mümkün olmadı",
|
||||
"unableLoadExtensionDetails": "Əlavənin təfərrüatlarını yükləmək mümkün olmadı",
|
||||
"filters": "Süzgəclər",
|
||||
"allTasks": "Bütün Tapşırıqlar",
|
||||
"searchDownloads": "Endirmələri axtar…",
|
||||
@@ -636,5 +637,6 @@
|
||||
"useMcpApiTokenTemplate": "Doğrulamalı konfiqurasiyaya keç",
|
||||
"webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.",
|
||||
"mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur.",
|
||||
"emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin"
|
||||
"emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin",
|
||||
"emptyStateRetryHint": "Yenidən cəhd etmək üçün {action} düyməsinə klikləyin"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Esteu segur que voleu desinstal·lar “{name}”?",
|
||||
"uninstall": "Desinstal· la",
|
||||
"unableLoadExtensions": "No s' han pogut carregar les extensions",
|
||||
"unableLoadExtensionDetails": "No s’han pogut carregar els detalls de l’extensió",
|
||||
"filters": "Filtres",
|
||||
"allTasks": "Totes les tasques",
|
||||
"searchDownloads": "Cerca baixades...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Si heu configurat un token d’API, cal proporcionar la informació d’autenticació en connectar l’agent",
|
||||
"useMcpApiTokenTemplate": "Canvia a la configuració autenticada",
|
||||
"mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP.",
|
||||
"emptyTaskCreateHint": "Fes clic a {action} per començar a baixar"
|
||||
"emptyTaskCreateHint": "Fes clic a {action} per començar a baixar",
|
||||
"emptyStateRetryHint": "Fes clic a {action} per tornar-ho a provar"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Sind Sie sicher, dass Sie „{name}“ deinstallieren möchten?",
|
||||
"uninstall": "Deinstallation",
|
||||
"unableLoadExtensions": "Erweiterungen können nicht geladen werden",
|
||||
"unableLoadExtensionDetails": "Erweiterungsdetails konnten nicht geladen werden",
|
||||
"filters": "Filter",
|
||||
"allTasks": "Alle Aufgaben",
|
||||
"searchDownloads": "Downloads suchen…",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Wenn du ein API-Token konfiguriert hast, müssen beim Verbinden des Agents Authentifizierungsinformationen übermittelt werden",
|
||||
"useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln",
|
||||
"mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist.",
|
||||
"emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten"
|
||||
"emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten",
|
||||
"emptyStateRetryHint": "Klicke auf {action}, um es erneut zu versuchen"
|
||||
}
|
||||
@@ -344,6 +344,7 @@
|
||||
"removeExtensionConfirm": "Are you sure you want to uninstall “{name}”?",
|
||||
"uninstall": "Uninstall",
|
||||
"unableLoadExtensions": "Unable to load extensions",
|
||||
"unableLoadExtensionDetails": "Unable to load extension details",
|
||||
"filters": "Filters",
|
||||
"allTasks": "All Tasks",
|
||||
"searchDownloads": "Search downloads…",
|
||||
@@ -654,5 +655,14 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"emptyStateRetryHint": "Click {action} to retry",
|
||||
"@emptyStateRetryHint": {
|
||||
"description": "Empty-state guidance; action is replaced with an inline icon button.",
|
||||
"placeholders": {
|
||||
"action": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "¿Está seguro de que desea desinstalar “{name}”?",
|
||||
"uninstall": "Desinstalación",
|
||||
"unableLoadExtensions": "No se puede cargar extensiones",
|
||||
"unableLoadExtensionDetails": "No se pudieron cargar los detalles de la extensión",
|
||||
"filters": "Filtros",
|
||||
"allTasks": "Todas las tareas",
|
||||
"searchDownloads": "Búsqueda descargas...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Si has configurado un token de API, debes proporcionar la información de autenticación al conectar el agente",
|
||||
"useMcpApiTokenTemplate": "Cambiar a la configuración autenticada",
|
||||
"mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP.",
|
||||
"emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar"
|
||||
"emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar",
|
||||
"emptyStateRetryHint": "Haz clic en {action} para reintentar"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "آیا مطمئن هستید که می خواهید \"{name}\" را حذف نصب کنید؟",
|
||||
"uninstall": "حذف نصب",
|
||||
"unableLoadExtensions": "افزونهها بارگیری نشدند",
|
||||
"unableLoadExtensionDetails": "بارگیری جزئیات افزونه ناموفق بود",
|
||||
"filters": "فیلترها",
|
||||
"allTasks": "همه وظایف",
|
||||
"searchDownloads": "جستجوی دانلودها",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "اگر توکن API را پیکربندی کردهاید، هنگام اتصال عامل باید اطلاعات احراز هویت را ارسال کنید",
|
||||
"useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت",
|
||||
"mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد.",
|
||||
"emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید"
|
||||
"emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید",
|
||||
"emptyStateRetryHint": "برای تلاش مجدد روی {action} کلیک کنید"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Etes-vous sûr de vouloir désinstaller « {name} » ?",
|
||||
"uninstall": "Désinstaller",
|
||||
"unableLoadExtensions": "Impossible de charger les extensions",
|
||||
"unableLoadExtensionDetails": "Impossible de charger les détails de l’extension",
|
||||
"filters": "Filtres",
|
||||
"allTasks": "Toutes les tâches",
|
||||
"searchDownloads": "Recherche de téléchargements...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Si vous avez configuré un jeton API, les informations d’authentification doivent être transmises lors de la connexion de l’agent",
|
||||
"useMcpApiTokenTemplate": "Passer à la configuration authentifiée",
|
||||
"mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP.",
|
||||
"emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement"
|
||||
"emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement",
|
||||
"emptyStateRetryHint": "Cliquez sur {action} pour réessayer"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Biztos, hogy el kívánja távolítani a „{name}” programot?",
|
||||
"uninstall": "Eltávolítás",
|
||||
"unableLoadExtensions": "Nem lehet betölteni a bővítményeket",
|
||||
"unableLoadExtensionDetails": "Nem sikerült betölteni a bővítmény részleteit",
|
||||
"filters": "szűrők",
|
||||
"allTasks": "Minden feladat",
|
||||
"searchDownloads": "Letöltések keresése…",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Ha API-tokent állítottál be, az ügynök csatlakoztatásakor meg kell adni a hitelesítési adatokat",
|
||||
"useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra",
|
||||
"mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP.",
|
||||
"emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához"
|
||||
"emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához",
|
||||
"emptyStateRetryHint": "Kattints a {action} gombra az újrapróbálkozáshoz"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Apakah Anda yakin ingin menghapus instalasi “{name}”?",
|
||||
"uninstall": "Batalkan",
|
||||
"unableLoadExtensions": "Tidak dapat memuat ekstensi",
|
||||
"unableLoadExtensionDetails": "Tidak dapat memuat detail ekstensi",
|
||||
"filters": "Filter",
|
||||
"allTasks": "Semua Tugas",
|
||||
"searchDownloads": "Cari pengunduhan...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Jika Anda telah mengatur token API, informasi autentikasi harus disertakan saat menghubungkan agen",
|
||||
"useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi",
|
||||
"mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP.",
|
||||
"emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh"
|
||||
"emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh",
|
||||
"emptyStateRetryHint": "Klik {action} untuk mencoba lagi"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Sei sicuro di voler disinstallare “{name}”?",
|
||||
"uninstall": "Disinstallare",
|
||||
"unableLoadExtensions": "Incapace di caricare le estensioni",
|
||||
"unableLoadExtensionDetails": "Impossibile caricare i dettagli dell’estensione",
|
||||
"filters": "Filtri",
|
||||
"allTasks": "Tutte le attività",
|
||||
"searchDownloads": "Cerca downloads...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Se hai configurato un token API, devi fornire le informazioni di autenticazione quando colleghi l’agente",
|
||||
"useMcpApiTokenTemplate": "Passa alla configurazione autenticata",
|
||||
"mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP.",
|
||||
"emptyTaskCreateHint": "Fai clic su {action} per iniziare il download"
|
||||
"emptyTaskCreateHint": "Fai clic su {action} per iniziare il download",
|
||||
"emptyStateRetryHint": "Fai clic su {action} per riprovare"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "「{name}」をアンインストールしたいですか?",
|
||||
"uninstall": "アンインストール",
|
||||
"unableLoadExtensions": "拡張機能を読み込むことができない",
|
||||
"unableLoadExtensionDetails": "拡張機能の詳細を読み込めませんでした",
|
||||
"filters": "フィルター",
|
||||
"allTasks": "すべてのタスク",
|
||||
"searchDownloads": "ダウンロード検索...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "API トークンを設定している場合、エージェント接続時に認証情報を渡す必要があります",
|
||||
"useMcpApiTokenTemplate": "認証付き設定に切り替える",
|
||||
"mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。",
|
||||
"emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始"
|
||||
"emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始",
|
||||
"emptyStateRetryHint": "{action}をクリックして再試行"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "당신은 \"{name}\"을 제거하고 싶지 않습니까?",
|
||||
"uninstall": "Uninstaller를 제거",
|
||||
"unableLoadExtensions": "확장을 로드할 수 없습니다",
|
||||
"unableLoadExtensionDetails": "확장 프로그램 세부 정보를 불러올 수 없습니다",
|
||||
"filters": "필터링",
|
||||
"allTasks": "모든 작업",
|
||||
"searchDownloads": "다운로드 수...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "API 토큰을 설정한 경우 에이전트 연결 시 인증 정보를 전달해야 합니다",
|
||||
"useMcpApiTokenTemplate": "인증 구성으로 전환",
|
||||
"mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다.",
|
||||
"emptyTaskCreateHint": "{action}을 눌러 다운로드 시작"
|
||||
"emptyTaskCreateHint": "{action}을 눌러 다운로드 시작",
|
||||
"emptyStateRetryHint": "{action}을 눌러 다시 시도"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Jesteś pewien, że chcesz odblokować \"{name}\"?",
|
||||
"uninstall": "Odinstaluj",
|
||||
"unableLoadExtensions": "Nie można wczytać rozszerzeń",
|
||||
"unableLoadExtensionDetails": "Nie można wczytać szczegółów rozszerzenia",
|
||||
"filters": "Filtry",
|
||||
"allTasks": "Wszystkie zadania",
|
||||
"searchDownloads": "Wyszukaj pliki do pobrania...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Jeśli skonfigurowano token API, podczas łączenia agenta należy przekazać dane uwierzytelniające",
|
||||
"useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem",
|
||||
"mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP.",
|
||||
"emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie"
|
||||
"emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie",
|
||||
"emptyStateRetryHint": "Kliknij {action}, aby spróbować ponownie"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Tem certeza de que deseja desinstalar “{name}”?",
|
||||
"uninstall": "Desinstalar",
|
||||
"unableLoadExtensions": "Não foi possível carregar extensões",
|
||||
"unableLoadExtensionDetails": "Não foi possível carregar os detalhes da extensão",
|
||||
"filters": "Filtros",
|
||||
"allTasks": "Todas as Tarefas",
|
||||
"searchDownloads": "Procurar downloads...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Se configurou um token de API, é necessário fornecer as informações de autenticação ao ligar o agente",
|
||||
"useMcpApiTokenTemplate": "Mudar para a configuração autenticada",
|
||||
"mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP.",
|
||||
"emptyTaskCreateHint": "Clique em {action} para começar a baixar"
|
||||
"emptyTaskCreateHint": "Clique em {action} para começar a baixar",
|
||||
"emptyStateRetryHint": "Clique em {action} para tentar novamente"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Вы уверены, что хотите удалить «{name}»?",
|
||||
"uninstall": "Удалить",
|
||||
"unableLoadExtensions": "Невозможно загрузить расширения",
|
||||
"unableLoadExtensionDetails": "Не удалось загрузить сведения о расширении",
|
||||
"filters": "Фильтры",
|
||||
"allTasks": "Все задачи",
|
||||
"searchDownloads": "Поисковые загрузки...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Если настроен API-токен, при подключении агента необходимо передать данные аутентификации",
|
||||
"useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией",
|
||||
"mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP.",
|
||||
"emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку"
|
||||
"emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку",
|
||||
"emptyStateRetryHint": "Нажмите {action}, чтобы повторить попытку"
|
||||
}
|
||||
@@ -343,6 +343,7 @@
|
||||
"removeExtensionConfirm": "Naozaj chcete odinštalovať „{name}“?",
|
||||
"uninstall": "Odinštalovať",
|
||||
"unableLoadExtensions": "Nepodarilo sa načítať rozšírenia",
|
||||
"unableLoadExtensionDetails": "Nepodarilo sa načítať podrobnosti rozšírenia",
|
||||
"filters": "Filtre",
|
||||
"allTasks": "Všetky úlohy",
|
||||
"searchDownloads": "Hľadať sťahovania…",
|
||||
@@ -653,5 +654,6 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"emptyStateRetryHint": "Kliknutím na {action} skúsite znova"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "\"{name}\" ஐ நிச்சயமாக நிறுவல் நீக்க விரும்புகிறீர்களா?",
|
||||
"uninstall": "நீக்குக",
|
||||
"unableLoadExtensions": "நீட்டிப்புகளை ஏற்ற முடியவில்லை",
|
||||
"unableLoadExtensionDetails": "நீட்டிப்பின் விவரங்களை ஏற்ற முடியவில்லை",
|
||||
"filters": "வடிக்கட்டிகள்",
|
||||
"allTasks": "அனைத்து பணிகளையும் நகலெடு",
|
||||
"searchDownloads": "பதிவிறக்கங்களைத் தேடுங்கள்...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "API டோக்கனை அமைத்திருந்தால், முகவரை இணைக்கும்போது அங்கீகாரத் தகவலை அனுப்ப வேண்டும்",
|
||||
"useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு",
|
||||
"mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்.",
|
||||
"emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்"
|
||||
"emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்",
|
||||
"emptyStateRetryHint": "மீண்டும் முயற்சிக்க {action} ஐ அழுத்தவும்"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "“{name}” yüklememek istediğinizden emin misiniz?",
|
||||
"uninstall": "Kaldır",
|
||||
"unableLoadExtensions": "uzatmaları yükleyemez",
|
||||
"unableLoadExtensionDetails": "Eklenti ayrıntıları yüklenemedi",
|
||||
"filters": "Filtreler",
|
||||
"allTasks": "Tüm Görevler",
|
||||
"searchDownloads": "Arama indirs...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Bir API anahtarı yapılandırdıysanız, ajan bağlanırken kimlik doğrulama bilgileri iletilmelidir",
|
||||
"useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç",
|
||||
"mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir.",
|
||||
"emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın"
|
||||
"emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın",
|
||||
"emptyStateRetryHint": "Yeniden denemek için {action} düğmesine tıklayın"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Ви впевнені, що ви хочете видалити \"{name}\"?",
|
||||
"uninstall": "Видаляє",
|
||||
"unableLoadExtensions": "Неможливо до розширення навантаження",
|
||||
"unableLoadExtensionDetails": "Не вдалося завантажити відомості про розширення",
|
||||
"filters": "Фільтри",
|
||||
"allTasks": "Всі завдання",
|
||||
"searchDownloads": "Пошук завантажень...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Якщо налаштовано API-токен, під час підключення агента потрібно передати дані автентифікації",
|
||||
"useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією",
|
||||
"mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP.",
|
||||
"emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження"
|
||||
"emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження",
|
||||
"emptyStateRetryHint": "Натисніть {action}, щоб спробувати ще раз"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "Bạn có chắc muốn gỡ bỏ cài đặt “{name} chiếc xe không?",
|
||||
"uninstall": "Hủy cài đặt",
|
||||
"unableLoadExtensions": "Không thể nạp phần mở rộng",
|
||||
"unableLoadExtensionDetails": "Không thể tải thông tin chi tiết tiện ích mở rộng",
|
||||
"filters": "Lọc",
|
||||
"allTasks": "Mọi tác vụ",
|
||||
"searchDownloads": "Tải về...",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "Nếu đã cấu hình API Token, bạn cần truyền thông tin xác thực khi kết nối tác nhân",
|
||||
"useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực",
|
||||
"mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP.",
|
||||
"emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống"
|
||||
"emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống",
|
||||
"emptyStateRetryHint": "Nhấn {action} để thử lại"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "确定要卸载“{name}”吗?",
|
||||
"uninstall": "卸载",
|
||||
"unableLoadExtensions": "无法加载扩展",
|
||||
"unableLoadExtensionDetails": "扩展详情加载失败",
|
||||
"filters": "筛选",
|
||||
"allTasks": "全部任务",
|
||||
"searchDownloads": "搜索下载任务…",
|
||||
@@ -452,5 +453,6 @@
|
||||
"useMcpApiTokenTemplate": "切换为认证配置",
|
||||
"webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。",
|
||||
"mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。",
|
||||
"emptyTaskCreateHint": "点击{action}开始下载"
|
||||
"emptyTaskCreateHint": "点击{action}开始下载",
|
||||
"emptyStateRetryHint": "点击{action}重新加载"
|
||||
}
|
||||
@@ -320,6 +320,7 @@
|
||||
"removeExtensionConfirm": "確定要解除安裝「{name}」嗎?",
|
||||
"uninstall": "解除安裝",
|
||||
"unableLoadExtensions": "無法載入擴充套件",
|
||||
"unableLoadExtensionDetails": "擴充功能詳情載入失敗",
|
||||
"filters": "篩選",
|
||||
"allTasks": "所有任務",
|
||||
"searchDownloads": "搜尋下載任務…",
|
||||
@@ -452,5 +453,6 @@
|
||||
"webMcpApiTokenHint": "若已設定 API Token,接入 Agent 時需要傳遞認證資訊",
|
||||
"useMcpApiTokenTemplate": "切換為認證設定",
|
||||
"mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。",
|
||||
"emptyTaskCreateHint": "點擊{action}開始下載"
|
||||
"emptyTaskCreateHint": "點擊{action}開始下載",
|
||||
"emptyStateRetryHint": "點擊{action}重新載入"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../theme/app_palette.dart';
|
||||
|
||||
enum AppEmptyIllustrationType { inbox, puzzle, search, connectionError, documentError }
|
||||
|
||||
/// Context-specific drawings share the same muted colors and rounded strokes.
|
||||
class AppEmptyIllustration extends StatelessWidget {
|
||||
const AppEmptyIllustration({super.key, required this.type});
|
||||
|
||||
final AppEmptyIllustrationType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return CustomPaint(
|
||||
size: const Size(148, 108),
|
||||
painter: type == AppEmptyIllustrationType.inbox
|
||||
? _InboxIllustrationPainter(palette: palette)
|
||||
: _ContextIllustrationPainter(palette: palette, type: type),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContextIllustrationPainter extends CustomPainter {
|
||||
const _ContextIllustrationPainter({required this.palette, required this.type});
|
||||
|
||||
final AppPalette palette;
|
||||
final AppEmptyIllustrationType type;
|
||||
|
||||
Paint get _outline => Paint()
|
||||
..color = palette.textSecondary.withValues(alpha: 0.72)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.8
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
Paint get _detail => Paint()
|
||||
..color = palette.textMuted.withValues(alpha: 0.34)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
Paint get _fill => Paint()..color = palette.surfaceSoft;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.save();
|
||||
canvas.scale(size.width / 148, size.height / 108);
|
||||
switch (type) {
|
||||
case AppEmptyIllustrationType.puzzle:
|
||||
_paintPuzzle(canvas);
|
||||
case AppEmptyIllustrationType.search:
|
||||
_paintSearch(canvas);
|
||||
case AppEmptyIllustrationType.connectionError:
|
||||
_paintConnectionError(canvas);
|
||||
case AppEmptyIllustrationType.documentError:
|
||||
_paintDocumentError(canvas);
|
||||
case AppEmptyIllustrationType.inbox:
|
||||
break;
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintPuzzle(Canvas canvas) {
|
||||
canvas.translate(9, 0);
|
||||
final piece = Path()
|
||||
..moveTo(42, 29)
|
||||
..lineTo(62, 29)
|
||||
..cubicTo(55, 10, 86, 10, 79, 29)
|
||||
..lineTo(98, 29)
|
||||
..quadraticBezierTo(104, 29, 104, 35)
|
||||
..lineTo(104, 49)
|
||||
..cubicTo(84, 42, 84, 73, 104, 66)
|
||||
..lineTo(104, 86)
|
||||
..quadraticBezierTo(104, 92, 98, 92)
|
||||
..lineTo(79, 92)
|
||||
..cubicTo(86, 73, 55, 73, 62, 92)
|
||||
..lineTo(42, 92)
|
||||
..quadraticBezierTo(36, 92, 36, 86)
|
||||
..lineTo(36, 66)
|
||||
..cubicTo(17, 73, 17, 42, 36, 49)
|
||||
..lineTo(36, 35)
|
||||
..quadraticBezierTo(36, 29, 42, 29)
|
||||
..close();
|
||||
canvas.drawPath(piece, _fill);
|
||||
canvas.drawPath(piece, _outline);
|
||||
}
|
||||
|
||||
void _paintSearch(Canvas canvas) {
|
||||
final lens = const Offset(64, 45);
|
||||
canvas.drawCircle(lens, 29, _fill);
|
||||
canvas.drawCircle(lens, 29, _outline);
|
||||
canvas.drawLine(const Offset(85, 67), const Offset(115, 96), _outline..strokeWidth = 4);
|
||||
canvas.drawLine(const Offset(53, 45), const Offset(75, 45), _detail);
|
||||
canvas.drawLine(const Offset(27, 85), const Offset(41, 85), _detail);
|
||||
canvas.drawCircle(const Offset(112, 23), 3, Paint()..color = palette.border);
|
||||
}
|
||||
|
||||
void _paintConnectionError(Canvas canvas) {
|
||||
final cloud = Path()
|
||||
..moveTo(40, 70)
|
||||
..cubicTo(21, 70, 19, 43, 39, 41)
|
||||
..cubicTo(40, 14, 77, 8, 91, 32)
|
||||
..cubicTo(111, 23, 129, 40, 121, 56)
|
||||
..cubicTo(133, 66, 119, 79, 108, 74)
|
||||
..lineTo(40, 74)
|
||||
..close();
|
||||
canvas.drawPath(cloud, _fill);
|
||||
canvas.drawPath(cloud, _outline);
|
||||
canvas.drawLine(const Offset(74, 74), const Offset(74, 82), _detail);
|
||||
canvas.drawLine(const Offset(48, 96), const Offset(63, 96), _outline);
|
||||
canvas.drawLine(const Offset(86, 96), const Offset(101, 96), _outline);
|
||||
canvas.drawLine(const Offset(71, 91), const Offset(77, 101), _detail);
|
||||
canvas.drawCircle(const Offset(45, 96), 2, _fill);
|
||||
canvas.drawCircle(const Offset(104, 96), 2, _fill);
|
||||
}
|
||||
|
||||
void _paintDocumentError(Canvas canvas) {
|
||||
final document = Path()
|
||||
..moveTo(47, 12)
|
||||
..lineTo(89, 12)
|
||||
..lineTo(107, 30)
|
||||
..lineTo(107, 89)
|
||||
..quadraticBezierTo(107, 96, 100, 96)
|
||||
..lineTo(47, 96)
|
||||
..quadraticBezierTo(40, 96, 40, 89)
|
||||
..lineTo(40, 19)
|
||||
..quadraticBezierTo(40, 12, 47, 12)
|
||||
..close();
|
||||
canvas.drawPath(document, _fill);
|
||||
canvas.drawPath(document, _outline);
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(89, 12)
|
||||
..lineTo(89, 30)
|
||||
..lineTo(107, 30),
|
||||
_detail,
|
||||
);
|
||||
for (final y in [43.0, 56.0, 69.0]) {
|
||||
canvas.drawLine(Offset(53, y), Offset(y == 69 ? 74 : 91, y), _detail);
|
||||
}
|
||||
canvas.drawCircle(const Offset(107, 85), 16, Paint()..color = palette.cardBg);
|
||||
canvas.drawCircle(const Offset(107, 85), 16, _outline);
|
||||
canvas.drawLine(const Offset(107, 77), const Offset(107, 86), _outline);
|
||||
canvas.drawCircle(const Offset(107, 92), 1.2, Paint()..color = palette.textSecondary.withValues(alpha: 0.72));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ContextIllustrationPainter oldDelegate) =>
|
||||
oldDelegate.palette != palette || oldDelegate.type != type;
|
||||
}
|
||||
|
||||
class _InboxIllustrationPainter extends CustomPainter {
|
||||
const _InboxIllustrationPainter({required this.palette});
|
||||
|
||||
final AppPalette palette;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final borderPaint = Paint()
|
||||
..color = palette.border
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4;
|
||||
final sheetRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 34, 6, 68, 72), const Radius.circular(7));
|
||||
|
||||
canvas.drawRRect(sheetRect.shift(const Offset(0, 3)), Paint()..color = palette.textMuted.withValues(alpha: 0.06));
|
||||
canvas.drawRRect(sheetRect, Paint()..color = palette.cardBg);
|
||||
canvas.drawRRect(sheetRect, borderPaint);
|
||||
|
||||
final rowPaint = Paint()
|
||||
..color = palette.textMuted.withValues(alpha: 0.34)
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round;
|
||||
final centerX = size.width / 2;
|
||||
for (var row = 0; row < 2; row++) {
|
||||
final y = 20.0 + row * 14;
|
||||
canvas.drawCircle(Offset(centerX - 22, y), 2.5, Paint()..color = palette.surfaceSoft);
|
||||
canvas.drawLine(Offset(centerX - 14, y), Offset(centerX + 21, y), rowPaint);
|
||||
}
|
||||
|
||||
final emptyBadgePaint = Paint()
|
||||
..color = palette.surfaceSoft
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(Offset(centerX, 56), 11, emptyBadgePaint);
|
||||
canvas.drawCircle(Offset(centerX, 56), 11, borderPaint);
|
||||
canvas.drawLine(Offset(centerX - 4, 56), Offset(centerX + 4, 56), rowPaint);
|
||||
|
||||
final trayPath = Path()
|
||||
..moveTo(26, 63)
|
||||
..lineTo(49, 63)
|
||||
..lineTo(57, 75)
|
||||
..quadraticBezierTo(59, 78, 63, 78)
|
||||
..lineTo(85, 78)
|
||||
..quadraticBezierTo(89, 78, 91, 75)
|
||||
..lineTo(99, 63)
|
||||
..lineTo(122, 63)
|
||||
..lineTo(115, 96)
|
||||
..quadraticBezierTo(114, 101, 108, 101)
|
||||
..lineTo(40, 101)
|
||||
..quadraticBezierTo(34, 101, 33, 96)
|
||||
..close();
|
||||
canvas.drawPath(trayPath, Paint()..color = palette.surfaceSoft);
|
||||
final trayPaint = Paint()
|
||||
..color = palette.textSecondary.withValues(alpha: 0.72)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.8
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
canvas.drawPath(trayPath, trayPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_InboxIllustrationPainter oldDelegate) {
|
||||
return oldDelegate.palette != palette;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../theme/app_design_tokens.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_tooltip.dart';
|
||||
import 'app_empty_illustration.dart';
|
||||
|
||||
export 'app_empty_illustration.dart' show AppEmptyIllustrationType;
|
||||
|
||||
/// Shared empty/error surface used by task and extension views.
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.illustration,
|
||||
this.action,
|
||||
this.illustrationKey,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final AppEmptyIllustrationType illustration;
|
||||
final Widget? action;
|
||||
final Key? illustrationKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppEmptyIllustration(key: illustrationKey, type: illustration),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (action != null) ...[const SizedBox(height: AppDesignTokens.space24), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the action inside localized guidance, matching every empty state.
|
||||
class AppEmptyStateAction extends StatelessWidget {
|
||||
const AppEmptyStateAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.onPressed,
|
||||
this.buttonKey,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String Function(String action) hint;
|
||||
final VoidCallback onPressed;
|
||||
final Key? buttonKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const actionMarker = '\uFFFC';
|
||||
final parts = hint(actionMarker).split(actionMarker);
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: parts.first.trimRight()),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: AppTooltip(
|
||||
message: label,
|
||||
child: Semantics(
|
||||
label: label,
|
||||
child: SizedBox.square(
|
||||
dimension: 30,
|
||||
child: shad.IconButton.outline(
|
||||
key: buttonKey,
|
||||
density: shad.ButtonDensity.compact,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: parts.last.trimLeft()),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart' show Colors, Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_text_field.dart';
|
||||
|
||||
/// Keeps the search icon outside the editable text's horizontal viewport.
|
||||
class AppSearchField extends StatefulWidget {
|
||||
const AppSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.placeholder,
|
||||
this.style,
|
||||
this.padding,
|
||||
this.decoration,
|
||||
this.iconSize = 16,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final Widget? placeholder;
|
||||
final TextStyle? style;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BoxDecoration? decoration;
|
||||
final double iconSize;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
@override
|
||||
State<AppSearchField> createState() => _AppSearchFieldState();
|
||||
}
|
||||
|
||||
class _AppSearchFieldState extends State<AppSearchField> {
|
||||
late final FocusNode _ownedFocusNode = FocusNode();
|
||||
|
||||
FocusNode get _focusNode => widget.focusNode ?? _ownedFocusNode;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ownedFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = shad.Theme.of(context);
|
||||
final fieldTheme = shad.ComponentTheme.maybeOf<shad.TextFieldTheme>(context);
|
||||
final palette = AppPalette.of(context);
|
||||
final padding = widget.padding ?? fieldTheme?.padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 8);
|
||||
final decoration =
|
||||
widget.decoration ??
|
||||
BoxDecoration(
|
||||
borderRadius: fieldTheme?.borderRadius ?? BorderRadius.circular(theme.radiusMd),
|
||||
border: fieldTheme?.border ?? Border.all(color: theme.colorScheme.border),
|
||||
color: fieldTheme?.filled == true ? theme.colorScheme.muted : theme.colorScheme.input.withValues(alpha: 0.3),
|
||||
);
|
||||
return ListenableBuilder(
|
||||
listenable: _focusNode,
|
||||
builder: (context, _) => shad.FocusOutline(
|
||||
focused: _focusNode.hasFocus,
|
||||
borderRadius: decoration.borderRadius,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Container(
|
||||
decoration: decoration,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
padding: padding,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
// A compressed desktop toolbar may leave almost no search
|
||||
// width just before switching to its mobile layout.
|
||||
if (constraints.maxWidth > widget.iconSize + theme.density.baseGap * theme.scaling) ...[
|
||||
Icon(Icons.search_rounded, size: widget.iconSize, color: palette.textMuted),
|
||||
SizedBox(width: theme.density.baseGap * theme.scaling),
|
||||
],
|
||||
Expanded(
|
||||
// The underlying field allows text to paint outside its own
|
||||
// viewport. Clip only the editable area, leaving the icon fixed.
|
||||
child: ClipRect(
|
||||
clipper: const _SearchTextClipper(),
|
||||
child: shad.ComponentTheme<shad.FocusOutlineTheme>(
|
||||
data: const shad.FocusOutlineTheme(
|
||||
align: 0,
|
||||
border: Border.fromBorderSide(BorderSide(color: Colors.transparent, width: 0)),
|
||||
),
|
||||
child: AppTextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
placeholder: widget.placeholder,
|
||||
style: widget.style,
|
||||
padding: EdgeInsets.zero,
|
||||
decoration: const BoxDecoration(),
|
||||
onChanged: widget.onChanged,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchTextClipper extends CustomClipper<Rect> {
|
||||
const _SearchTextClipper();
|
||||
|
||||
// Keep horizontal scrolling out of the icon area without cutting glyphs or
|
||||
// tall cursors in compact fields. The outer field clips its own border.
|
||||
@override
|
||||
Rect getClip(Size size) => Rect.fromLTRB(0, -size.height, size.width, size.height * 2);
|
||||
|
||||
@override
|
||||
bool shouldReclip(_SearchTextClipper oldClipper) => false;
|
||||
}
|
||||
@@ -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,265 @@
|
||||
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);
|
||||
expect(site.detailCalls, 3);
|
||||
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
|
||||
container.invalidate(provider);
|
||||
expect((await container.read(provider.future)).readme, 'loaded');
|
||||
expect(site.detailCalls, 4);
|
||||
});
|
||||
|
||||
test('details automatically recover on the third attempt and retain the result', () async {
|
||||
final site = _SiteApi();
|
||||
site.onDetail = (id, _) async {
|
||||
if (site.detailCalls < 3) throw StateError('offline');
|
||||
return StoreExtension.fromJson({'id': id, 'readme': 'recovered'});
|
||||
};
|
||||
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||
addTearDown(container.dispose);
|
||||
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||
final subscription = container.listen(provider, (_, _) {});
|
||||
expect((await container.read(provider.future)).readme, 'recovered');
|
||||
expect(site.detailCalls, 3);
|
||||
subscription.close();
|
||||
await _settle();
|
||||
expect((await container.read(provider.future)).readme, 'recovered');
|
||||
expect(site.detailCalls, 3);
|
||||
});
|
||||
|
||||
test('closing details stops pending automatic retries', () async {
|
||||
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
|
||||
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||
container.listen(provider, (_, _) {});
|
||||
await _settle();
|
||||
expect(site.detailCalls, 1);
|
||||
container.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 350));
|
||||
expect(site.detailCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
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/extension.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 attempts = List.generate(3, (_) => Completer<StoreExtension>());
|
||||
final site = _DetailApi();
|
||||
site.onRequest = () => attempts[site.detailCalls - 1].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);
|
||||
expect(site.detailCalls, 1);
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
attempts[attempt].completeError(StateError('offline'));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
await tester.pump(Duration(milliseconds: 300 * (attempt + 1)));
|
||||
expect(site.detailCalls, attempt + 2);
|
||||
}
|
||||
attempts.last.completeError(StateError('offline'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsNothing);
|
||||
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-error-illustration')), findsOneWidget);
|
||||
expect(find.text('Unable to load extension details'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
expect(site.detailCalls, 3);
|
||||
final reload = Completer<StoreExtension>();
|
||||
site.onRequest = () => reload.future;
|
||||
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
|
||||
await tester.pump();
|
||||
expect(site.detailCalls, 4);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
reload.complete(
|
||||
StoreExtension.fromJson({
|
||||
'id': 'market',
|
||||
'title': 'Market',
|
||||
'version': '1.0.0',
|
||||
'readme': '# Loaded details',
|
||||
'homepage': 'https://example.com',
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
testWidgets('installed summary uses local details without a remote request at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final directory = Directory.systemTemp.createTempSync('gopeed-installed-detail-');
|
||||
addTearDown(() => directory.deleteSync(recursive: true));
|
||||
File('${directory.path}/README.md').writeAsStringSync('# Installed documentation');
|
||||
final installed = Extension(
|
||||
identity: 'market',
|
||||
name: 'market',
|
||||
author: 'Local author',
|
||||
title: 'Local extension',
|
||||
description: 'Installed manifest',
|
||||
icon: '',
|
||||
version: '1.0.0',
|
||||
homepage: 'https://example.com/local',
|
||||
repository: Repository(url: 'https://github.com/test/local', directory: ''),
|
||||
disabled: false,
|
||||
devMode: true,
|
||||
devPath: directory.path,
|
||||
);
|
||||
final site = _DetailApi()..request = Completer<StoreExtension>().future;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
extensionsControllerProvider.overrideWith(() => _InstalledSummaryController(installed)),
|
||||
gopeedSiteApiProvider.overrideWithValue(site),
|
||||
],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: shad.Scaffold(
|
||||
child: ExtensionDetailView(
|
||||
item: ExtensionListItem(installed: installed, store: _summary()),
|
||||
mobile: width < 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(site.detailCalls, 0);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
expect(find.text('Installed documentation'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
Future<StoreExtension> Function()? onRequest;
|
||||
int detailCalls = 0;
|
||||
|
||||
@override
|
||||
Future<StoreExtension> getExtension(String id, {String? version}) {
|
||||
detailCalls++;
|
||||
return onRequest?.call() ?? request;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _InstalledSummaryController extends ExtensionsController {
|
||||
_InstalledSummaryController(this.installed);
|
||||
|
||||
final Extension installed;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async =>
|
||||
ExtensionsState(installedExtensions: [installed], storeExtensions: [_summary()]);
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_empty_state.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 768.0, 1024.0]) {
|
||||
testWidgets('empty extension market uses the shared state at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState());
|
||||
expect(find.byType(AppEmptyState), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-empty-illustration')), findsOneWidget);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty installed list matches the market and can switch tabs at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState(listFilter: ExtensionListFilter.installed));
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-empty-browse')), findsNothing);
|
||||
await tester.tap(find.text('Market'));
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(tester.element(find.byType(ExtensionsPage)));
|
||||
expect(container.read(extensionsControllerProvider).value!.listFilter, ExtensionListFilter.market);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty search explains no matches at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState(storeQuery: 'no-match'));
|
||||
expect(find.byKey(const ValueKey('extensions-empty-illustration')), findsOneWidget);
|
||||
expect(find.text('No matching extensions found'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extension load failure can retry from the shared state at width $width', (tester) async {
|
||||
final controller = _FailedExtensionsController();
|
||||
await _pumpPage(tester, width, const ExtensionsState(), controller: controller);
|
||||
expect(find.byKey(const ValueKey('extensions-error-illustration')), findsOneWidget);
|
||||
expect(find.text('Unable to load extensions'), findsOneWidget);
|
||||
expect(find.textContaining('test-network-error'), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('extensions-error-retry')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.retryCalls, 1);
|
||||
expect(find.byKey(const ValueKey('extensions-error-state')), findsNothing);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpPage(
|
||||
WidgetTester tester,
|
||||
double width,
|
||||
ExtensionsState initial, {
|
||||
ExtensionsController? controller,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 768);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [extensionsControllerProvider.overrideWith(() => controller ?? _EmptyExtensionsController(initial))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const ExtensionsPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _EmptyExtensionsController extends ExtensionsController {
|
||||
_EmptyExtensionsController(this.initial);
|
||||
|
||||
final ExtensionsState initial;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async => initial;
|
||||
}
|
||||
|
||||
class _FailedExtensionsController extends ExtensionsController {
|
||||
int retryCalls = 0;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async => throw StateError('test-network-error');
|
||||
|
||||
@override
|
||||
Future<void> loadInitialData() async {
|
||||
retryCalls++;
|
||||
state = const AsyncValue.data(ExtensionsState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar, Icons;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_search_field.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final direction in TextDirection.values) {
|
||||
for (final width in [240.0, 480.0]) {
|
||||
testWidgets(
|
||||
'scrolling and selecting long search text preserves the icon ($direction, $width)',
|
||||
(tester) async {
|
||||
final controller = TextEditingController();
|
||||
final focus = FocusNode();
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(focus.dispose);
|
||||
await _pumpField(tester, controller, focus, width: width, direction: direction);
|
||||
focus.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
final iconBefore = await _iconPixels(tester);
|
||||
final iconRect = tester.getRect(find.byIcon(material.Icons.search_rounded));
|
||||
final text = List.filled(
|
||||
20,
|
||||
direction == TextDirection.ltr ? 'long-search-text-' : 'نص البحث الطويل ',
|
||||
).join();
|
||||
controller.value = TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final editable = tester.state<EditableTextState>(find.byType(EditableText));
|
||||
expect(editable.renderEditable.maxScrollExtent, greaterThan(0));
|
||||
editable.renderEditable.offset.jumpTo(editable.renderEditable.maxScrollExtent);
|
||||
await tester.pumpAndSettle();
|
||||
expect(editable.renderEditable.offset.pixels, greaterThan(0));
|
||||
expect(tester.getRect(find.byIcon(material.Icons.search_rounded)), iconRect);
|
||||
expect(await _iconPixels(tester), orderedEquals(iconBefore));
|
||||
controller.selection = TextSelection(baseOffset: 0, extentOffset: text.length);
|
||||
await tester.pumpAndSettle();
|
||||
expect(await _iconPixels(tester), orderedEquals(iconBefore));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'search text keeps Android native selection actions',
|
||||
(tester) async {
|
||||
final controller = TextEditingController(text: 'search selection');
|
||||
final focus = FocusNode();
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(focus.dispose);
|
||||
await _pumpField(tester, controller, focus);
|
||||
await tester.longPress(find.byType(EditableText));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
|
||||
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
|
||||
},
|
||||
skip: kIsWeb,
|
||||
variant: TargetPlatformVariant({TargetPlatform.android}),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'search text keeps desktop native selection actions',
|
||||
(tester) async {
|
||||
final controller = TextEditingController(text: 'search selection');
|
||||
final focus = FocusNode();
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(focus.dispose);
|
||||
await _pumpField(tester, controller, focus);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(EditableText)),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
buttons: kSecondaryMouseButton,
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
|
||||
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
|
||||
},
|
||||
skip: kIsWeb,
|
||||
variant: TargetPlatformVariant({TargetPlatform.macOS}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpField(
|
||||
WidgetTester tester,
|
||||
TextEditingController controller,
|
||||
FocusNode focus, {
|
||||
double width = 320,
|
||||
TextDirection direction = TextDirection.ltr,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Directionality(
|
||||
textDirection: direction,
|
||||
child: Center(
|
||||
child: RepaintBoundary(
|
||||
key: const ValueKey('search-paint-boundary'),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: AppSearchField(controller: controller, focusNode: focus, placeholder: const Text('Search')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<List<int>> _iconPixels(WidgetTester tester) async {
|
||||
final boundaryFinder = find.byKey(const ValueKey('search-paint-boundary'));
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(boundaryFinder);
|
||||
final rect = tester.getRect(find.byIcon(material.Icons.search_rounded)).shift(-tester.getTopLeft(boundaryFinder));
|
||||
final snapshot = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage(pixelRatio: 1);
|
||||
final width = image.width;
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!.buffer.asUint8List();
|
||||
image.dispose();
|
||||
return (width: width, pixels: Uint8List.fromList(bytes));
|
||||
});
|
||||
return [
|
||||
for (var y = rect.top.floor(); y < rect.bottom.ceil(); y++)
|
||||
for (var x = rect.left.floor(); x < rect.right.ceil(); x++)
|
||||
...snapshot!.pixels.sublist((y * snapshot.width + x) * 4, (y * snapshot.width + x) * 4 + 4),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user