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

@@ -0,0 +1,217 @@
import 'package:flutter/widgets.dart';
import '../theme/app_palette.dart';
enum AppEmptyIllustrationType { inbox, puzzle, search, connectionError, documentError }
/// Context-specific drawings share the same muted colors and rounded strokes.
class AppEmptyIllustration extends StatelessWidget {
const AppEmptyIllustration({super.key, required this.type});
final AppEmptyIllustrationType type;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return CustomPaint(
size: const Size(148, 108),
painter: type == AppEmptyIllustrationType.inbox
? _InboxIllustrationPainter(palette: palette)
: _ContextIllustrationPainter(palette: palette, type: type),
);
}
}
class _ContextIllustrationPainter extends CustomPainter {
const _ContextIllustrationPainter({required this.palette, required this.type});
final AppPalette palette;
final AppEmptyIllustrationType type;
Paint get _outline => Paint()
..color = palette.textSecondary.withValues(alpha: 0.72)
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
Paint get _detail => Paint()
..color = palette.textMuted.withValues(alpha: 0.34)
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round;
Paint get _fill => Paint()..color = palette.surfaceSoft;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width / 148, size.height / 108);
switch (type) {
case AppEmptyIllustrationType.puzzle:
_paintPuzzle(canvas);
case AppEmptyIllustrationType.search:
_paintSearch(canvas);
case AppEmptyIllustrationType.connectionError:
_paintConnectionError(canvas);
case AppEmptyIllustrationType.documentError:
_paintDocumentError(canvas);
case AppEmptyIllustrationType.inbox:
break;
}
canvas.restore();
}
void _paintPuzzle(Canvas canvas) {
canvas.translate(9, 0);
final piece = Path()
..moveTo(42, 29)
..lineTo(62, 29)
..cubicTo(55, 10, 86, 10, 79, 29)
..lineTo(98, 29)
..quadraticBezierTo(104, 29, 104, 35)
..lineTo(104, 49)
..cubicTo(84, 42, 84, 73, 104, 66)
..lineTo(104, 86)
..quadraticBezierTo(104, 92, 98, 92)
..lineTo(79, 92)
..cubicTo(86, 73, 55, 73, 62, 92)
..lineTo(42, 92)
..quadraticBezierTo(36, 92, 36, 86)
..lineTo(36, 66)
..cubicTo(17, 73, 17, 42, 36, 49)
..lineTo(36, 35)
..quadraticBezierTo(36, 29, 42, 29)
..close();
canvas.drawPath(piece, _fill);
canvas.drawPath(piece, _outline);
}
void _paintSearch(Canvas canvas) {
final lens = const Offset(64, 45);
canvas.drawCircle(lens, 29, _fill);
canvas.drawCircle(lens, 29, _outline);
canvas.drawLine(const Offset(85, 67), const Offset(115, 96), _outline..strokeWidth = 4);
canvas.drawLine(const Offset(53, 45), const Offset(75, 45), _detail);
canvas.drawLine(const Offset(27, 85), const Offset(41, 85), _detail);
canvas.drawCircle(const Offset(112, 23), 3, Paint()..color = palette.border);
}
void _paintConnectionError(Canvas canvas) {
final cloud = Path()
..moveTo(40, 70)
..cubicTo(21, 70, 19, 43, 39, 41)
..cubicTo(40, 14, 77, 8, 91, 32)
..cubicTo(111, 23, 129, 40, 121, 56)
..cubicTo(133, 66, 119, 79, 108, 74)
..lineTo(40, 74)
..close();
canvas.drawPath(cloud, _fill);
canvas.drawPath(cloud, _outline);
canvas.drawLine(const Offset(74, 74), const Offset(74, 82), _detail);
canvas.drawLine(const Offset(48, 96), const Offset(63, 96), _outline);
canvas.drawLine(const Offset(86, 96), const Offset(101, 96), _outline);
canvas.drawLine(const Offset(71, 91), const Offset(77, 101), _detail);
canvas.drawCircle(const Offset(45, 96), 2, _fill);
canvas.drawCircle(const Offset(104, 96), 2, _fill);
}
void _paintDocumentError(Canvas canvas) {
final document = Path()
..moveTo(47, 12)
..lineTo(89, 12)
..lineTo(107, 30)
..lineTo(107, 89)
..quadraticBezierTo(107, 96, 100, 96)
..lineTo(47, 96)
..quadraticBezierTo(40, 96, 40, 89)
..lineTo(40, 19)
..quadraticBezierTo(40, 12, 47, 12)
..close();
canvas.drawPath(document, _fill);
canvas.drawPath(document, _outline);
canvas.drawPath(
Path()
..moveTo(89, 12)
..lineTo(89, 30)
..lineTo(107, 30),
_detail,
);
for (final y in [43.0, 56.0, 69.0]) {
canvas.drawLine(Offset(53, y), Offset(y == 69 ? 74 : 91, y), _detail);
}
canvas.drawCircle(const Offset(107, 85), 16, Paint()..color = palette.cardBg);
canvas.drawCircle(const Offset(107, 85), 16, _outline);
canvas.drawLine(const Offset(107, 77), const Offset(107, 86), _outline);
canvas.drawCircle(const Offset(107, 92), 1.2, Paint()..color = palette.textSecondary.withValues(alpha: 0.72));
}
@override
bool shouldRepaint(_ContextIllustrationPainter oldDelegate) =>
oldDelegate.palette != palette || oldDelegate.type != type;
}
class _InboxIllustrationPainter extends CustomPainter {
const _InboxIllustrationPainter({required this.palette});
final AppPalette palette;
@override
void paint(Canvas canvas, Size size) {
final borderPaint = Paint()
..color = palette.border
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
final sheetRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 34, 6, 68, 72), const Radius.circular(7));
canvas.drawRRect(sheetRect.shift(const Offset(0, 3)), Paint()..color = palette.textMuted.withValues(alpha: 0.06));
canvas.drawRRect(sheetRect, Paint()..color = palette.cardBg);
canvas.drawRRect(sheetRect, borderPaint);
final rowPaint = Paint()
..color = palette.textMuted.withValues(alpha: 0.34)
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final centerX = size.width / 2;
for (var row = 0; row < 2; row++) {
final y = 20.0 + row * 14;
canvas.drawCircle(Offset(centerX - 22, y), 2.5, Paint()..color = palette.surfaceSoft);
canvas.drawLine(Offset(centerX - 14, y), Offset(centerX + 21, y), rowPaint);
}
final emptyBadgePaint = Paint()
..color = palette.surfaceSoft
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, 56), 11, emptyBadgePaint);
canvas.drawCircle(Offset(centerX, 56), 11, borderPaint);
canvas.drawLine(Offset(centerX - 4, 56), Offset(centerX + 4, 56), rowPaint);
final trayPath = Path()
..moveTo(26, 63)
..lineTo(49, 63)
..lineTo(57, 75)
..quadraticBezierTo(59, 78, 63, 78)
..lineTo(85, 78)
..quadraticBezierTo(89, 78, 91, 75)
..lineTo(99, 63)
..lineTo(122, 63)
..lineTo(115, 96)
..quadraticBezierTo(114, 101, 108, 101)
..lineTo(40, 101)
..quadraticBezierTo(34, 101, 33, 96)
..close();
canvas.drawPath(trayPath, Paint()..color = palette.surfaceSoft);
final trayPaint = Paint()
..color = palette.textSecondary.withValues(alpha: 0.72)
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
canvas.drawPath(trayPath, trayPaint);
}
@override
bool shouldRepaint(_InboxIllustrationPainter oldDelegate) {
return oldDelegate.palette != palette;
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
import 'app_tooltip.dart';
import 'app_empty_illustration.dart';
export 'app_empty_illustration.dart' show AppEmptyIllustrationType;
/// Shared empty/error surface used by task and extension views.
class AppEmptyState extends StatelessWidget {
const AppEmptyState({
super.key,
required this.message,
required this.illustration,
this.action,
this.illustrationKey,
});
final String message;
final AppEmptyIllustrationType illustration;
final Widget? action;
final Key? illustrationKey;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppEmptyIllustration(key: illustrationKey, type: illustration),
const SizedBox(height: 18),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
),
if (action != null) ...[const SizedBox(height: AppDesignTokens.space24), action!],
],
),
),
),
),
);
}
}
/// Places the action inside localized guidance, matching every empty state.
class AppEmptyStateAction extends StatelessWidget {
const AppEmptyStateAction({
super.key,
required this.icon,
required this.label,
required this.hint,
required this.onPressed,
this.buttonKey,
});
final IconData icon;
final String label;
final String Function(String action) hint;
final VoidCallback onPressed;
final Key? buttonKey;
@override
Widget build(BuildContext context) {
const actionMarker = '\uFFFC';
final parts = hint(actionMarker).split(actionMarker);
return Text.rich(
TextSpan(
children: [
TextSpan(text: parts.first.trimRight()),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: AppTooltip(
message: label,
child: Semantics(
label: label,
child: SizedBox.square(
dimension: 30,
child: shad.IconButton.outline(
key: buttonKey,
density: shad.ButtonDensity.compact,
onPressed: onPressed,
icon: Icon(icon, size: 16),
),
),
),
),
),
),
TextSpan(text: parts.last.trimLeft()),
],
),
textAlign: TextAlign.center,
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13),
);
}
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import '../theme/app_palette.dart';
import 'app_text_field.dart';
/// Keeps the search icon outside the editable text's horizontal viewport.
class AppSearchField extends StatefulWidget {
const AppSearchField({
super.key,
required this.controller,
this.focusNode,
this.placeholder,
this.style,
this.padding,
this.decoration,
this.iconSize = 16,
this.onChanged,
this.onSubmitted,
});
final TextEditingController controller;
final FocusNode? focusNode;
final Widget? placeholder;
final TextStyle? style;
final EdgeInsetsGeometry? padding;
final BoxDecoration? decoration;
final double iconSize;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
@override
State<AppSearchField> createState() => _AppSearchFieldState();
}
class _AppSearchFieldState extends State<AppSearchField> {
late final FocusNode _ownedFocusNode = FocusNode();
FocusNode get _focusNode => widget.focusNode ?? _ownedFocusNode;
@override
void dispose() {
_ownedFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = shad.Theme.of(context);
final fieldTheme = shad.ComponentTheme.maybeOf<shad.TextFieldTheme>(context);
final palette = AppPalette.of(context);
final padding = widget.padding ?? fieldTheme?.padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 8);
final decoration =
widget.decoration ??
BoxDecoration(
borderRadius: fieldTheme?.borderRadius ?? BorderRadius.circular(theme.radiusMd),
border: fieldTheme?.border ?? Border.all(color: theme.colorScheme.border),
color: fieldTheme?.filled == true ? theme.colorScheme.muted : theme.colorScheme.input.withValues(alpha: 0.3),
);
return ListenableBuilder(
listenable: _focusNode,
builder: (context, _) => shad.FocusOutline(
focused: _focusNode.hasFocus,
borderRadius: decoration.borderRadius,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _focusNode.requestFocus,
child: Container(
decoration: decoration,
clipBehavior: Clip.hardEdge,
padding: padding,
child: LayoutBuilder(
builder: (context, constraints) => Row(
children: [
// A compressed desktop toolbar may leave almost no search
// width just before switching to its mobile layout.
if (constraints.maxWidth > widget.iconSize + theme.density.baseGap * theme.scaling) ...[
Icon(Icons.search_rounded, size: widget.iconSize, color: palette.textMuted),
SizedBox(width: theme.density.baseGap * theme.scaling),
],
Expanded(
// The underlying field allows text to paint outside its own
// viewport. Clip only the editable area, leaving the icon fixed.
child: ClipRect(
clipper: const _SearchTextClipper(),
child: shad.ComponentTheme<shad.FocusOutlineTheme>(
data: const shad.FocusOutlineTheme(
align: 0,
border: Border.fromBorderSide(BorderSide(color: Colors.transparent, width: 0)),
),
child: AppTextField(
controller: widget.controller,
focusNode: _focusNode,
placeholder: widget.placeholder,
style: widget.style,
padding: EdgeInsets.zero,
decoration: const BoxDecoration(),
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
),
),
),
),
],
),
),
),
),
),
);
}
}
class _SearchTextClipper extends CustomClipper<Rect> {
const _SearchTextClipper();
// Keep horizontal scrolling out of the icon area without cutting glyphs or
// tall cursors in compact fields. The outer field clips its own border.
@override
Rect getClip(Size size) => Rect.fromLTRB(0, -size.height, size.width, size.height * 2);
@override
bool shouldReclip(_SearchTextClipper oldClipper) => false;
}