mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 17:10:29 +00:00
perf(extensions): load store summaries and check updates asynchronously (#1583)
This commit is contained in:
41 files changed
+1567
-257
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user