perf(extensions): load store summaries and check updates asynchronously (#1583)

This commit is contained in:
Levi authored and GitHub committed 2026-10-14 20:08:41 +08:00
1 parent b86c2c18e7
commit 12860bb207
41 files changed
+1567 -257

No files matched your search

@@ -3,13 +3,13 @@ import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/extension.dart';
import '../../../api/model/install_extension.dart';
import '../../../api/model/store_extension.dart';
import '../../../api/model/switch_extension.dart';
import '../../../api/model/update_extension_settings.dart';
import '../../../core/capabilities/app_capabilities.dart';
import 'store_extension_details_provider.dart';
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
ExtensionsController.new,
@@ -121,63 +121,81 @@ class ExtensionsState {
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
static const manualInstallBusyKey = '__manual_install__';
int _devModeCount = 0;
int _installedRevision = 0;
int _storeRevision = 0;
Future<void>? _updateCheck;
bool _checkUpdatesAgain = false;
@override
Future<ExtensionsState> build() async {
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
Future<ExtensionsState> build() => _loadInitialData();
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
state = AsyncValue.data(next);
final installed = await _loadInstalled(refreshUpdates: true, current: next);
next = state.value ?? installed;
await _refreshStore(current: next);
return state.value ?? next;
await Future.wait([
_loadInstalled(refreshUpdates: true, current: next),
_refreshStore(current: _current, forceRefresh: forceRefresh),
]);
return ref.mounted ? _current : next;
}
Future<void> loadInitialData() async {
state = const AsyncValue.loading();
state = AsyncValue.data(await build());
await _loadInitialData(forceRefresh: true);
}
Future<void> loadInstalled({bool refreshUpdates = false}) async {
if (!ref.mounted) return;
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
}
Future<void> refreshStore() async {
await _refreshStore(current: _current.copyWith(loadingStore: true));
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
}
Future<void> loadMoreStore() async {
final current = _current;
final pagination = current.storePagination;
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
final revision = _storeRevision;
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(
_current.copyWith(
storeExtensions: [..._current.storeExtensions, ...page.data],
// Cached pages can briefly overlap when stars/install counts change.
storeExtensions: {
for (final extension in _current.storeExtensions) extension.id: extension,
for (final extension in page.data) extension.id: extension,
}.values.toList(),
storePagination: page.pagination,
loadingMoreStore: false,
),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
rethrow;
}
}
Future<void> searchStore(String query) async {
if (query.trim() == _current.storeQuery) return;
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
await refreshStore();
await _refreshStore(current: _current);
}
Future<void> changeSort(StoreExtensionSort sort) async {
if (sort == _current.storeSort) return;
state = AsyncValue.data(_current.copyWith(storeSort: sort));
await refreshStore();
await _refreshStore(current: _current);
}
void changeFilter(ExtensionListFilter filter) {
@@ -257,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
});
}
Future<void> checkUpdate() async {
final flags = <String, String>{};
for (final ext in _current.installedExtensions) {
try {
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
flags[ext.identity] = resp.newVersion;
Future<void> checkUpdate() {
if (!ref.mounted) return Future.value();
_checkUpdatesAgain = true;
// Installation/refresh can request a fresh scan while one is running.
// Coalesce those requests instead of launching overlapping Git operations.
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
}
Future<void> _runUpdateChecks() async {
final service = ref.read(gopeedServiceProvider);
while (ref.mounted && _checkUpdatesAgain) {
_checkUpdatesAgain = false;
final revision = _installedRevision;
final installed = List<Extension>.of(_current.installedExtensions);
final flags = <String, String>{};
for (final ext in installed) {
if (!ref.mounted) return;
if (revision != _installedRevision) break;
try {
final resp = await service.upgradeCheckExtension(ext.identity);
if (!ref.mounted) return;
if (revision != _installedRevision) break;
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
} catch (_) {
// A failed update check must never prevent loading the store.
}
} catch (_) {}
}
if (ref.mounted && revision == _installedRevision) {
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
}
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
void tryOpenDevMode() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
if (_current.devMode) return;
if (!ref.mounted || _current.devMode) return;
_devModeCount = 0;
});
}
@@ -286,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
ExtensionsState get _current => state.value ?? const ExtensionsState();
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
final revision = ++_installedRevision;
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
final installed = await ref.read(gopeedServiceProvider).getExtensions();
if (!ref.mounted) return current;
if (revision != _installedRevision) return _current;
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
if (refreshUpdates) {
await checkUpdate();
unawaited(checkUpdate());
}
return _current;
}
Future<void> _refreshStore({required ExtensionsState current}) async {
state = AsyncValue.data(current.copyWith(loadingStore: true));
final page = await GopeedSiteApi.instance.getExtensions(
page: 1,
limit: 20,
sort: _current.storeSort,
query: _current.storeQuery,
);
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
final revision = ++_storeRevision;
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
try {
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: 1,
limit: 20,
sort: current.storeSort,
query: current.storeQuery,
forceRefresh: forceRefresh,
);
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingStore: false));
rethrow;
}
}
Future<void> _runBusy(String id, Future<void> Function() action) async {
@@ -314,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
try {
await action();
} finally {
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
if (ref.mounted) {
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
}
}
}
void _bumpStoreInstallCount(String id) {
if (!ref.mounted) return;
final current = _current;
final index = current.storeExtensions.indexWhere((e) => e.id == id);
if (index < 0) return;
@@ -344,14 +399,17 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
topics: ext.topics,
createdAt: ext.createdAt,
updatedAt: ext.updatedAt,
hasDetails: ext.hasDetails,
);
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
}
void _reportInstallSafe(String id) {
if (!ref.mounted) return;
final siteApi = ref.read(gopeedSiteApiProvider);
unawaited(() async {
try {
await GopeedSiteApi.instance.reportExtensionInstall(id);
await siteApi.reportExtensionInstall(id);
} catch (_) {}
}());
}
@@ -0,0 +1,36 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/store_extension.dart';
final gopeedSiteApiProvider = Provider<GopeedSiteApi>((ref) => GopeedSiteApi.instance);
typedef StoreExtensionDetailsKey = ({String id, String version});
// A shared future deduplicates drawer/page requests. Successful details survive
// closing the view, while failures can be retried and new versions get a new key.
final storeExtensionDetailsProvider = FutureProvider.autoDispose.family<StoreExtension, StoreExtensionDetailsKey>(
(ref, key) async {
final api = ref.watch(gopeedSiteApiProvider);
// Keep the whole three-attempt cycle loading; expose only the final error.
for (var attempt = 0; ; attempt++) {
try {
final extension = await api.getExtension(key.id, version: key.version);
if (ref.mounted) {
final keepAlive = ref.keepAlive();
final expiry = Timer(const Duration(minutes: 30), keepAlive.close);
ref.onDispose(expiry.cancel);
}
return extension;
} catch (_) {
if (attempt == 2 || !ref.mounted) rethrow;
await Future<void>.delayed(Duration(milliseconds: 300 * (attempt + 1)));
if (!ref.mounted) rethrow;
}
}
},
// The bounded cycle above owns retries, including after a manual reload.
retry: (retryCount, error) => null,
);
@@ -17,7 +17,9 @@ import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_swipe_tabs.dart';
import '../../../../shared/widgets/app_primary_button.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../l10n/l10n.dart';
@@ -124,10 +126,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
child: stateAsync.when(
loading: () => _buildContent(const ExtensionsState(loadingInstalled: true, loadingStore: true)),
error: (error, _) => _ErrorState(
error: error,
onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData(),
),
error: (error, _) =>
_ErrorState(onRetry: () => ref.read(extensionsControllerProvider.notifier).loadInitialData()),
data: (state) {
if (state.listFilter == ExtensionListFilter.market &&
state.storePagination?.hasNext == true &&
@@ -409,11 +409,17 @@ class _Content extends StatelessWidget {
)
else if (state.displayItems.isEmpty)
SliverFillRemaining(
child: Center(
child: Text(
context.l10n.noExtensions,
style: TextStyle(color: palette.textSecondary, fontSize: 14),
),
hasScrollBody: false,
child: AppEmptyState(
key: const ValueKey('extensions-empty-state'),
illustration:
state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty
? AppEmptyIllustrationType.search
: AppEmptyIllustrationType.puzzle,
illustrationKey: const ValueKey('extensions-empty-illustration'),
message: state.listFilter == ExtensionListFilter.market && state.storeQuery.isNotEmpty
? context.l10n.extensionStoreEmpty
: context.l10n.noExtensions,
),
)
else
@@ -663,12 +669,11 @@ class _Toolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final search = AppTextField(
final search = AppSearchField(
key: const ValueKey('extension-search-input'),
controller: searchController,
padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null,
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
onSubmitted: onSearch,
);
final sorting = Row(
@@ -1255,36 +1260,23 @@ class _ExtensionSettingsPanel extends StatelessWidget {
}
class _ErrorState extends StatelessWidget {
const _ErrorState({required this.error, required this.onRetry});
const _ErrorState({required this.onRetry});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.extension_off_outlined, size: 32, color: palette.error),
const SizedBox(height: 12),
Text(
context.l10n.unableLoadExtensions,
style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(color: palette.textSecondary),
),
const SizedBox(height: 16),
shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)),
],
),
return AppEmptyState(
key: const ValueKey('extensions-error-state'),
illustration: AppEmptyIllustrationType.connectionError,
illustrationKey: const ValueKey('extensions-error-illustration'),
message: context.l10n.unableLoadExtensions,
action: AppEmptyStateAction(
buttonKey: const ValueKey('extensions-error-retry'),
icon: Icons.refresh_rounded,
label: context.l10n.retry,
hint: context.l10n.emptyStateRetryHint,
onPressed: onRetry,
),
);
}
@@ -15,10 +15,12 @@ import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../../../util/util.dart';
import '../../application/extensions_controller.dart';
import '../../application/store_extension_details_provider.dart';
import 'extension_icon.dart';
import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
@@ -63,7 +65,16 @@ class ExtensionDetailView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final palette = AppPalette.of(context);
final state = ref.watch(extensionsControllerProvider).value;
final current = state?.findItem(item.id) ?? item;
final summary = state?.findItem(item.id) ?? item;
final summaryStore = summary.store;
// Native installed extensions provide their manifest and local README.
// Web cannot read the local files and still needs the store README.
final useLocalDetails = summary.isInstalled && !Util.isWeb();
final detailKey = useLocalDetails || summaryStore == null || summaryStore.hasDetails
? null
: (id: summaryStore.id, version: summaryStore.version);
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
final installed = current.installed;
final store = current.store;
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
@@ -116,7 +127,7 @@ class ExtensionDetailView extends ConsumerWidget {
'v${current.version}',
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
),
if (store != null) _ExtensionStats(store: store),
if (summaryStore != null) _ExtensionStats(store: summaryStore),
],
),
],
@@ -164,7 +175,31 @@ class ExtensionDetailView extends ConsumerWidget {
),
],
),
SizedBox(height: mobile ? 24 : 28),
if (details?.isLoading == true)
const Padding(
key: ValueKey('extension-details-loading'),
padding: EdgeInsets.only(top: 24),
child: Center(
child: SizedBox.square(dimension: 44, child: Center(child: shad.CircularProgressIndicator())),
),
),
if (details?.hasError == true && details?.isLoading != true && detailKey != null)
Padding(
padding: const EdgeInsets.only(top: 24),
child: AppEmptyState(
key: const ValueKey('extension-details-error-state'),
illustration: AppEmptyIllustrationType.documentError,
message: context.l10n.unableLoadExtensionDetails,
illustrationKey: const ValueKey('extension-details-error-illustration'),
action: AppEmptyStateAction(
buttonKey: const ValueKey('extension-details-retry'),
icon: Icons.refresh_rounded,
label: context.l10n.retry,
hint: context.l10n.emptyStateRetryHint,
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
),
),
),
_ExtensionReadme(
// Snapshot the version so upgrades reload the installed README.
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
@@ -213,7 +248,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
if (info.content.trim().isEmpty) return const SizedBox.shrink();
return Padding(
key: const ValueKey('extension-details-readme'),
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
padding: const EdgeInsets.only(top: AppDesignTokens.space24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -5,7 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../tasks/presentation/widgets/task_create_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../l10n/l10n.dart';
class TasksTopBar extends StatelessWidget {
@@ -111,14 +111,14 @@ class _SearchField extends StatelessWidget {
),
child: SizedBox(
height: 40,
child: AppTextField(
child: AppSearchField(
controller: controller,
style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1),
placeholder: Text(
context.l10n.searchDownloads,
style: TextStyle(color: palette.searchHint, fontSize: 14, height: 1),
),
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 14, color: palette.textMuted))],
iconSize: 14,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
decoration: BoxDecoration(
color: palette.inputBg,
@@ -3,7 +3,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_search_field.dart';
class HistorySearchField extends StatelessWidget {
const HistorySearchField({super.key, required this.controller, required this.onChanged});
@@ -18,7 +18,7 @@ class HistorySearchField extends StatelessWidget {
return ConstrainedBox(
// Allow large accessibility text to grow instead of clipping the line.
constraints: const BoxConstraints(minHeight: 38),
child: AppTextField(
child: AppSearchField(
key: const ValueKey('create-history-filter'),
controller: controller,
style: textStyle,
@@ -30,7 +30,7 @@ class HistorySearchField extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
features: [InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted))],
iconSize: 15,
onChanged: onChanged,
),
);
@@ -1,10 +1,8 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/app_empty_state.dart';
/// Keeps the action inside the localized sentence, including RTL word order.
class TaskEmptyCreateHint extends StatelessWidget {
const TaskEmptyCreateHint({super.key, required this.onCreateTask});
@@ -12,38 +10,12 @@ class TaskEmptyCreateHint extends StatelessWidget {
@override
Widget build(BuildContext context) {
const actionMarker = '\uFFFC';
final parts = context.l10n.emptyTaskCreateHint(actionMarker).split(actionMarker);
return Text.rich(
TextSpan(
children: [
TextSpan(text: parts.first.trimRight()),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: AppTooltip(
message: context.l10n.create,
child: Semantics(
label: context.l10n.create,
child: SizedBox.square(
dimension: 30,
child: IconButton.outline(
key: const ValueKey('tasks-empty-create-button'),
density: ButtonDensity.compact,
onPressed: onCreateTask,
icon: const Icon(Icons.add, size: 16),
),
),
),
),
),
),
TextSpan(text: parts.last.trimLeft()),
],
),
textAlign: TextAlign.center,
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13),
return AppEmptyStateAction(
buttonKey: const ValueKey('tasks-empty-create-button'),
icon: Icons.add,
label: context.l10n.create,
hint: context.l10n.emptyTaskCreateHint,
onPressed: onCreateTask,
);
}
}
@@ -1,9 +1,8 @@
import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import 'task_empty_create_hint.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import 'task_empty_create_hint.dart';
class TaskEmptyState extends StatelessWidget {
const TaskEmptyState({super.key, this.message, this.onCreateTask});
@@ -13,101 +12,11 @@ class TaskEmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
key: const ValueKey('task-empty-illustration'),
size: const Size(148, 108),
painter: _TaskEmptyIllustrationPainter(palette: palette),
),
const SizedBox(height: 18),
Text(
message ?? context.l10n.emptyTaskList,
textAlign: TextAlign.center,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
),
if (onCreateTask != null) ...[
const SizedBox(height: AppDesignTokens.space24),
TaskEmptyCreateHint(onCreateTask: onCreateTask!),
],
],
),
),
),
),
return AppEmptyState(
message: message ?? context.l10n.emptyTaskList,
illustration: AppEmptyIllustrationType.inbox,
illustrationKey: const ValueKey('task-empty-illustration'),
action: onCreateTask == null ? null : TaskEmptyCreateHint(onCreateTask: onCreateTask!),
);
}
}
class _TaskEmptyIllustrationPainter extends CustomPainter {
const _TaskEmptyIllustrationPainter({required this.palette});
final AppPalette palette;
@override
void paint(Canvas canvas, Size size) {
final borderPaint = Paint()
..color = palette.border
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
final sheetRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 34, 6, 68, 72), const Radius.circular(7));
canvas.drawRRect(sheetRect.shift(const Offset(0, 3)), Paint()..color = palette.textMuted.withValues(alpha: 0.06));
canvas.drawRRect(sheetRect, Paint()..color = palette.cardBg);
canvas.drawRRect(sheetRect, borderPaint);
final rowPaint = Paint()
..color = palette.textMuted.withValues(alpha: 0.34)
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final centerX = size.width / 2;
for (var row = 0; row < 2; row++) {
final y = 20.0 + row * 14;
canvas.drawCircle(Offset(centerX - 22, y), 2.5, Paint()..color = palette.surfaceSoft);
canvas.drawLine(Offset(centerX - 14, y), Offset(centerX + 21, y), rowPaint);
}
final emptyBadgePaint = Paint()
..color = palette.surfaceSoft
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, 56), 11, emptyBadgePaint);
canvas.drawCircle(Offset(centerX, 56), 11, borderPaint);
canvas.drawLine(Offset(centerX - 4, 56), Offset(centerX + 4, 56), rowPaint);
final trayPath = Path()
..moveTo(26, 63)
..lineTo(49, 63)
..lineTo(57, 75)
..quadraticBezierTo(59, 78, 63, 78)
..lineTo(85, 78)
..quadraticBezierTo(89, 78, 91, 75)
..lineTo(99, 63)
..lineTo(122, 63)
..lineTo(115, 96)
..quadraticBezierTo(114, 101, 108, 101)
..lineTo(40, 101)
..quadraticBezierTo(34, 101, 33, 96)
..close();
canvas.drawPath(trayPath, Paint()..color = palette.surfaceSoft);
final trayPaint = Paint()
..color = palette.textSecondary.withValues(alpha: 0.72)
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
canvas.drawPath(trayPath, trayPaint);
}
@override
bool shouldRepaint(_TaskEmptyIllustrationPainter oldDelegate) {
return oldDelegate.palette != palette;
}
}