feat: align shared app foundations
This commit is contained in:
1 parent
bd6bb0ffce
commit
c5653a3699
21 files changed
+894
-66
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 金值统一的网络图片入口。
|
||||
///
|
||||
/// 图片会自动使用 [CachedNetworkImage] 的磁盘缓存;业务页面不应直接创建
|
||||
/// `Image.network`,以保证加载和失败状态的视觉及缓存策略一致。
|
||||
class AppNetworkImage extends StatelessWidget {
|
||||
const AppNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius,
|
||||
this.cacheKey,
|
||||
this.httpHeaders,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final BorderRadius? borderRadius;
|
||||
final String? cacheKey;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = imageUrl.trim().isEmpty
|
||||
? _ImageFallback(errorWidget: errorWidget)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
httpHeaders: httpHeaders,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
alignment: alignment,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
placeholder: (_, _) =>
|
||||
placeholder ?? const _ImageLoadingPlaceholder(),
|
||||
errorWidget: (_, _, _) => _ImageFallback(errorWidget: errorWidget),
|
||||
);
|
||||
|
||||
final clipped = borderRadius == null
|
||||
? child
|
||||
: ClipRRect(borderRadius: borderRadius!, child: child);
|
||||
if (semanticLabel == null) return clipped;
|
||||
return Semantics(image: true, label: semanticLabel, child: clipped);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageLoadingPlaceholder extends StatelessWidget {
|
||||
const _ImageLoadingPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = ShadTheme.of(context).colorScheme;
|
||||
return ColoredBox(
|
||||
color: colors.muted,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageFallback extends StatelessWidget {
|
||||
const _ImageFallback({required this.errorWidget});
|
||||
|
||||
final Widget? errorWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (errorWidget != null) return errorWidget!;
|
||||
final colors = ShadTheme.of(context).colorScheme;
|
||||
return ColoredBox(
|
||||
color: colors.muted,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: colors.mutedForeground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:easy_refresh/easy_refresh.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef RefreshAction = Future<void> Function();
|
||||
|
||||
/// Shared pull-to-refresh and load-more wrapper for future list features.
|
||||
class AppRefresh extends StatelessWidget {
|
||||
const AppRefresh({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onRefresh,
|
||||
this.onLoad,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final RefreshAction? onRefresh;
|
||||
final RefreshAction? onLoad;
|
||||
final EasyRefreshController? controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => EasyRefresh(
|
||||
controller: controller,
|
||||
onRefresh: onRefresh,
|
||||
onLoad: onLoad,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'app_state_card.dart';
|
||||
|
||||
/// Shared loading, failure and empty-state contract for feature pages.
|
||||
class AsyncStateView<T> extends StatelessWidget {
|
||||
const AsyncStateView({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.data,
|
||||
this.isEmpty,
|
||||
this.empty,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
this.errorTitle = '加载失败',
|
||||
this.errorSubtitle = '请检查网络后重试',
|
||||
this.emptyIcon = Icons.inbox_outlined,
|
||||
this.emptyTitle = '暂无内容',
|
||||
this.emptySubtitle = '暂时没有可展示的数据',
|
||||
});
|
||||
|
||||
final AsyncValue<T> state;
|
||||
final Widget Function(T value) data;
|
||||
final bool Function(T value)? isEmpty;
|
||||
final Widget Function()? empty;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
final String errorTitle;
|
||||
final String errorSubtitle;
|
||||
final IconData emptyIcon;
|
||||
final String emptyTitle;
|
||||
final String emptySubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return state.when(
|
||||
loading: () =>
|
||||
loading ?? const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: errorTitle,
|
||||
subtitle: errorSubtitle,
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
),
|
||||
),
|
||||
data: (value) => isEmpty?.call(value) == true
|
||||
? empty?.call() ??
|
||||
Center(
|
||||
child: AppStateCard(
|
||||
icon: emptyIcon,
|
||||
title: emptyTitle,
|
||||
subtitle: emptySubtitle,
|
||||
),
|
||||
)
|
||||
: data(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:easy_refresh/easy_refresh.dart';
|
||||
|
||||
/// Applies one Chinese copy set to every pull-to-refresh/list-load control.
|
||||
void configureEasyRefreshChineseLocalization() {
|
||||
EasyRefresh.defaultHeaderBuilder = () => const ClassicHeader(
|
||||
dragText: '下拉刷新',
|
||||
armedText: '松开刷新',
|
||||
readyText: '刷新中...',
|
||||
processingText: '刷新中...',
|
||||
processedText: '刷新成功',
|
||||
noMoreText: '没有更多了',
|
||||
failedText: '刷新失败',
|
||||
messageText: '上次更新于 %T',
|
||||
);
|
||||
EasyRefresh.defaultFooterBuilder = () => const ClassicFooter(
|
||||
dragText: '上拉加载',
|
||||
armedText: '松开加载更多',
|
||||
readyText: '加载中...',
|
||||
processingText: '加载中...',
|
||||
processedText: '加载完成',
|
||||
noMoreText: '没有更多了',
|
||||
failedText: '加载失败',
|
||||
messageText: '上次更新于 %T',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user