From 12860bb207eba875bcee3f5e20767bc5dfddb36b Mon Sep 17 00:00:00 2001 From: Levi Date: Wed, 14 Oct 2026 20:08:41 +0800 Subject: [PATCH] perf(extensions): load store summaries and check updates asynchronously (#1583) --- ui/flutter/lib/api/gopeed_site_api.dart | 61 +++- ui/flutter/lib/api/model/store_extension.dart | 3 + .../application/extensions_controller.dart | 146 +++++++--- .../store_extension_details_provider.dart | 36 +++ .../presentation/pages/extensions_page.dart | 64 ++--- .../widgets/extension_detail_view.dart | 43 ++- .../presentation/widgets/tasks_top_bar.dart | 6 +- .../widgets/history_search_field.dart | 6 +- .../widgets/task_empty_create_hint.dart | 42 +-- .../widgets/task_empty_state.dart | 105 +------ ui/flutter/lib/l10n/app_ar.arb | 4 +- ui/flutter/lib/l10n/app_az.arb | 4 +- ui/flutter/lib/l10n/app_ca.arb | 4 +- ui/flutter/lib/l10n/app_de.arb | 4 +- ui/flutter/lib/l10n/app_en.arb | 10 + ui/flutter/lib/l10n/app_es.arb | 4 +- ui/flutter/lib/l10n/app_fa.arb | 4 +- ui/flutter/lib/l10n/app_fr.arb | 4 +- ui/flutter/lib/l10n/app_hu.arb | 4 +- ui/flutter/lib/l10n/app_id.arb | 4 +- ui/flutter/lib/l10n/app_it.arb | 4 +- ui/flutter/lib/l10n/app_ja.arb | 4 +- ui/flutter/lib/l10n/app_ko.arb | 4 +- ui/flutter/lib/l10n/app_pl.arb | 4 +- ui/flutter/lib/l10n/app_pt.arb | 4 +- ui/flutter/lib/l10n/app_ru.arb | 4 +- ui/flutter/lib/l10n/app_sk.arb | 4 +- ui/flutter/lib/l10n/app_ta.arb | 4 +- ui/flutter/lib/l10n/app_tr.arb | 4 +- ui/flutter/lib/l10n/app_uk.arb | 4 +- ui/flutter/lib/l10n/app_vi.arb | 4 +- ui/flutter/lib/l10n/app_zh.arb | 4 +- ui/flutter/lib/l10n/app_zh_TW.arb | 4 +- .../widgets/app_empty_illustration.dart | 217 ++++++++++++++ .../lib/shared/widgets/app_empty_state.dart | 108 +++++++ .../lib/shared/widgets/app_search_field.dart | 125 +++++++++ ui/flutter/test/api/gopeed_site_api_test.dart | 78 ++++++ .../extensions_controller_test.dart | 265 ++++++++++++++++++ .../extension_detail_loading_test.dart | 181 ++++++++++++ .../widgets/extensions_empty_state_test.dart | 99 +++++++ .../shared/widgets/app_search_field_test.dart | 141 ++++++++++ 41 files changed, 1567 insertions(+), 257 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart create mode 100644 ui/flutter/lib/shared/widgets/app_empty_illustration.dart create mode 100644 ui/flutter/lib/shared/widgets/app_empty_state.dart create mode 100644 ui/flutter/lib/shared/widgets/app_search_field.dart create mode 100644 ui/flutter/test/api/gopeed_site_api_test.dart create mode 100644 ui/flutter/test/features/extensions/application/extensions_controller_test.dart create mode 100644 ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart create mode 100644 ui/flutter/test/features/extensions/presentation/widgets/extensions_empty_state_test.dart create mode 100644 ui/flutter/test/shared/widgets/app_search_field_test.dart diff --git a/ui/flutter/lib/api/gopeed_site_api.dart b/ui/flutter/lib/api/gopeed_site_api.dart index e90dedda..73c34f8f 100644 --- a/ui/flutter/lib/api/gopeed_site_api.dart +++ b/ui/flutter/lib/api/gopeed_site_api.dart @@ -11,6 +11,8 @@ class GopeedSiteApi { static final instance = GopeedSiteApi._(); static const _host = 'gopeed.com'; + final _storePages = {}; + final _storeRequests = >{}; Future> 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 _fetchStorePage(Map params) async { + final json = await _getJson('/api/extensions', queryParameters: params); return StoreExtensionPage.fromJson(json as Map); } + Future 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); + } + Future reportExtensionInstall(String id) async { final uri = Uri.https(_host, '/api/extensions/install'); await proxyRequest( @@ -49,8 +83,11 @@ class GopeedSiteApi { ); } - Future _getJson(String path, {Map? queryParameters}) async { - final uri = Uri.https(_host, path, queryParameters); + Future _getJson(String path, {Map? queryParameters}) { + return _getJsonUri(Uri.https(_host, path, queryParameters)); + } + + Future _getJsonUri(Uri uri) async { final Response response = await proxyRequest(uri.toString()); if (response.data == null || response.data!.isEmpty) { throw Exception('Empty response from $uri'); diff --git a/ui/flutter/lib/api/model/store_extension.dart b/ui/flutter/lib/api/model/store_extension.dart index ca5a9b7b..7fe6f025 100644 --- a/ui/flutter/lib/api/model/store_extension.dart +++ b/ui/flutter/lib/api/model/store_extension.dart @@ -64,6 +64,7 @@ class StoreExtension { final List 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 json) { @@ -106,6 +108,7 @@ class StoreExtension { topics: _parseTopics(json['topics']), createdAt: _parseDate(json['createdAt']), updatedAt: _parseDate(json['updatedAt']), + hasDetails: json.containsKey('readme'), ); } diff --git a/ui/flutter/lib/features/extensions/application/extensions_controller.dart b/ui/flutter/lib/features/extensions/application/extensions_controller.dart index 110af4ce..3cb0a9b6 100644 --- a/ui/flutter/lib/features/extensions/application/extensions_controller.dart +++ b/ui/flutter/lib/features/extensions/application/extensions_controller.dart @@ -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.new, @@ -121,63 +121,81 @@ class ExtensionsState { class ExtensionsController extends AsyncNotifier { static const manualInstallBusyKey = '__manual_install__'; int _devModeCount = 0; + int _installedRevision = 0; + int _storeRevision = 0; + Future? _updateCheck; + bool _checkUpdatesAgain = false; @override - Future build() async { - var next = const ExtensionsState(loadingInstalled: true, loadingStore: true); + Future build() => _loadInitialData(); + + Future _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 loadInitialData() async { - state = const AsyncValue.loading(); - state = AsyncValue.data(await build()); + await _loadInitialData(forceRefresh: true); } Future loadInstalled({bool refreshUpdates = false}) async { + if (!ref.mounted) return; await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true)); } Future refreshStore() async { - await _refreshStore(current: _current.copyWith(loadingStore: true)); + await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true); } Future 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 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 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 { }); } - Future checkUpdate() async { - final flags = {}; - 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 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 _runUpdateChecks() async { + final service = ref.read(gopeedServiceProvider); + while (ref.mounted && _checkUpdatesAgain) { + _checkUpdatesAgain = false; + final revision = _installedRevision; + final installed = List.of(_current.installedExtensions); + final flags = {}; + 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 get _current => state.value ?? const ExtensionsState(); Future _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 _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 _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 _runBusy(String id, Future Function() action) async { @@ -314,12 +366,15 @@ class ExtensionsController extends AsyncNotifier { try { await action(); } finally { - final busy = Set.of(_current.busyExtensionIds)..remove(id); - state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy)); + if (ref.mounted) { + final busy = Set.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 { 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 (_) {} }()); } diff --git a/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart b/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart new file mode 100644 index 00000000..947c4d6d --- /dev/null +++ b/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart @@ -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((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( + (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.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, +); diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 9d720605..73e40e02 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -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 { ), 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, ), ); } diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index 7acf0c65..472434a3 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -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: [ diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart index 5805d4bf..e05acf00 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -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, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart index b46485f0..2ca402c1 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart @@ -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, ), ); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart index 0ddd8189..3b662bae 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart @@ -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, ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart index be086dd6..48789a84 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart @@ -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; - } -} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index c4f1f1ed..105be8c0 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -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} لإعادة المحاولة" } diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index c8d875f9..84fb90e7 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 2d05abe1..f8bdbce6 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 57ec757d..063e7cb6 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index b8d6f5de..2fda6b77 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -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" + } + } } } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index b38c551c..6f2eeade 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index bdd1463d..9d0444ea 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -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} کلیک کنید" } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 575a7509..101cb5a9 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 34b49690..d96afc27 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 765da637..91a47d6f 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 47447887..15a510bb 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 3338bcf5..e7dec4be 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -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}をクリックして再試行" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index a4cc72de..d51d9f55 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -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}을 눌러 다시 시도" } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index 451d8285..95ff16b2 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 8b67dea4..cc869731 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 9bb79910..69b9b08a 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -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}, чтобы повторить попытку" } diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index 0932539b..3097a7be 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 6de8c219..d54ccfd2 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -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} ஐ அழுத்தவும்" } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index f69ee45b..32b98445 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index a0605ae7..c27005f0 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -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}, щоб спробувати ще раз" } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 34a176ec..ba30e032 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index d3aaba61..cf1bdf67 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -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}重新加载" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 4915b44d..38444e8b 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -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}重新載入" } diff --git a/ui/flutter/lib/shared/widgets/app_empty_illustration.dart b/ui/flutter/lib/shared/widgets/app_empty_illustration.dart new file mode 100644 index 00000000..4cf73f9e --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_empty_illustration.dart @@ -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; + } +} diff --git a/ui/flutter/lib/shared/widgets/app_empty_state.dart b/ui/flutter/lib/shared/widgets/app_empty_state.dart new file mode 100644 index 00000000..b7d248ac --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_empty_state.dart @@ -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), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/app_search_field.dart b/ui/flutter/lib/shared/widgets/app_search_field.dart new file mode 100644 index 00000000..aa4b2def --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_search_field.dart @@ -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? onChanged; + final ValueChanged? onSubmitted; + + @override + State createState() => _AppSearchFieldState(); +} + +class _AppSearchFieldState extends State { + 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(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( + 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 { + 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; +} diff --git a/ui/flutter/test/api/gopeed_site_api_test.dart b/ui/flutter/test/api/gopeed_site_api_test.dart new file mode 100644 index 00000000..fdda9510 --- /dev/null +++ b/ui/flutter/test/api/gopeed_site_api_test.dart @@ -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>(); + 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 _response(Map json) => Response( + requestOptions: RequestOptions(path: '/test'), + data: jsonEncode(json), + statusCode: 200, +); + +class _Transport implements GopeedTransport { + final urls = []; + Future> Function(String) onProxy = (_) async => _response({'data': []}); + + @override + Future> proxyRequest(String uri, {dynamic data, Options? options}) { + urls.add(uri); + return onProxy(uri); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/ui/flutter/test/features/extensions/application/extensions_controller_test.dart b/ui/flutter/test/features/extensions/application/extensions_controller_test.dart new file mode 100644 index 00000000..a2a98dfd --- /dev/null +++ b/ui/flutter/test/features/extensions/application/extensions_controller_test.dart @@ -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>(); + final update = Completer(); + 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(); + 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(); + 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(); + final latest = Completer(); + final site = _SiteApi() + ..onList = (query) => switch (query) { + 'old' => old.future, + 'new' => latest.future, + _ => Future.value(_page('market')), + }; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => []); + 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, (_) => []); + 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(); + 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.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 _settle() async { + for (var i = 0; i < 5; i++) { + await Future.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 Function(String?) onList = (_) async => _page('market'); + Future Function(String, String?) onDetail = (id, _) async => + StoreExtension.fromJson({'id': id, 'readme': null}); + + @override + Future 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 getExtension(String id, {String? version}) { + detailCalls++; + return onDetail(id, version); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart new file mode 100644 index 00000000..ddef58eb --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart @@ -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()); + 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(); + 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().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 build() async => ExtensionsState(storeExtensions: [_summary()]); +} + +class _DetailApi implements GopeedSiteApi { + late Future request; + Future Function()? onRequest; + int detailCalls = 0; + + @override + Future 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 build() async => + ExtensionsState(installedExtensions: [installed], storeExtensions: [_summary()]); +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extensions_empty_state_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extensions_empty_state_test.dart new file mode 100644 index 00000000..8783f048 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extensions_empty_state_test.dart @@ -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 _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 build() async => initial; +} + +class _FailedExtensionsController extends ExtensionsController { + int retryCalls = 0; + + @override + Future build() async => throw StateError('test-network-error'); + + @override + Future loadInitialData() async { + retryCalls++; + state = const AsyncValue.data(ExtensionsState()); + } +} diff --git a/ui/flutter/test/shared/widgets/app_search_field_test.dart b/ui/flutter/test/shared/widgets/app_search_field_test.dart new file mode 100644 index 00000000..483529b1 --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_search_field_test.dart @@ -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(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 _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> _iconPixels(WidgetTester tester) async { + final boundaryFinder = find.byKey(const ValueKey('search-paint-boundary')); + final boundary = tester.renderObject(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), + ]; +}