基础ui组件

This commit is contained in:
ddshi 2025-09-17 11:42:38 +08:00
parent 8005e21110
commit 1212c4ea27
8 changed files with 2709 additions and 50 deletions

View File

@ -121,17 +121,29 @@ flutter clean
- ✅ 数据迁移机制配置完成,支持版本管理
- ⚠️ 代码质量26个`avoid_print`提示,无致命错误
**任务1.3: 核心工具类**
- [ ] 图片压缩工具类长边500px + WebP
- [ ] 文件存储管理工具
- [ ] UUID生成和路径管理
- [ ] 错误处理和日志系统
**任务1.3: 核心工具类**
- [x] 图片压缩工具类长边500px + WebP
- [x] 文件存储管理工具
- [x] UUID生成和路径管理
- [x] 错误处理和日志系统
**任务1.4: 基础UI组件**
- [ ] 创建可复用的按钮组件
- [ ] 实现加载状态组件(骨架屏)
- [ ] 空状态页面组件
- [ ] 错误提示组件
**验收状态**
- ✅ 图片压缩工具类支持JPG/PNG/GIF/WebP/HEIC格式长边500pxWebP格式GIF帧提取防内存溢出
- ✅ 文件存储管理:日期分类存储(/yyyy/MM/dd/),完整的文件和目录操作
- ✅ UUID和路径管理多种UUID生成路径安全检查链式路径构建器
- ✅ 日志系统5级日志debug/info/warning/error/fatal替换所有print语句
**任务1.4: 基础UI组件** ✅
- [x] 创建可复用的按钮组件
- [x] 实现加载状态组件(骨架屏)
- [x] 空状态页面组件
- [x] 错误提示组件
**验收状态**
- ✅ 可复用按钮组件:支持主要、次要、文本、轮廓、危险按钮类型,多种尺寸,加载状态,图标支持
- ✅ 加载状态组件:骨架屏动画、圆形加载器、进度条、全屏加载、列表/网格骨架屏
- ✅ 空状态页面组件:多种预设场景(无数据、无搜索结果、网络错误等),自定义图标和操作
- ✅ 错误提示组件:多种错误类型(网络、服务器、权限、数据等),详细错误信息显示
#### 📤 Phase 2: 分享功能2-3天
**任务2.1: 分享接收机制**
@ -365,20 +377,20 @@ class UserService {
### 📊 总体进度
- [x] Phase 1.1: 项目基础配置5/5
- [x] Phase 1.2: 数据层架构搭建4/4
- [ ] Phase 1.3: 核心工具类0/4
- [ ] Phase 1.4: 基础UI组件0/4
- [x] Phase 1.3: 核心工具类4/4
- [x] Phase 1.4: 基础UI组件4/4
- [ ] Phase 2: 分享功能0/4
### 🎯 当前任务详情
**任务编号**1.3
**任务名称**核心工具类
**任务编号**2.1
**任务名称**分享接收机制
**任务状态**:待开始
**预计完成**2025年9月17
**依赖项**Phase 1.2 完成
**预计完成**2025年9月18
**依赖项**Phase 1.4 完成
**任务验收标准**
- 图片压缩工具类实现长边500px + WebP格式
- 文件存储管理工具(支持日期分类存储)
- UUID生成和路径管理工具
- 错误处理和日志系统完善
- 代码质量检查通过解决print警告
- 分享接收机制配置完成
- Android分享接收配置完成
- iOS分享接收配置完成
- 多张图片接收逻辑处理
- 分享功能测试通过

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
///
/// -
///
class AppTheme {
//
AppTheme._();
@ -243,4 +244,113 @@ class AppTheme {
// 0.5使使
return brightness > 0.5 ? Colors.black : Colors.white;
}
}
/// - 使
///
class AppColors {
/// -
static const Color primary = Color(0xFF2196F3);
/// -
static const Color onPrimary = Color(0xFFFFFFFF);
/// -
static const Color secondary = Color(0xFF03DAC6);
/// -
static const Color onSecondary = Color(0xFF000000);
/// -
static const Color error = Color(0xFFB00020);
/// -
static const Color onError = Color(0xFFFFFFFF);
/// -
static const Color background = Color(0xFFF5F5F5);
/// -
static const Color onBackground = Color(0xFF212121);
/// -
static const Color surface = Color(0xFFFFFFFF);
/// -
static const Color onSurface = Color(0xFF212121);
/// -
static const Color surfaceVariant = Color(0xFFE0E0E0);
/// -
static const Color onSurfaceVariant = Color(0xFF616161);
/// - 线
static const Color outline = Color(0xFFBDBDBD);
/// -
static const Color success = Color(0xFF4CAF50);
/// -
static const Color warning = Color(0xFFFF9800);
/// -
static const Color info = Color(0xFF2196F3);
/// -
static const Color disabled = Color(0xFFBDBDBD);
/// -
static const Color loading = Color(0xFF2196F3);
/// -
static const Color transparent = Color(0x00000000);
/// -
static const Color shadow = Color(0x1F000000);
/// BuildContext获取颜色配置 - 便
static AppColors of(BuildContext context) {
//
//
return AppColors();
}
///
static ColorScheme get lightColorScheme {
return const ColorScheme.light(
primary: primary,
onPrimary: onPrimary,
secondary: secondary,
onSecondary: onSecondary,
error: error,
onError: onError,
background: background,
onBackground: onBackground,
surface: surface,
onSurface: onSurface,
surfaceVariant: surfaceVariant,
onSurfaceVariant: onSurfaceVariant,
outline: outline,
);
}
///
static ColorScheme get darkColorScheme {
return const ColorScheme.dark(
primary: primary,
onPrimary: onPrimary,
secondary: secondary,
onSecondary: onSecondary,
error: error,
onError: onError,
background: Color(0xFF121212),
onBackground: Color(0xFFFFFFFF),
surface: Color(0xFF1E1E1E),
onSurface: Color(0xFFFFFFFF),
surfaceVariant: Color(0xFF2C2C2C),
onSurfaceVariant: Color(0xFFBDBDBD),
outline: Color(0xFF424242),
);
}
}

View File

@ -41,7 +41,7 @@ class PathUtils {
String? prefix,
}) {
final baseName = generateTimeBasedId();
final fileName = prefix != null ? '${prefix}$baseName' : baseName;
final fileName = prefix != null ? '$prefix$baseName' : baseName;
return '$fileName$extension';
}

View File

@ -119,16 +119,6 @@ class HiveDatabase {
}
}
/// -
///
static Future<void> _createDefaultData() async {
final foldersBox = Hive.box<HiveImageFolder>(_foldersBoxName);
final tagsBox = Hive.box<HiveImageTag>(_tagsBoxName);
// 使
await DatabaseMigration.createDefaultFolders(foldersBox);
await DatabaseMigration.createDefaultTags(tagsBox);
}
/// -
///
@ -151,16 +141,16 @@ class HiveDatabase {
lastUsedAt: now,
);
await foldersBox.put('default', emergencyFolder);
print('创建紧急默认文件夹');
Logger.info('创建紧急默认文件夹');
}
//
await settingsBox.put(_versionKey, _currentVersion);
print('紧急恢复数据创建完成');
Logger.info('紧急恢复数据创建完成');
} catch (e) {
print('紧急恢复数据创建失败: $e');
Logger.error('紧急恢复数据创建失败', error: e);
//
throw Exception('数据库初始化失败,应用无法正常运行: $e');
}
@ -177,17 +167,17 @@ class HiveDatabase {
///
static Future<bool> clearAll() async {
try {
print('开始清理所有数据库数据...');
Logger.info('开始清理所有数据库数据...');
await imagesBox.clear();
await foldersBox.clear();
await tagsBox.clear();
await settingsBox.clear();
print('数据库清理完成');
Logger.info('数据库清理完成');
return true;
} catch (e) {
print('数据库清理失败: $e');
Logger.error('数据库清理失败', error: e);
return false;
}
}
@ -197,14 +187,14 @@ class HiveDatabase {
///
static Future<bool> close() async {
try {
print('正在关闭Hive数据库...');
Logger.info('正在关闭Hive数据库...');
await Hive.close();
print('Hive数据库已关闭');
Logger.info('Hive数据库已关闭');
return true;
} catch (e) {
print('关闭Hive数据库失败: $e');
Logger.error('关闭Hive数据库失败', error: e);
return false;
}
}
@ -213,7 +203,7 @@ class HiveDatabase {
///
static Future<bool> validateIntegrity() async {
try {
print('开始验证数据库完整性...');
Logger.info('开始验证数据库完整性...');
final isValid = await DatabaseMigration.validateDatabaseIntegrity(
imagesBox,
@ -222,14 +212,14 @@ class HiveDatabase {
);
if (isValid) {
print('数据库完整性验证通过');
Logger.info('数据库完整性验证通过');
} else {
print('数据库完整性验证发现问题并已修复');
Logger.info('数据库完整性验证发现问题并已修复');
}
return isValid;
} catch (e) {
print('数据库完整性验证失败: $e');
Logger.error('数据库完整性验证失败', error: e);
return false;
}
}
@ -244,7 +234,7 @@ class HiveDatabase {
tagsBox,
);
} catch (e) {
print('获取数据库统计信息失败: $e');
Logger.error('获取数据库统计信息失败', error: e);
return {
'error': '获取统计信息失败',
'message': e.toString(),
@ -256,7 +246,7 @@ class HiveDatabase {
///
static Future<Map<String, int>> cleanup() async {
try {
print('开始清理数据库...');
Logger.info('开始清理数据库...');
final cleanupStats = await DatabaseMigration.cleanupDatabase(
imagesBox,
@ -264,10 +254,10 @@ class HiveDatabase {
tagsBox,
);
print('数据库清理完成: $cleanupStats');
Logger.info('数据库清理完成: $cleanupStats');
return cleanupStats;
} catch (e) {
print('数据库清理失败: $e');
Logger.error('数据库清理失败', error: e);
return {
'error': 1,
};

View File

@ -0,0 +1,508 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
/// -
///
///
class CustomButton extends StatelessWidget {
///
final String text;
///
final VoidCallback? onPressed;
///
final ButtonType buttonType;
///
final ButtonSize buttonSize;
///
final bool isLoading;
///
final bool isDisabled;
///
final IconData? prefixIcon;
///
final IconData? suffixIcon;
/// buttonType
final Color? backgroundColor;
/// buttonType
final Color? textColor;
/// null时使用默认值
final double? borderRadius;
/// null时根据内容自适应
final double? minWidth;
/// null时无限制
final double? maxWidth;
/// null时使用默认内边距
final EdgeInsetsGeometry? padding;
/// null时根据按钮类型使用默认阴影
final double? elevation;
const CustomButton({
super.key,
required this.text,
required this.onPressed,
this.buttonType = ButtonType.primary,
this.buttonSize = ButtonSize.medium,
this.isLoading = false,
this.isDisabled = false,
this.prefixIcon,
this.suffixIcon,
this.backgroundColor,
this.textColor,
this.borderRadius,
this.minWidth,
this.maxWidth,
this.padding,
this.elevation,
});
/// -
factory CustomButton.primary({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
bool isDisabled = false,
IconData? prefixIcon,
IconData? suffixIcon,
double? minWidth,
double? maxWidth,
}) {
return CustomButton(
text: text,
onPressed: onPressed,
buttonType: ButtonType.primary,
isLoading: isLoading,
isDisabled: isDisabled,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
minWidth: minWidth,
maxWidth: maxWidth,
);
}
/// -
factory CustomButton.secondary({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
bool isDisabled = false,
IconData? prefixIcon,
IconData? suffixIcon,
double? minWidth,
double? maxWidth,
}) {
return CustomButton(
text: text,
onPressed: onPressed,
buttonType: ButtonType.secondary,
isLoading: isLoading,
isDisabled: isDisabled,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
minWidth: minWidth,
maxWidth: maxWidth,
);
}
/// -
factory CustomButton.text({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
bool isDisabled = false,
IconData? prefixIcon,
IconData? suffixIcon,
Color? textColor,
double? minWidth,
double? maxWidth,
}) {
return CustomButton(
text: text,
onPressed: onPressed,
buttonType: ButtonType.text,
isLoading: isLoading,
isDisabled: isDisabled,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
textColor: textColor,
minWidth: minWidth,
maxWidth: maxWidth,
);
}
/// -
factory CustomButton.outlined({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
bool isDisabled = false,
IconData? prefixIcon,
IconData? suffixIcon,
double? minWidth,
double? maxWidth,
}) {
return CustomButton(
text: text,
onPressed: onPressed,
buttonType: ButtonType.outlined,
isLoading: isLoading,
isDisabled: isDisabled,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
minWidth: minWidth,
maxWidth: maxWidth,
);
}
/// - 使
factory CustomButton.danger({
required String text,
required VoidCallback? onPressed,
bool isLoading = false,
bool isDisabled = false,
IconData? prefixIcon,
IconData? suffixIcon,
double? minWidth,
double? maxWidth,
}) {
return CustomButton(
text: text,
onPressed: onPressed,
buttonType: ButtonType.danger,
isLoading: isLoading,
isDisabled: isDisabled,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
minWidth: minWidth,
maxWidth: maxWidth,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
//
final bool buttonEnabled = !isLoading && !isDisabled && onPressed != null;
//
final buttonStyle = _getButtonStyle(theme, appColors);
final textStyle = _getTextStyle(theme, appColors);
final paddingValue = _getPadding();
//
final buttonContent = isLoading
? SizedBox(
width: textStyle.fontSize,
height: textStyle.fontSize,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
buttonStyle.foregroundColor?.resolve({}) ??
theme.colorScheme.onPrimary,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (prefixIcon != null) ...[
Icon(
prefixIcon,
size: textStyle.fontSize! * 1.2,
color: textStyle.color,
),
const SizedBox(width: 8),
],
Text(
text,
style: textStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (suffixIcon != null) ...[
const SizedBox(width: 8),
Icon(
suffixIcon,
size: textStyle.fontSize! * 1.2,
color: textStyle.color,
),
],
],
);
//
switch (buttonType) {
case ButtonType.primary:
case ButtonType.secondary:
case ButtonType.danger:
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: minWidth ?? 0,
maxWidth: maxWidth ?? double.infinity,
minHeight: _getMinHeight(),
),
child: ElevatedButton(
onPressed: buttonEnabled ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: buttonStyle.backgroundColor?.resolve({}),
foregroundColor: buttonStyle.foregroundColor?.resolve({}),
elevation: elevation ?? (buttonEnabled ? 2 : 0),
padding: paddingValue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
borderRadius ?? _getDefaultBorderRadius()),
),
disabledBackgroundColor:
buttonStyle.backgroundColor?.resolve({MaterialState.disabled}),
disabledForegroundColor:
buttonStyle.foregroundColor?.resolve({MaterialState.disabled}),
),
child: buttonContent,
),
);
case ButtonType.outlined:
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: minWidth ?? 0,
maxWidth: maxWidth ?? double.infinity,
minHeight: _getMinHeight(),
),
child: OutlinedButton(
onPressed: buttonEnabled ? onPressed : null,
style: OutlinedButton.styleFrom(
foregroundColor: buttonStyle.foregroundColor?.resolve({}),
padding: paddingValue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
borderRadius ?? _getDefaultBorderRadius()),
),
side: BorderSide(
color: buttonEnabled
? (buttonStyle.foregroundColor?.resolve({}) ??
theme.colorScheme.primary)
: (buttonStyle.foregroundColor?.resolve({MaterialState.disabled}) ??
theme.colorScheme.onSurface.withOpacity(0.12)),
),
disabledForegroundColor:
buttonStyle.foregroundColor?.resolve({MaterialState.disabled}),
),
child: buttonContent,
),
);
case ButtonType.text:
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: minWidth ?? 0,
maxWidth: maxWidth ?? double.infinity,
minHeight: _getMinHeight(),
),
child: TextButton(
onPressed: buttonEnabled ? onPressed : null,
style: TextButton.styleFrom(
foregroundColor: buttonStyle.foregroundColor?.resolve({}),
padding: paddingValue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
borderRadius ?? _getDefaultBorderRadius()),
),
disabledForegroundColor:
buttonStyle.foregroundColor?.resolve({MaterialState.disabled}),
),
child: buttonContent,
),
);
}
}
/// -
ButtonStyle _getButtonStyle(ThemeData theme, AppColors appColors) {
switch (buttonType) {
case ButtonType.primary:
return ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return backgroundColor?.withOpacity(0.12) ??
theme.colorScheme.primary.withOpacity(0.12);
}
return backgroundColor ?? theme.colorScheme.primary;
}),
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return textColor?.withOpacity(0.38) ??
theme.colorScheme.onPrimary.withOpacity(0.38);
}
return textColor ?? theme.colorScheme.onPrimary;
}),
);
case ButtonType.secondary:
return ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return (backgroundColor ?? theme.colorScheme.secondaryContainer)
.withOpacity(0.12);
}
return backgroundColor ?? theme.colorScheme.secondaryContainer;
}),
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return (textColor ?? theme.colorScheme.onSecondaryContainer)
.withOpacity(0.38);
}
return textColor ?? theme.colorScheme.onSecondaryContainer;
}),
);
case ButtonType.danger:
return ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return (backgroundColor ?? AppColors.error).withOpacity(0.12);
}
return backgroundColor ?? AppColors.error;
}),
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return (textColor ?? AppColors.onError).withOpacity(0.38);
}
return textColor ?? AppColors.onError;
}),
);
case ButtonType.outlined:
case ButtonType.text:
return ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.disabled)) {
return (textColor ?? theme.colorScheme.primary).withOpacity(0.38);
}
return textColor ?? theme.colorScheme.primary;
}),
);
}
}
/// -
TextStyle _getTextStyle(ThemeData theme, AppColors appColors) {
final baseStyle = theme.textTheme.labelLarge ?? const TextStyle();
switch (buttonSize) {
case ButtonSize.small:
return baseStyle.copyWith(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _getButtonStyle(theme, appColors)
.foregroundColor
?.resolve(isDisabled || isLoading
? {MaterialState.disabled}
: {}),
);
case ButtonSize.medium:
return baseStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getButtonStyle(theme, appColors)
.foregroundColor
?.resolve(isDisabled || isLoading
? {MaterialState.disabled}
: {}),
);
case ButtonSize.large:
return baseStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w700,
color: _getButtonStyle(theme, appColors)
.foregroundColor
?.resolve(isDisabled || isLoading
? {MaterialState.disabled}
: {}),
);
}
}
/// -
EdgeInsetsGeometry _getPadding() {
if (padding != null) return padding!;
switch (buttonSize) {
case ButtonSize.small:
return const EdgeInsets.symmetric(horizontal: 12, vertical: 8);
case ButtonSize.medium:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 12);
case ButtonSize.large:
return const EdgeInsets.symmetric(horizontal: 24, vertical: 16);
}
}
/// -
double _getMinHeight() {
switch (buttonSize) {
case ButtonSize.small:
return 32;
case ButtonSize.medium:
return 40;
case ButtonSize.large:
return 48;
}
}
/// -
double _getDefaultBorderRadius() {
switch (buttonSize) {
case ButtonSize.small:
return 4;
case ButtonSize.medium:
return 8;
case ButtonSize.large:
return 12;
}
}
}
/// -
enum ButtonType {
/// - 使
primary,
/// - 使
secondary,
/// -
outlined,
/// -
text,
/// - 使
danger,
}
/// -
enum ButtonSize {
/// -
small,
/// -
medium,
/// -
large,
}

View File

@ -0,0 +1,541 @@
import 'package:flutter/material.dart';
/// -
///
/// -
/// [icon] 使Material Icons
/// [title]
/// [message]
/// [actionText] null时不显示按钮
/// [onAction] null时按钮不可点击
/// [iconSize] null时使用默认大小
/// [iconColor] null时使用主题色彩
class EmptyState extends StatelessWidget {
/// 使Material Icons
final IconData icon;
///
final String title;
///
final String? message;
/// null时不显示按钮
final String? actionText;
/// null时按钮不可点击
final VoidCallback? onAction;
/// null时使用默认大小
final double? iconSize;
/// null时使用主题色彩
final Color? iconColor;
/// icon参数
final Widget? customIcon;
/// actionText参数
final Widget? customAction;
///
final double spacing;
///
final EdgeInsetsGeometry? padding;
///
final bool showBorder;
///
final double borderRadius;
/// null时使用透明背景
final Color? backgroundColor;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.message,
this.actionText,
this.onAction,
this.iconSize,
this.iconColor,
this.customIcon,
this.customAction,
this.spacing = 16,
this.padding,
this.showBorder = false,
this.borderRadius = 12,
this.backgroundColor,
});
/// -
factory EmptyState.noData({
String? title,
String? message,
String? actionText,
VoidCallback? onAction,
IconData? icon,
}) {
return EmptyState(
icon: icon ?? Icons.inbox_outlined,
title: title ?? '暂无数据',
message: message ?? '当前没有可显示的数据',
actionText: actionText,
onAction: onAction,
iconColor: Colors.grey,
);
}
/// -
factory EmptyState.noSearchResults({
String? searchQuery,
VoidCallback? onClearSearch,
}) {
return EmptyState(
icon: Icons.search_off_outlined,
title: '未找到相关结果',
message: searchQuery != null
? '未找到与"$searchQuery"相关的内容'
: '未找到相关内容,请尝试其他关键词',
actionText: onClearSearch != null ? '清除搜索' : null,
onAction: onClearSearch,
iconColor: Colors.grey,
);
}
/// -
factory EmptyState.networkError({
VoidCallback? onRetry,
}) {
return EmptyState(
icon: Icons.wifi_off_outlined,
title: '网络连接失败',
message: '请检查网络连接后重试',
actionText: '重新加载',
onAction: onRetry,
iconColor: Colors.orange,
);
}
/// -
factory EmptyState.serverError({
VoidCallback? onRetry,
}) {
return EmptyState(
icon: Icons.cloud_off_outlined,
title: '服务器异常',
message: '服务器暂时无法访问,请稍后重试',
actionText: '重新加载',
onAction: onRetry,
iconColor: Colors.red,
);
}
/// - 访
factory EmptyState.permissionDenied({
VoidCallback? onRequestPermission,
}) {
return EmptyState(
icon: Icons.lock_outline,
title: '权限不足',
message: '您没有访问此内容的权限',
actionText: onRequestPermission != null ? '申请权限' : null,
onAction: onRequestPermission,
iconColor: Colors.orange,
);
}
/// -
factory EmptyState.emptyFavorites({
VoidCallback? onBrowse,
}) {
return EmptyState(
icon: Icons.favorite_border_outlined,
title: '暂无收藏',
message: '您还没有收藏任何内容',
actionText: onBrowse != null ? '去浏览' : null,
onAction: onBrowse,
iconColor: Colors.pink,
);
}
/// -
factory EmptyState.emptyCart({
VoidCallback? onShop,
}) {
return EmptyState(
icon: Icons.shopping_cart_outlined,
title: '购物车是空的',
message: '您还没有添加任何商品',
actionText: onShop != null ? '去购物' : null,
onAction: onShop,
iconColor: Colors.blue,
);
}
/// -
factory EmptyState.emptyHistory({
VoidCallback? onBrowse,
}) {
return EmptyState(
icon: Icons.history_outlined,
title: '暂无历史记录',
message: '您还没有任何操作记录',
actionText: onBrowse != null ? '去浏览' : null,
onAction: onBrowse,
iconColor: Colors.grey,
);
}
/// -
factory EmptyState.emptyFiles({
VoidCallback? onUpload,
}) {
return EmptyState(
icon: Icons.folder_open_outlined,
title: '文件夹是空的',
message: '此文件夹中还没有任何文件',
actionText: onUpload != null ? '上传文件' : null,
onAction: onUpload,
iconColor: Colors.blue,
);
}
/// -
factory EmptyState.emptyImages({
VoidCallback? onAddImages,
}) {
return EmptyState(
icon: Icons.photo_library_outlined,
title: '暂无图片',
message: '您还没有添加任何图片',
actionText: onAddImages != null ? '添加图片' : null,
onAction: onAddImages,
iconColor: Colors.purple,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget content = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
//
if (customIcon != null) ...[
customIcon!,
SizedBox(height: spacing),
] else ...[
Icon(
icon,
size: iconSize ?? 64,
color: iconColor ?? theme.colorScheme.onSurface.withOpacity(0.4),
),
SizedBox(height: spacing),
],
//
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
//
if (message != null) ...[
SizedBox(height: spacing * 0.5),
Text(
message!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
//
if (customAction != null) ...[
SizedBox(height: spacing * 1.5),
customAction!,
] else if (actionText != null) ...[
SizedBox(height: spacing * 1.5),
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh, size: 18),
label: Text(actionText!),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
],
);
//
content = Padding(
padding: padding ?? const EdgeInsets.all(24),
child: content,
);
//
if (showBorder) {
content = Container(
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.surface.withOpacity(0.5),
border: Border.all(
color: theme.colorScheme.outline.withOpacity(0.2),
width: 1,
),
borderRadius: BorderRadius.circular(borderRadius),
),
child: content,
);
} else if (backgroundColor != null) {
content = Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
),
child: content,
);
}
return content;
}
}
/// -
/// [icon]
/// [title]
/// [message]
/// [actionText]
/// [onAction]
/// [showAppBar]
/// [appBarTitle]
class EmptyStatePage extends StatelessWidget {
///
final IconData icon;
///
final String title;
///
final String? message;
///
final String? actionText;
///
final VoidCallback? onAction;
///
final bool showAppBar;
///
final String? appBarTitle;
///
final Widget? customEmptyState;
/// null时使用主题背景色
final Color? backgroundColor;
const EmptyStatePage({
super.key,
required this.icon,
required this.title,
this.message,
this.actionText,
this.onAction,
this.showAppBar = false,
this.appBarTitle,
this.customEmptyState,
this.backgroundColor,
});
///
factory EmptyStatePage.noData({
String? title,
String? message,
String? actionText,
VoidCallback? onAction,
bool showAppBar = false,
String? appBarTitle,
}) {
return EmptyStatePage(
icon: Icons.inbox_outlined,
title: title ?? '暂无数据',
message: message ?? '当前没有可显示的数据',
actionText: actionText,
onAction: onAction,
showAppBar: showAppBar,
appBarTitle: appBarTitle ?? '无数据',
);
}
///
factory EmptyStatePage.noSearchResults({
String? searchQuery,
VoidCallback? onClearSearch,
bool showAppBar = false,
}) {
return EmptyStatePage(
icon: Icons.search_off_outlined,
title: '未找到相关结果',
message: searchQuery != null
? '未找到与"$searchQuery"相关的内容'
: '未找到相关内容,请尝试其他关键词',
actionText: onClearSearch != null ? '清除搜索' : null,
onAction: onClearSearch,
showAppBar: showAppBar,
appBarTitle: '搜索结果',
);
}
///
factory EmptyStatePage.networkError({
VoidCallback? onRetry,
bool showAppBar = false,
}) {
return EmptyStatePage(
icon: Icons.wifi_off_outlined,
title: '网络连接失败',
message: '请检查网络连接后重试',
actionText: '重新加载',
onAction: onRetry,
showAppBar: showAppBar,
appBarTitle: '网络错误',
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: backgroundColor ?? theme.colorScheme.background,
appBar: showAppBar
? AppBar(
title: Text(appBarTitle ?? title),
centerTitle: true,
)
: null,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: customEmptyState ?? EmptyState(
icon: icon,
title: title,
message: message,
actionText: actionText,
onAction: onAction,
showBorder: false,
),
),
),
);
}
}
/// -
/// [isEmpty]
/// [emptyState]
/// [child]
class EmptyStateBuilder extends StatelessWidget {
///
final bool isEmpty;
///
final Widget emptyState;
///
final Widget child;
const EmptyStateBuilder({
super.key,
required this.isEmpty,
required this.emptyState,
required this.child,
});
@override
Widget build(BuildContext context) {
if (isEmpty) {
return emptyState;
}
return child;
}
}
/// -
/// [isEmpty]
/// [icon]
/// [title]
/// [message]
/// [actionText]
/// [onAction]
/// [child]
class EmptyStateWrapper extends StatelessWidget {
///
final bool isEmpty;
///
final IconData icon;
///
final String title;
///
final String? message;
///
final String? actionText;
///
final VoidCallback? onAction;
///
final Widget child;
///
final Widget? customEmptyState;
const EmptyStateWrapper({
super.key,
required this.isEmpty,
required this.icon,
required this.title,
required this.child,
this.message,
this.actionText,
this.onAction,
this.customEmptyState,
});
@override
Widget build(BuildContext context) {
if (isEmpty) {
return customEmptyState ?? EmptyState(
icon: icon,
title: title,
message: message,
actionText: actionText,
onAction: onAction,
);
}
return child;
}
}

View File

@ -0,0 +1,638 @@
import 'package:flutter/material.dart';
/// -
///
/// -
/// [errorType]
/// [title]
/// [message]
/// [actionText] null时不显示按钮
/// [onAction] null时按钮不可点击
/// [iconSize] null时使用默认大小
/// [showDetails]
/// [details]
class ErrorState extends StatelessWidget {
///
final ErrorType errorType;
///
final String title;
///
final String? message;
/// null时不显示按钮
final String? actionText;
/// null时按钮不可点击
final VoidCallback? onAction;
/// null时使用默认大小
final double? iconSize;
///
final Widget? customIcon;
/// actionText参数
final Widget? customAction;
///
final double spacing;
///
final EdgeInsetsGeometry? padding;
///
final bool showBorder;
///
final double borderRadius;
/// null时使用透明背景
final Color? backgroundColor;
///
final bool showDetails;
///
final String? details;
const ErrorState({
super.key,
this.errorType = ErrorType.general,
required this.title,
this.message,
this.actionText,
this.onAction,
this.iconSize,
this.customIcon,
this.customAction,
this.spacing = 16,
this.padding,
this.showBorder = false,
this.borderRadius = 12,
this.backgroundColor,
this.showDetails = false,
this.details,
});
/// -
factory ErrorState.networkError({
VoidCallback? onRetry,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.network,
title: '网络连接失败',
message: '请检查网络连接后重试',
actionText: '重新加载',
onAction: onRetry,
showDetails: showDetails,
details: details,
);
}
/// -
factory ErrorState.serverError({
VoidCallback? onRetry,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.server,
title: '服务器异常',
message: '服务器暂时无法访问,请稍后重试',
actionText: '重新加载',
onAction: onRetry,
showDetails: showDetails,
details: details,
);
}
/// - 访
factory ErrorState.permissionError({
VoidCallback? onRequestPermission,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.permission,
title: '权限不足',
message: '您没有访问此内容的权限',
actionText: onRequestPermission != null ? '申请权限' : null,
onAction: onRequestPermission,
showDetails: showDetails,
details: details,
);
}
/// -
factory ErrorState.dataError({
VoidCallback? onRetry,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.data,
title: '数据解析失败',
message: '数据格式错误,无法正确解析',
actionText: '重新加载',
onAction: onRetry,
showDetails: showDetails,
details: details,
);
}
/// -
factory ErrorState.timeoutError({
VoidCallback? onRetry,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.timeout,
title: '请求超时',
message: '请求时间过长,请重试',
actionText: '重新加载',
onAction: onRetry,
showDetails: showDetails,
details: details,
);
}
/// -
factory ErrorState.notFoundError({
VoidCallback? onGoBack,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.notFound,
title: '页面不存在',
message: '您访问的内容可能已被删除或移动',
actionText: onGoBack != null ? '返回' : null,
onAction: onGoBack,
showDetails: showDetails,
details: details,
);
}
/// -
factory ErrorState.unknownError({
VoidCallback? onRetry,
String? details,
bool showDetails = false,
}) {
return ErrorState(
errorType: ErrorType.unknown,
title: '发生未知错误',
message: '抱歉,发生了一些意外情况',
actionText: '重新加载',
onAction: onRetry,
showDetails: showDetails,
details: details,
);
}
///
IconData _getErrorIcon() {
switch (errorType) {
case ErrorType.network:
return Icons.wifi_off_outlined;
case ErrorType.server:
return Icons.cloud_off_outlined;
case ErrorType.permission:
return Icons.lock_outline;
case ErrorType.data:
return Icons.data_array_outlined;
case ErrorType.timeout:
return Icons.timer_off_outlined;
case ErrorType.notFound:
return Icons.search_off_outlined;
case ErrorType.unknown:
default:
return Icons.error_outline;
}
}
///
Color _getErrorColor(BuildContext context) {
final theme = Theme.of(context);
switch (errorType) {
case ErrorType.network:
return Colors.orange;
case ErrorType.server:
return Colors.red;
case ErrorType.permission:
return Colors.orange;
case ErrorType.data:
return Colors.red;
case ErrorType.timeout:
return Colors.orange;
case ErrorType.notFound:
return Colors.grey;
case ErrorType.unknown:
default:
return theme.colorScheme.error;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final errorColor = _getErrorColor(context);
final errorIcon = _getErrorIcon();
Widget content = Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
//
if (customIcon != null) ...[
customIcon!,
SizedBox(height: spacing),
] else ...[
Icon(
errorIcon,
size: iconSize ?? 64,
color: errorColor,
),
SizedBox(height: spacing),
],
//
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: errorColor,
),
textAlign: TextAlign.center,
),
//
if (message != null) ...[
SizedBox(height: spacing * 0.5),
Text(
message!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
//
if (showDetails && details != null) ...[
SizedBox(height: spacing * 0.5),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: errorColor.withOpacity(0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'详细信息:',
style: theme.textTheme.bodySmall?.copyWith(
color: errorColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
details!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
],
//
if (customAction != null) ...[
SizedBox(height: spacing * 1.5),
customAction!,
] else if (actionText != null) ...[
SizedBox(height: spacing * 1.5),
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh, size: 18),
label: Text(actionText!),
style: ElevatedButton.styleFrom(
backgroundColor: errorColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
],
);
//
content = Padding(
padding: padding ?? const EdgeInsets.all(24),
child: content,
);
//
if (showBorder) {
content = Container(
decoration: BoxDecoration(
color: backgroundColor ?? errorColor.withOpacity(0.05),
border: Border.all(
color: errorColor.withOpacity(0.3),
width: 1,
),
borderRadius: BorderRadius.circular(borderRadius),
),
child: content,
);
} else if (backgroundColor != null) {
content = Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
),
child: content,
);
}
return content;
}
}
/// -
enum ErrorType {
/// -
general,
/// -
network,
/// -
server,
/// -
permission,
/// -
data,
/// -
timeout,
/// -
notFound,
/// -
unknown,
}
/// -
/// [errorType]
/// [title]
/// [message]
/// [actionText]
/// [onAction]
/// [showAppBar]
/// [appBarTitle]
/// [showDetails]
/// [details]
class ErrorStatePage extends StatelessWidget {
///
final ErrorType errorType;
///
final String title;
///
final String? message;
///
final String? actionText;
///
final VoidCallback? onAction;
///
final bool showAppBar;
///
final String? appBarTitle;
///
final Widget? customErrorState;
/// null时使用主题背景色
final Color? backgroundColor;
///
final bool showDetails;
///
final String? details;
const ErrorStatePage({
super.key,
this.errorType = ErrorType.general,
required this.title,
this.message,
this.actionText,
this.onAction,
this.showAppBar = false,
this.appBarTitle,
this.customErrorState,
this.backgroundColor,
this.showDetails = false,
this.details,
});
///
factory ErrorStatePage.networkError({
VoidCallback? onRetry,
bool showAppBar = false,
String? appBarTitle,
String? details,
bool showDetails = false,
}) {
return ErrorStatePage(
errorType: ErrorType.network,
title: '网络连接失败',
message: '请检查网络连接后重试',
actionText: '重新加载',
onAction: onRetry,
showAppBar: showAppBar,
appBarTitle: appBarTitle ?? '网络错误',
showDetails: showDetails,
details: details,
);
}
///
factory ErrorStatePage.serverError({
VoidCallback? onRetry,
bool showAppBar = false,
String? appBarTitle,
String? details,
bool showDetails = false,
}) {
return ErrorStatePage(
errorType: ErrorType.server,
title: '服务器异常',
message: '服务器暂时无法访问,请稍后重试',
actionText: '重新加载',
onAction: onRetry,
showAppBar: showAppBar,
appBarTitle: appBarTitle ?? '服务器错误',
showDetails: showDetails,
details: details,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: backgroundColor ?? theme.colorScheme.background,
appBar: showAppBar
? AppBar(
title: Text(appBarTitle ?? title),
centerTitle: true,
)
: null,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: customErrorState ?? ErrorState(
errorType: errorType,
title: title,
message: message,
actionText: actionText,
onAction: onAction,
showBorder: false,
showDetails: showDetails,
details: details,
),
),
),
);
}
}
/// -
/// [hasError]
/// [errorState]
/// [child]
class ErrorStateBuilder extends StatelessWidget {
///
final bool hasError;
///
final Widget errorState;
///
final Widget child;
const ErrorStateBuilder({
super.key,
required this.hasError,
required this.errorState,
required this.child,
});
@override
Widget build(BuildContext context) {
if (hasError) {
return errorState;
}
return child;
}
}
/// -
/// [hasError]
/// [errorType]
/// [title]
/// [message]
/// [actionText]
/// [onAction]
/// [child]
/// [showDetails]
/// [details]
class ErrorStateWrapper extends StatelessWidget {
///
final bool hasError;
///
final ErrorType errorType;
///
final String title;
///
final String? message;
///
final String? actionText;
///
final VoidCallback? onAction;
///
final Widget child;
///
final Widget? customErrorState;
///
final bool showDetails;
///
final String? details;
const ErrorStateWrapper({
super.key,
required this.hasError,
this.errorType = ErrorType.general,
required this.title,
required this.child,
this.message,
this.actionText,
this.onAction,
this.customErrorState,
this.showDetails = false,
this.details,
});
@override
Widget build(BuildContext context) {
if (hasError) {
return customErrorState ?? ErrorState(
errorType: errorType,
title: title,
message: message,
actionText: actionText,
onAction: onAction,
showDetails: showDetails,
details: details,
);
}
return child;
}
}

View File

@ -0,0 +1,860 @@
import 'package:flutter/material.dart';
/// - 使Flutter内置动画实现加载效果
class SimpleShimmer extends StatefulWidget {
final Widget child;
final Duration duration;
final Color baseColor;
final Color highlightColor;
const SimpleShimmer({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 1500),
required this.baseColor,
required this.highlightColor,
});
@override
State<SimpleShimmer> createState() => _SimpleShimmerState();
}
class _SimpleShimmerState extends State<SimpleShimmer> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
)..repeat();
_animation = Tween<double>(begin: -2, end: 2).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutSine,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment(_animation.value, 0),
end: Alignment(_animation.value + 1, 0),
colors: [
widget.baseColor,
widget.highlightColor,
widget.baseColor,
],
stops: const [0.0, 0.5, 1.0],
).createShader(bounds);
},
child: widget.child,
);
},
);
}
}
/// -
///
/// -
/// [width] null时根据父容器自适应
/// [height]
/// [borderRadius] null时使用默认值
/// [margin]
class Skeleton extends StatefulWidget {
/// null时根据父容器自适应
final double? width;
///
final double height;
/// null时使用默认值
final double? borderRadius;
///
final EdgeInsetsGeometry? margin;
/// null时使用主题色彩
final Color? backgroundColor;
/// null时使用主题色彩
final Color? highlightColor;
const Skeleton({
super.key,
this.width,
required this.height,
this.borderRadius,
this.margin,
this.backgroundColor,
this.highlightColor,
});
@override
State<Skeleton> createState() => _SkeletonState();
}
class _SkeletonState extends State<Skeleton> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_animation = Tween<double>(begin: -2, end: 2).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutSine,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = widget.backgroundColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.5);
final highlightColor = widget.highlightColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.8);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
margin: widget.margin,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius ?? 8),
gradient: LinearGradient(
begin: Alignment(_animation.value, 0),
end: Alignment(_animation.value + 1, 0),
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
);
}
}
/// -
/// [size]
/// [margin]
class CircleSkeleton extends StatefulWidget {
///
final double size;
///
final EdgeInsetsGeometry? margin;
/// null时使用主题色彩
final Color? backgroundColor;
/// null时使用主题色彩
final Color? highlightColor;
const CircleSkeleton({
super.key,
required this.size,
this.margin,
this.backgroundColor,
this.highlightColor,
});
@override
State<CircleSkeleton> createState() => _CircleSkeletonState();
}
class _CircleSkeletonState extends State<CircleSkeleton> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
_animation = Tween<double>(begin: -2, end: 2).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutSine,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = widget.backgroundColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.5);
final highlightColor = widget.highlightColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.8);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.size,
height: widget.size,
margin: widget.margin,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment(_animation.value, 0),
end: Alignment(_animation.value + 1, 0),
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
);
}
}
/// -
///
/// [width] null时根据父容器自适应
/// [height]
/// [aspectRatio] null时使用固定高度
class ImageSkeleton extends StatelessWidget {
/// null时根据父容器自适应
final double? width;
///
final double height;
/// null时使用固定高度
final double? aspectRatio;
///
final EdgeInsetsGeometry? margin;
/// null时使用主题色彩
final Color? backgroundColor;
/// null时使用主题色彩
final Color? highlightColor;
const ImageSkeleton({
super.key,
this.width,
required this.height,
this.aspectRatio,
this.margin,
this.backgroundColor,
this.highlightColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = backgroundColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.5);
final highlightColor = this.highlightColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.8);
Widget skeleton = Container(
width: width,
height: height,
margin: margin,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(8),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 1500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
),
borderRadius: BorderRadius.circular(8),
),
),
);
if (aspectRatio != null) {
skeleton = AspectRatio(
aspectRatio: aspectRatio!,
child: skeleton,
);
}
return skeleton;
}
}
/// -
///
/// [width] null时根据父容器自适应
/// [height] null时使用默认行高
/// [lineCount] 1
class TextSkeleton extends StatelessWidget {
/// null时根据父容器自适应
final double? width;
/// null时使用默认行高
final double? height;
/// 1
final int lineCount;
///
final double lineSpacing;
///
final EdgeInsetsGeometry? margin;
/// null时使用主题色彩
final Color? backgroundColor;
/// null时使用主题色彩
final Color? highlightColor;
const TextSkeleton({
super.key,
this.width,
this.height,
this.lineCount = 1,
this.lineSpacing = 8,
this.margin,
this.backgroundColor,
this.highlightColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = backgroundColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.5);
final highlightColor = this.highlightColor ??
theme.colorScheme.surfaceVariant.withOpacity(0.8);
final textHeight = height ?? theme.textTheme.bodyMedium?.fontSize ?? 14;
if (lineCount == 1) {
return Container(
width: width,
height: textHeight,
margin: margin,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(4),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 1500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
),
borderRadius: BorderRadius.circular(4),
),
),
);
}
return Container(
margin: margin,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(lineCount, (index) {
//
final lineWidth = index == lineCount - 1 ? (width ?? double.infinity) * 0.7 : width;
return Padding(
padding: EdgeInsets.only(bottom: index < lineCount - 1 ? lineSpacing : 0),
child: Container(
width: lineWidth,
height: textHeight,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(4),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 1500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: const [0.0, 0.5, 1.0],
),
borderRadius: BorderRadius.circular(4),
),
),
),
);
}),
),
);
}
}
/// -
/// [size] null时使用默认大小
/// [strokeWidth] 线null时使用默认宽度
/// [color] null时使用主题主色调
class LoadingIndicator extends StatelessWidget {
/// null时使用默认大小
final double? size;
/// 线null时使用默认宽度
final double? strokeWidth;
/// null时使用主题主色调
final Color? color;
const LoadingIndicator({
super.key,
this.size,
this.strokeWidth,
this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final indicatorColor = color ?? theme.colorScheme.primary;
final indicatorSize = size ?? 24;
return SizedBox(
width: indicatorSize,
height: indicatorSize,
child: CircularProgressIndicator(
strokeWidth: strokeWidth ?? 2,
valueColor: AlwaysStoppedAnimation<Color>(indicatorColor),
),
);
}
}
/// 线 -
/// [width] null时根据父容器自适应
/// [height] null时使用默认高度
/// [color] null时使用主题主色调
class LinearLoadingIndicator extends StatelessWidget {
/// null时根据父容器自适应
final double? width;
/// null时使用默认高度
final double? height;
/// null时使用主题主色调
final Color? color;
/// null时使用主题背景色
final Color? backgroundColor;
///
final bool showPercentage;
const LinearLoadingIndicator({
super.key,
this.width,
this.height,
this.color,
this.backgroundColor,
this.showPercentage = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final progressColor = color ?? theme.colorScheme.primary;
final bgColor = backgroundColor ?? theme.colorScheme.surfaceVariant;
final progressHeight = height ?? 4;
Widget progressBar = Container(
width: width,
height: progressHeight,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(progressHeight / 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(progressHeight / 2),
child: LinearProgressIndicator(
backgroundColor: Colors.transparent,
valueColor: AlwaysStoppedAnimation<Color>(progressColor),
minHeight: progressHeight,
),
),
);
if (showPercentage) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
progressBar,
const SizedBox(height: 8),
Text(
'加载中...',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
);
}
return progressBar;
}
}
/// -
/// [isLoading]
/// [loadingWidget] null时使用默认骨架屏
/// [child]
/// [skeleton] null时使用默认骨架屏
class LoadingWrapper extends StatelessWidget {
///
final bool isLoading;
/// null时使用默认骨架屏
final Widget? loadingWidget;
///
final Widget child;
/// null时使用默认骨架屏
final Widget? skeleton;
const LoadingWrapper({
super.key,
required this.isLoading,
required this.child,
this.loadingWidget,
this.skeleton,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return loadingWidget ?? skeleton ?? Container();
}
return child;
}
}
/// -
/// [itemCount]
/// [itemHeight]
/// [itemSpacing]
/// [padding]
class SkeletonList extends StatelessWidget {
///
final int itemCount;
///
final double itemHeight;
///
final double itemSpacing;
///
final EdgeInsetsGeometry? padding;
///
final bool showAvatar;
///
final bool showMultiLineText;
const SkeletonList({
super.key,
required this.itemCount,
this.itemHeight = 80,
this.itemSpacing = 8,
this.padding,
this.showAvatar = true,
this.showMultiLineText = true,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: padding,
itemCount: itemCount,
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: itemSpacing),
child: Container(
height: itemHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAvatar) ...[
CircleSkeleton(size: itemHeight * 0.6),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextSkeleton(
width: double.infinity,
height: 16,
lineCount: showMultiLineText ? 2 : 1,
lineSpacing: 6,
),
],
),
),
],
),
),
);
},
);
}
}
/// -
/// [crossAxisCount]
/// [itemAspectRatio]
/// [itemSpacing]
/// [padding]
class SkeletonGrid extends StatelessWidget {
///
final int crossAxisCount;
///
final double itemAspectRatio;
///
final double itemSpacing;
///
final EdgeInsetsGeometry? padding;
///
final int itemCount;
const SkeletonGrid({
super.key,
required this.crossAxisCount,
this.itemAspectRatio = 1.0,
this.itemSpacing = 8,
this.padding,
this.itemCount = 6,
});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: padding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: itemAspectRatio,
crossAxisSpacing: itemSpacing,
mainAxisSpacing: itemSpacing,
),
itemCount: itemCount,
itemBuilder: (context, index) {
return const Skeleton(
width: double.infinity,
height: double.infinity,
);
},
);
}
}
/// -
/// [message]
/// [showProgress]
/// [backgroundColor] null时使用半透明黑色
class FullScreenLoading extends StatelessWidget {
///
final String? message;
///
final bool showProgress;
/// null时使用半透明黑色
final Color? backgroundColor;
/// null时使用白色
final Color? textColor;
const FullScreenLoading({
super.key,
this.message,
this.showProgress = true,
this.backgroundColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = backgroundColor ?? Colors.black.withOpacity(0.7);
final textColor = this.textColor ?? Colors.white;
return Container(
color: bgColor,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (showProgress) ...[
LoadingIndicator(
size: 48,
color: textColor,
),
const SizedBox(height: 16),
],
if (message != null) ...[
Text(
message!,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
}
/// -
enum LoadingState {
///
initial,
///
loading,
///
completed,
///
error,
///
empty,
}
/// - UI
/// [state]
/// [loadingBuilder]
/// [completedBuilder]
/// [errorBuilder]
/// [emptyBuilder]
/// [initialBuilder]
class LoadingStateBuilder extends StatelessWidget {
///
final LoadingState state;
///
final WidgetBuilder? loadingBuilder;
///
final WidgetBuilder completedBuilder;
///
final WidgetBuilder? errorBuilder;
///
final WidgetBuilder? emptyBuilder;
///
final WidgetBuilder? initialBuilder;
const LoadingStateBuilder({
super.key,
required this.state,
required this.completedBuilder,
this.loadingBuilder,
this.errorBuilder,
this.emptyBuilder,
this.initialBuilder,
});
@override
Widget build(BuildContext context) {
switch (state) {
case LoadingState.initial:
return initialBuilder?.call(context) ?? Container();
case LoadingState.loading:
return loadingBuilder?.call(context) ??
const Center(child: LoadingIndicator());
case LoadingState.completed:
return completedBuilder(context);
case LoadingState.error:
return errorBuilder?.call(context) ??
const Center(child: Text('加载失败,请重试'));
case LoadingState.empty:
return emptyBuilder?.call(context) ??
const Center(child: Text('暂无数据'));
}
}
}