基础ui组件
This commit is contained in:
parent
8005e21110
commit
1212c4ea27
54
CLAUDE.md
54
CLAUDE.md
@ -121,17 +121,29 @@ flutter clean
|
|||||||
- ✅ 数据迁移机制配置完成,支持版本管理
|
- ✅ 数据迁移机制配置完成,支持版本管理
|
||||||
- ⚠️ 代码质量:26个`avoid_print`提示,无致命错误
|
- ⚠️ 代码质量:26个`avoid_print`提示,无致命错误
|
||||||
|
|
||||||
**任务1.3: 核心工具类**
|
**任务1.3: 核心工具类** ✅
|
||||||
- [ ] 图片压缩工具类(长边500px + WebP)
|
- [x] 图片压缩工具类(长边500px + WebP)
|
||||||
- [ ] 文件存储管理工具
|
- [x] 文件存储管理工具
|
||||||
- [ ] UUID生成和路径管理
|
- [x] UUID生成和路径管理
|
||||||
- [ ] 错误处理和日志系统
|
- [x] 错误处理和日志系统
|
||||||
|
|
||||||
**任务1.4: 基础UI组件**
|
**验收状态**:
|
||||||
- [ ] 创建可复用的按钮组件
|
- ✅ 图片压缩工具类:支持JPG/PNG/GIF/WebP/HEIC格式,长边500px,WebP格式,GIF帧提取防内存溢出
|
||||||
- [ ] 实现加载状态组件(骨架屏)
|
- ✅ 文件存储管理:日期分类存储(/yyyy/MM/dd/),完整的文件和目录操作
|
||||||
- [ ] 空状态页面组件
|
- ✅ UUID和路径管理:多种UUID生成,路径安全检查,链式路径构建器
|
||||||
- [ ] 错误提示组件
|
- ✅ 日志系统:5级日志(debug/info/warning/error/fatal),替换所有print语句
|
||||||
|
|
||||||
|
**任务1.4: 基础UI组件** ✅
|
||||||
|
- [x] 创建可复用的按钮组件
|
||||||
|
- [x] 实现加载状态组件(骨架屏)
|
||||||
|
- [x] 空状态页面组件
|
||||||
|
- [x] 错误提示组件
|
||||||
|
|
||||||
|
**验收状态**:
|
||||||
|
- ✅ 可复用按钮组件:支持主要、次要、文本、轮廓、危险按钮类型,多种尺寸,加载状态,图标支持
|
||||||
|
- ✅ 加载状态组件:骨架屏动画、圆形加载器、进度条、全屏加载、列表/网格骨架屏
|
||||||
|
- ✅ 空状态页面组件:多种预设场景(无数据、无搜索结果、网络错误等),自定义图标和操作
|
||||||
|
- ✅ 错误提示组件:多种错误类型(网络、服务器、权限、数据等),详细错误信息显示
|
||||||
|
|
||||||
#### 📤 Phase 2: 分享功能(2-3天)
|
#### 📤 Phase 2: 分享功能(2-3天)
|
||||||
**任务2.1: 分享接收机制**
|
**任务2.1: 分享接收机制**
|
||||||
@ -365,20 +377,20 @@ class UserService {
|
|||||||
### 📊 总体进度
|
### 📊 总体进度
|
||||||
- [x] Phase 1.1: 项目基础配置(5/5)✅
|
- [x] Phase 1.1: 项目基础配置(5/5)✅
|
||||||
- [x] Phase 1.2: 数据层架构搭建(4/4)✅
|
- [x] Phase 1.2: 数据层架构搭建(4/4)✅
|
||||||
- [ ] Phase 1.3: 核心工具类(0/4)
|
- [x] Phase 1.3: 核心工具类(4/4)✅
|
||||||
- [ ] Phase 1.4: 基础UI组件(0/4)
|
- [x] Phase 1.4: 基础UI组件(4/4)✅
|
||||||
- [ ] Phase 2: 分享功能(0/4)
|
- [ ] Phase 2: 分享功能(0/4)
|
||||||
|
|
||||||
### 🎯 当前任务详情
|
### 🎯 当前任务详情
|
||||||
**任务编号**:1.3
|
**任务编号**:2.1
|
||||||
**任务名称**:核心工具类
|
**任务名称**:分享接收机制
|
||||||
**任务状态**:待开始
|
**任务状态**:待开始
|
||||||
**预计完成**:2025年9月17日
|
**预计完成**:2025年9月18日
|
||||||
**依赖项**:Phase 1.2 完成
|
**依赖项**:Phase 1.4 完成
|
||||||
|
|
||||||
**任务验收标准**:
|
**任务验收标准**:
|
||||||
- 图片压缩工具类实现(长边500px + WebP格式)
|
- 分享接收机制配置完成
|
||||||
- 文件存储管理工具(支持日期分类存储)
|
- Android分享接收配置完成
|
||||||
- UUID生成和路径管理工具
|
- iOS分享接收配置完成
|
||||||
- 错误处理和日志系统完善
|
- 多张图片接收逻辑处理
|
||||||
- 代码质量检查通过(解决print警告)
|
- 分享功能测试通过
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// 应用主题配置类
|
/// 应用主题配置类 - 管理应用的主题样式和色彩方案
|
||||||
|
/// 支持浅色主题、深色主题和系统跟随模式
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// 私有构造函数,防止实例化
|
// 私有构造函数,防止实例化
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
@ -243,4 +244,113 @@ class AppTheme {
|
|||||||
// 亮度大于0.5使用深色,否则使用浅色
|
// 亮度大于0.5使用深色,否则使用浅色
|
||||||
return brightness > 0.5 ? Colors.black : Colors.white;
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -41,7 +41,7 @@ class PathUtils {
|
|||||||
String? prefix,
|
String? prefix,
|
||||||
}) {
|
}) {
|
||||||
final baseName = generateTimeBasedId();
|
final baseName = generateTimeBasedId();
|
||||||
final fileName = prefix != null ? '${prefix}$baseName' : baseName;
|
final fileName = prefix != null ? '$prefix$baseName' : baseName;
|
||||||
return '$fileName$extension';
|
return '$fileName$extension';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
lastUsedAt: now,
|
||||||
);
|
);
|
||||||
await foldersBox.put('default', emergencyFolder);
|
await foldersBox.put('default', emergencyFolder);
|
||||||
print('创建紧急默认文件夹');
|
Logger.info('创建紧急默认文件夹');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置当前版本号,避免重复迁移
|
// 设置当前版本号,避免重复迁移
|
||||||
await settingsBox.put(_versionKey, _currentVersion);
|
await settingsBox.put(_versionKey, _currentVersion);
|
||||||
|
|
||||||
print('紧急恢复数据创建完成');
|
Logger.info('紧急恢复数据创建完成');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('紧急恢复数据创建失败: $e');
|
Logger.error('紧急恢复数据创建失败', error: e);
|
||||||
// 如果连紧急恢复都失败,只能抛出致命异常
|
// 如果连紧急恢复都失败,只能抛出致命异常
|
||||||
throw Exception('数据库初始化失败,应用无法正常运行: $e');
|
throw Exception('数据库初始化失败,应用无法正常运行: $e');
|
||||||
}
|
}
|
||||||
@ -177,17 +167,17 @@ class HiveDatabase {
|
|||||||
/// 返回清理操作是否成功
|
/// 返回清理操作是否成功
|
||||||
static Future<bool> clearAll() async {
|
static Future<bool> clearAll() async {
|
||||||
try {
|
try {
|
||||||
print('开始清理所有数据库数据...');
|
Logger.info('开始清理所有数据库数据...');
|
||||||
|
|
||||||
await imagesBox.clear();
|
await imagesBox.clear();
|
||||||
await foldersBox.clear();
|
await foldersBox.clear();
|
||||||
await tagsBox.clear();
|
await tagsBox.clear();
|
||||||
await settingsBox.clear();
|
await settingsBox.clear();
|
||||||
|
|
||||||
print('数据库清理完成');
|
Logger.info('数据库清理完成');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('数据库清理失败: $e');
|
Logger.error('数据库清理失败', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,14 +187,14 @@ class HiveDatabase {
|
|||||||
/// 返回关闭操作是否成功
|
/// 返回关闭操作是否成功
|
||||||
static Future<bool> close() async {
|
static Future<bool> close() async {
|
||||||
try {
|
try {
|
||||||
print('正在关闭Hive数据库...');
|
Logger.info('正在关闭Hive数据库...');
|
||||||
|
|
||||||
await Hive.close();
|
await Hive.close();
|
||||||
|
|
||||||
print('Hive数据库已关闭');
|
Logger.info('Hive数据库已关闭');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('关闭Hive数据库失败: $e');
|
Logger.error('关闭Hive数据库失败', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,7 +203,7 @@ class HiveDatabase {
|
|||||||
/// 返回数据库是否通过完整性检查
|
/// 返回数据库是否通过完整性检查
|
||||||
static Future<bool> validateIntegrity() async {
|
static Future<bool> validateIntegrity() async {
|
||||||
try {
|
try {
|
||||||
print('开始验证数据库完整性...');
|
Logger.info('开始验证数据库完整性...');
|
||||||
|
|
||||||
final isValid = await DatabaseMigration.validateDatabaseIntegrity(
|
final isValid = await DatabaseMigration.validateDatabaseIntegrity(
|
||||||
imagesBox,
|
imagesBox,
|
||||||
@ -222,14 +212,14 @@ class HiveDatabase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
print('数据库完整性验证通过');
|
Logger.info('数据库完整性验证通过');
|
||||||
} else {
|
} else {
|
||||||
print('数据库完整性验证发现问题并已修复');
|
Logger.info('数据库完整性验证发现问题并已修复');
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isValid;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('数据库完整性验证失败: $e');
|
Logger.error('数据库完整性验证失败', error: e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,7 +234,7 @@ class HiveDatabase {
|
|||||||
tagsBox,
|
tagsBox,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('获取数据库统计信息失败: $e');
|
Logger.error('获取数据库统计信息失败', error: e);
|
||||||
return {
|
return {
|
||||||
'error': '获取统计信息失败',
|
'error': '获取统计信息失败',
|
||||||
'message': e.toString(),
|
'message': e.toString(),
|
||||||
@ -256,7 +246,7 @@ class HiveDatabase {
|
|||||||
/// 返回清理结果的统计信息,包括清理的项目数量
|
/// 返回清理结果的统计信息,包括清理的项目数量
|
||||||
static Future<Map<String, int>> cleanup() async {
|
static Future<Map<String, int>> cleanup() async {
|
||||||
try {
|
try {
|
||||||
print('开始清理数据库...');
|
Logger.info('开始清理数据库...');
|
||||||
|
|
||||||
final cleanupStats = await DatabaseMigration.cleanupDatabase(
|
final cleanupStats = await DatabaseMigration.cleanupDatabase(
|
||||||
imagesBox,
|
imagesBox,
|
||||||
@ -264,10 +254,10 @@ class HiveDatabase {
|
|||||||
tagsBox,
|
tagsBox,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('数据库清理完成: $cleanupStats');
|
Logger.info('数据库清理完成: $cleanupStats');
|
||||||
return cleanupStats;
|
return cleanupStats;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('数据库清理失败: $e');
|
Logger.error('数据库清理失败', error: e);
|
||||||
return {
|
return {
|
||||||
'error': 1,
|
'error': 1,
|
||||||
};
|
};
|
||||||
|
|||||||
508
lib/presentation/widgets/custom_button.dart
Normal file
508
lib/presentation/widgets/custom_button.dart
Normal 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,
|
||||||
|
}
|
||||||
541
lib/presentation/widgets/empty_state_widgets.dart
Normal file
541
lib/presentation/widgets/empty_state_widgets.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
638
lib/presentation/widgets/error_widgets.dart
Normal file
638
lib/presentation/widgets/error_widgets.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
860
lib/presentation/widgets/loading_widgets.dart
Normal file
860
lib/presentation/widgets/loading_widgets.dart
Normal 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('暂无数据'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user