This commit is contained in:
sjk
2025-11-17 14:09:17 +08:00
commit 31e46c5bf6
479 changed files with 109324 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
/// 自定义应用栏
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final bool centerTitle;
final Color? backgroundColor;
final Color? foregroundColor;
final double elevation;
final bool automaticallyImplyLeading;
final PreferredSizeWidget? bottom;
final VoidCallback? onBackPressed;
const CustomAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.centerTitle = true,
this.backgroundColor,
this.foregroundColor,
this.elevation = 0,
this.automaticallyImplyLeading = true,
this.bottom,
this.onBackPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
title: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: foregroundColor ?? theme.colorScheme.onSurface,
),
),
centerTitle: centerTitle,
backgroundColor: backgroundColor ?? theme.colorScheme.surface,
foregroundColor: foregroundColor ?? theme.colorScheme.onSurface,
elevation: elevation,
automaticallyImplyLeading: automaticallyImplyLeading,
leading: leading ?? (onBackPressed != null
? IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: onBackPressed,
)
: null),
actions: actions,
bottom: bottom,
surfaceTintColor: Colors.transparent,
);
}
@override
Size get preferredSize => Size.fromHeight(
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0),
);
}
/// 带搜索功能的应用栏
class SearchAppBar extends StatefulWidget implements PreferredSizeWidget {
final String title;
final String hintText;
final ValueChanged<String>? onSearchChanged;
final VoidCallback? onSearchSubmitted;
final List<Widget>? actions;
final bool automaticallyImplyLeading;
final VoidCallback? onBackPressed;
const SearchAppBar({
super.key,
required this.title,
this.hintText = '搜索...',
this.onSearchChanged,
this.onSearchSubmitted,
this.actions,
this.automaticallyImplyLeading = true,
this.onBackPressed,
});
@override
State<SearchAppBar> createState() => _SearchAppBarState();
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _SearchAppBarState extends State<SearchAppBar> {
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _startSearch() {
setState(() {
_isSearching = true;
});
_searchFocusNode.requestFocus();
}
void _stopSearch() {
setState(() {
_isSearching = false;
_searchController.clear();
});
_searchFocusNode.unfocus();
widget.onSearchChanged?.call('');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
title: _isSearching
? TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: widget.hintText,
border: InputBorder.none,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
style: theme.textTheme.bodyLarge,
onChanged: widget.onSearchChanged,
onSubmitted: (_) => widget.onSearchSubmitted?.call(),
)
: Text(
widget.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
centerTitle: !_isSearching,
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
elevation: 0,
automaticallyImplyLeading: widget.automaticallyImplyLeading && !_isSearching,
leading: _isSearching
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _stopSearch,
)
: (widget.onBackPressed != null
? IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: widget.onBackPressed,
)
: null),
actions: _isSearching
? [
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
widget.onSearchChanged?.call('');
},
),
]
: [
IconButton(
icon: const Icon(Icons.search),
onPressed: _startSearch,
),
...?widget.actions,
],
surfaceTintColor: Colors.transparent,
);
}
}
/// 带标签页的应用栏
class TabAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Tab> tabs;
final TabController? controller;
final List<Widget>? actions;
final bool automaticallyImplyLeading;
final VoidCallback? onBackPressed;
const TabAppBar({
super.key,
required this.title,
required this.tabs,
this.controller,
this.actions,
this.automaticallyImplyLeading = true,
this.onBackPressed,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
title: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
elevation: 0,
automaticallyImplyLeading: automaticallyImplyLeading,
leading: onBackPressed != null
? IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: onBackPressed,
)
: null,
actions: actions,
bottom: TabBar(
controller: controller,
tabs: tabs,
labelColor: theme.colorScheme.primary,
unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.6),
indicatorColor: theme.colorScheme.primary,
indicatorWeight: 2,
labelStyle: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: theme.textTheme.titleSmall,
),
surfaceTintColor: Colors.transparent,
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight + kTextTabBarHeight);
}

View File

@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
/// 自定义卡片组件
class CustomCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final Color? color;
final double? elevation;
final BorderRadius? borderRadius;
final Border? border;
final VoidCallback? onTap;
final bool isSelected;
final Color? selectedColor;
final double? width;
final double? height;
final BoxShadow? shadow;
const CustomCard({
super.key,
required this.child,
this.padding,
this.margin,
this.color,
this.elevation,
this.borderRadius,
this.border,
this.onTap,
this.isSelected = false,
this.selectedColor,
this.width,
this.height,
this.shadow,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final defaultBorderRadius = BorderRadius.circular(12);
Widget card = Container(
width: width,
height: height,
margin: margin,
decoration: BoxDecoration(
color: isSelected
? (selectedColor ?? theme.colorScheme.primaryContainer)
: (color ?? theme.colorScheme.surface),
borderRadius: borderRadius ?? defaultBorderRadius,
border: border ?? (isSelected
? Border.all(
color: theme.colorScheme.primary,
width: 2,
)
: Border.all(
color: theme.colorScheme.outline.withOpacity(0.2),
width: 1,
)),
boxShadow: shadow != null
? [shadow!]
: [
BoxShadow(
color: theme.colorScheme.shadow.withOpacity(0.1),
blurRadius: elevation ?? 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius ?? defaultBorderRadius,
child: Padding(
padding: padding ?? const EdgeInsets.all(16),
child: child,
),
),
),
);
return card;
}
}
/// 信息卡片
class InfoCard extends StatelessWidget {
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final Color? backgroundColor;
final bool isSelected;
const InfoCard({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.padding,
this.margin,
this.backgroundColor,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CustomCard(
onTap: onTap,
padding: padding ?? const EdgeInsets.all(16),
margin: margin,
color: backgroundColor,
isSelected: isSelected,
child: Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: theme.textTheme.bodyMedium?.copyWith(
color: isSelected
? theme.colorScheme.onPrimaryContainer.withOpacity(0.8)
: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: 12),
trailing!,
],
],
),
);
}
}
/// 统计卡片
class StatCard extends StatelessWidget {
final String title;
final String value;
final String? unit;
final IconData? icon;
final Color? iconColor;
final Color? backgroundColor;
final VoidCallback? onTap;
final EdgeInsetsGeometry? margin;
final Widget? trailing;
const StatCard({
super.key,
required this.title,
required this.value,
this.unit,
this.icon,
this.iconColor,
this.backgroundColor,
this.onTap,
this.margin,
this.trailing,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CustomCard(
onTap: onTap,
margin: margin,
color: backgroundColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 20,
color: iconColor ?? theme.colorScheme.primary,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
if (unit != null) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
unit!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
),
],
],
),
],
),
);
}
}
/// 功能卡片
class FeatureCard extends StatelessWidget {
final String title;
final String? description;
final IconData icon;
final Color? iconColor;
final Color? backgroundColor;
final VoidCallback? onTap;
final EdgeInsetsGeometry? margin;
final bool isEnabled;
final Widget? badge;
const FeatureCard({
super.key,
required this.title,
this.description,
required this.icon,
this.iconColor,
this.backgroundColor,
this.onTap,
this.margin,
this.isEnabled = true,
this.badge,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CustomCard(
onTap: isEnabled ? onTap : null,
margin: margin,
color: backgroundColor,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (iconColor ?? theme.colorScheme.primary).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 24,
color: isEnabled
? (iconColor ?? theme.colorScheme.primary)
: theme.colorScheme.onSurface.withOpacity(0.4),
),
),
const SizedBox(height: 12),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isEnabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.4),
),
),
if (description != null) ...[
const SizedBox(height: 4),
Text(
description!,
style: theme.textTheme.bodySmall?.copyWith(
color: isEnabled
? theme.colorScheme.onSurface.withOpacity(0.7)
: theme.colorScheme.onSurface.withOpacity(0.4),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
if (badge != null)
Positioned(
top: 0,
right: 0,
child: badge!,
),
],
),
);
}
}
/// 进度卡片
class ProgressCard extends StatelessWidget {
final String title;
final String? subtitle;
final double progress;
final String? progressText;
final Color? progressColor;
final Color? backgroundColor;
final VoidCallback? onTap;
final EdgeInsetsGeometry? margin;
final Widget? trailing;
const ProgressCard({
super.key,
required this.title,
this.subtitle,
required this.progress,
this.progressText,
this.progressColor,
this.backgroundColor,
this.onTap,
this.margin,
this.trailing,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CustomCard(
onTap: onTap,
margin: margin,
color: backgroundColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: 12),
trailing!,
],
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
backgroundColor: theme.colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
progressColor ?? theme.colorScheme.primary,
),
minHeight: 6,
),
),
if (progressText != null) ...[
const SizedBox(width: 12),
Text(
progressText!,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface.withOpacity(0.8),
),
),
],
],
),
],
),
);
}
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/error_provider.dart';
/// 全局错误处理组件
class GlobalErrorHandler extends ConsumerWidget {
final Widget child;
const GlobalErrorHandler({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorState = ref.watch(errorProvider);
// 监听错误状态变化
ref.listen<ErrorState>(errorProvider, (previous, next) {
if (next.currentError != null) {
_showErrorDialog(context, next.currentError!, ref);
}
});
return child;
}
void _showErrorDialog(BuildContext context, AppError error, WidgetRef ref) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ErrorDialog(
error: error,
onDismiss: () {
Navigator.of(context).pop();
ref.read(errorProvider.notifier).removeError(error.id);
},
onRetry: null,
),
);
}
}
/// 错误对话框
class ErrorDialog extends StatelessWidget {
final AppError error;
final VoidCallback onDismiss;
final VoidCallback? onRetry;
const ErrorDialog({
super.key,
required this.error,
required this.onDismiss,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(
_getErrorIcon(),
color: _getErrorColor(),
),
const SizedBox(width: 8),
Text(_getErrorTitle()),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(error.message),
if (error.details != null) ...[
const SizedBox(height: 8),
Text(
error.details!,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
actions: [
if (onRetry != null)
TextButton(
onPressed: () {
onDismiss();
onRetry!();
},
child: const Text('重试'),
),
TextButton(
onPressed: onDismiss,
child: const Text('确定'),
),
],
);
}
IconData _getErrorIcon() {
switch (error.type) {
case ErrorType.network:
return Icons.wifi_off;
case ErrorType.authentication:
return Icons.lock;
case ErrorType.validation:
return Icons.warning;
case ErrorType.server:
return Icons.error;
case ErrorType.unknown:
default:
return Icons.help_outline;
}
}
Color _getErrorColor() {
switch (error.severity) {
case ErrorSeverity.critical:
return Colors.red;
case ErrorSeverity.error:
return Colors.orange;
case ErrorSeverity.warning:
return Colors.yellow[700]!;
case ErrorSeverity.info:
return Colors.blue;
}
}
String _getErrorTitle() {
switch (error.type) {
case ErrorType.network:
return '网络错误';
case ErrorType.authentication:
return '认证错误';
case ErrorType.validation:
return '验证错误';
case ErrorType.server:
return '服务器错误';
case ErrorType.unknown:
default:
return '未知错误';
}
}
}
/// 错误横幅组件
class ErrorBanner extends ConsumerWidget {
const ErrorBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorState = ref.watch(errorProvider);
if (!errorState.hasErrors) {
return const SizedBox.shrink();
}
final lowSeverityErrors = errorState.errors
.where((error) => error.severity == ErrorSeverity.info)
.toList();
if (lowSeverityErrors.isEmpty) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.blue[50],
child: Row(
children: [
Icon(
Icons.info_outline,
color: Colors.blue[700],
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
lowSeverityErrors.first.message,
style: TextStyle(
color: Colors.blue[700],
fontSize: 14,
),
),
),
IconButton(
onPressed: () {
ref.read(errorProvider.notifier).removeError(lowSeverityErrors.first.id);
},
icon: Icon(
Icons.close,
color: Colors.blue[700],
size: 20,
),
),
],
),
);
}
}
/// 错误重试组件
class ErrorRetryWidget extends StatelessWidget {
final String message;
final VoidCallback onRetry;
final IconData? icon;
const ErrorRetryWidget({
super.key,
required this.message,
required this.onRetry,
this.icon,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon ?? Icons.error_outline,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
);
}
}

View File

@@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
/// 错误显示组件
class ErrorDisplayWidget extends StatelessWidget {
final String message;
final String? title;
final IconData? icon;
final VoidCallback? onRetry;
final String? retryText;
final EdgeInsetsGeometry? padding;
final bool showIcon;
final Color? iconColor;
final TextAlign textAlign;
const ErrorDisplayWidget({
super.key,
required this.message,
this.title,
this.icon,
this.onRetry,
this.retryText,
this.padding,
this.showIcon = true,
this.iconColor,
this.textAlign = TextAlign.center,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: padding ?? const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (showIcon) ...[
Icon(
icon ?? Icons.error_outline,
size: 64,
color: iconColor ?? theme.colorScheme.error,
),
const SizedBox(height: 16),
],
if (title != null) ...[
Text(
title!,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
textAlign: textAlign,
),
const SizedBox(height: 8),
],
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: textAlign,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(retryText ?? '重试'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
],
),
);
}
}
/// 页面错误组件
class PageErrorWidget extends StatelessWidget {
final String message;
final String? title;
final VoidCallback? onRetry;
final bool showAppBar;
final String? appBarTitle;
final VoidCallback? onBack;
const PageErrorWidget({
super.key,
required this.message,
this.title,
this.onRetry,
this.showAppBar = false,
this.appBarTitle,
this.onBack,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: showAppBar
? AppBar(
title: appBarTitle != null ? Text(appBarTitle!) : null,
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
leading: onBack != null
? IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: onBack,
)
: null,
)
: null,
body: Center(
child: ErrorDisplayWidget(
title: title ?? '出错了',
message: message,
onRetry: onRetry,
),
),
);
}
}
/// 网络错误组件
class NetworkErrorWidget extends StatelessWidget {
final VoidCallback? onRetry;
final String? customMessage;
final EdgeInsetsGeometry? padding;
const NetworkErrorWidget({
super.key,
this.onRetry,
this.customMessage,
this.padding,
});
@override
Widget build(BuildContext context) {
return ErrorDisplayWidget(
title: '网络连接失败',
message: customMessage ?? '请检查网络连接后重试',
icon: Icons.wifi_off,
onRetry: onRetry,
padding: padding,
);
}
}
/// 空数据组件
class EmptyDataWidget extends StatelessWidget {
final String message;
final String? title;
final IconData? icon;
final VoidCallback? onAction;
final String? actionText;
final EdgeInsetsGeometry? padding;
final Color? iconColor;
const EmptyDataWidget({
super.key,
required this.message,
this.title,
this.icon,
this.onAction,
this.actionText,
this.padding,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: padding ?? const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.inbox_outlined,
size: 64,
color: iconColor ?? theme.colorScheme.onSurface.withOpacity(0.4),
),
const SizedBox(height: 16),
if (title != null) ...[
Text(
title!,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
],
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
if (onAction != null) ...[
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(actionText ?? '添加'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
side: BorderSide(color: theme.colorScheme.primary),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
],
),
);
}
}
/// 搜索无结果组件
class NoSearchResultWidget extends StatelessWidget {
final String query;
final VoidCallback? onClear;
final EdgeInsetsGeometry? padding;
const NoSearchResultWidget({
super.key,
required this.query,
this.onClear,
this.padding,
});
@override
Widget build(BuildContext context) {
return EmptyDataWidget(
title: '未找到相关结果',
message: '没有找到与"$query"相关的内容\n请尝试其他关键词',
icon: Icons.search_off,
onAction: onClear,
actionText: '清除搜索',
padding: padding,
);
}
}
/// 权限错误组件
class PermissionErrorWidget extends StatelessWidget {
final String message;
final VoidCallback? onRequestPermission;
final EdgeInsetsGeometry? padding;
const PermissionErrorWidget({
super.key,
required this.message,
this.onRequestPermission,
this.padding,
});
@override
Widget build(BuildContext context) {
return ErrorDisplayWidget(
title: '权限不足',
message: message,
icon: Icons.lock_outline,
onRetry: onRequestPermission,
retryText: '授权',
padding: padding,
iconColor: Theme.of(context).colorScheme.warning,
);
}
}
/// 服务器错误组件
class ServerErrorWidget extends StatelessWidget {
final String? customMessage;
final VoidCallback? onRetry;
final EdgeInsetsGeometry? padding;
const ServerErrorWidget({
super.key,
this.customMessage,
this.onRetry,
this.padding,
});
@override
Widget build(BuildContext context) {
return ErrorDisplayWidget(
title: '服务器错误',
message: customMessage ?? '服务器暂时无法响应,请稍后重试',
icon: Icons.cloud_off,
onRetry: onRetry,
padding: padding,
);
}
}
/// 通用错误处理器
class ErrorHandler {
static Widget handleError(
Object error, {
VoidCallback? onRetry,
EdgeInsetsGeometry? padding,
}) {
if (error.toString().contains('network') ||
error.toString().contains('connection')) {
return NetworkErrorWidget(
onRetry: onRetry,
padding: padding,
);
}
if (error.toString().contains('permission')) {
return PermissionErrorWidget(
message: error.toString(),
onRequestPermission: onRetry,
padding: padding,
);
}
if (error.toString().contains('server') ||
error.toString().contains('500')) {
return ServerErrorWidget(
onRetry: onRetry,
padding: padding,
);
}
return ErrorDisplayWidget(
message: error.toString(),
onRetry: onRetry,
padding: padding,
);
}
}
/// 错误边界组件
class ErrorBoundary extends StatefulWidget {
final Widget child;
final Widget Function(Object error)? errorBuilder;
final void Function(Object error, StackTrace stackTrace)? onError;
const ErrorBoundary({
super.key,
required this.child,
this.errorBuilder,
this.onError,
});
@override
State<ErrorBoundary> createState() => _ErrorBoundaryState();
}
class _ErrorBoundaryState extends State<ErrorBoundary> {
Object? _error;
@override
Widget build(BuildContext context) {
if (_error != null) {
return widget.errorBuilder?.call(_error!) ??
ErrorHandler.handleError(_error!);
}
return widget.child;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 重置错误状态
if (_error != null) {
setState(() {
_error = null;
});
}
}
void _handleError(Object error, StackTrace stackTrace) {
widget.onError?.call(error, stackTrace);
if (mounted) {
setState(() {
_error = error;
});
}
}
}
/// 扩展 ColorScheme 以支持警告颜色
extension ColorSchemeExtension on ColorScheme {
Color get warning => const Color(0xFFFF9800);
Color get onWarning => const Color(0xFF000000);
}

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
/// 加载组件
class LoadingWidget extends StatelessWidget {
final String? message;
final double? size;
final Color? color;
final EdgeInsetsGeometry? padding;
final bool showMessage;
const LoadingWidget({
super.key,
this.message,
this.size,
this.color,
this.padding,
this.showMessage = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: padding ?? const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size ?? 32,
height: size ?? 32,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
color ?? theme.colorScheme.primary,
),
),
),
if (showMessage && message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}
/// 页面加载组件
class PageLoadingWidget extends StatelessWidget {
final String? message;
final bool showAppBar;
final String? title;
const PageLoadingWidget({
super.key,
this.message,
this.showAppBar = false,
this.title,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: showAppBar
? AppBar(
title: title != null ? Text(title!) : null,
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
)
: null,
body: Center(
child: LoadingWidget(
message: message ?? '加载中...',
size: 48,
),
),
);
}
}
/// 列表加载组件
class ListLoadingWidget extends StatelessWidget {
final int itemCount;
final double itemHeight;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
const ListLoadingWidget({
super.key,
this.itemCount = 5,
this.itemHeight = 80,
this.padding,
this.margin,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: padding,
itemCount: itemCount,
itemBuilder: (context, index) => Container(
height: itemHeight,
margin: margin ?? const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: const ShimmerWidget(),
),
);
}
}
/// 骨架屏组件
class ShimmerWidget extends StatefulWidget {
final double? width;
final double? height;
final BorderRadius? borderRadius;
final Color? baseColor;
final Color? highlightColor;
const ShimmerWidget({
super.key,
this.width,
this.height,
this.borderRadius,
this.baseColor,
this.highlightColor,
});
@override
State<ShimmerWidget> createState() => _ShimmerWidgetState();
}
class _ShimmerWidgetState extends State<ShimmerWidget>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_animationController.repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = widget.baseColor ?? theme.colorScheme.surfaceVariant;
final highlightColor = widget.highlightColor ??
theme.colorScheme.surface.withOpacity(0.8);
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: [
_animation.value - 0.3,
_animation.value,
_animation.value + 0.3,
],
),
),
);
},
);
}
}
/// 卡片骨架屏
class CardShimmerWidget extends StatelessWidget {
final EdgeInsetsGeometry? margin;
final double? height;
const CardShimmerWidget({
super.key,
this.margin,
this.height,
});
@override
Widget build(BuildContext context) {
return Container(
margin: margin ?? const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const ShimmerWidget(
width: 40,
height: 40,
borderRadius: BorderRadius.all(Radius.circular(20)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerWidget(
width: double.infinity,
height: 16,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
ShimmerWidget(
width: 120,
height: 12,
borderRadius: BorderRadius.circular(4),
),
],
),
),
],
),
if (height != null && height! > 100) ...[
const SizedBox(height: 16),
ShimmerWidget(
width: double.infinity,
height: 12,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
ShimmerWidget(
width: double.infinity,
height: 12,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
ShimmerWidget(
width: 200,
height: 12,
borderRadius: BorderRadius.circular(4),
),
],
],
),
);
}
}
/// 按钮加载状态
class LoadingButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final IconData? icon;
final Color? backgroundColor;
final Color? foregroundColor;
final EdgeInsetsGeometry? padding;
final double? width;
final double? height;
final BorderRadius? borderRadius;
const LoadingButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.icon,
this.backgroundColor,
this.foregroundColor,
this.padding,
this.width,
this.height,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
height: height ?? 48,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
foregroundColor: foregroundColor ?? theme.colorScheme.onPrimary,
padding: padding ?? const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: borderRadius ?? BorderRadius.circular(8),
),
elevation: 0,
),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
foregroundColor ?? theme.colorScheme.onPrimary,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
/// 刷新指示器
class RefreshIndicatorWidget extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;
final String? refreshText;
const RefreshIndicatorWidget({
super.key,
required this.child,
required this.onRefresh,
this.refreshText,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
child: child,
);
}
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/network_provider.dart';
/// 网络状态指示器
class NetworkIndicator extends ConsumerWidget {
final Widget child;
final bool showBanner;
const NetworkIndicator({
super.key,
required this.child,
this.showBanner = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final networkState = ref.watch(networkProvider);
return Column(
children: [
if (showBanner && networkState.status == NetworkStatus.disconnected)
_buildOfflineBanner(context),
Expanded(child: child),
],
);
}
Widget _buildOfflineBanner(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.red[600],
child: Row(
children: [
const Icon(
Icons.wifi_off,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'网络连接已断开',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextButton(
onPressed: () {
// 可以添加重试逻辑
},
child: const Text(
'重试',
style: TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
);
}
}
/// 网络状态图标
class NetworkStatusIcon extends ConsumerWidget {
final double size;
final Color? color;
const NetworkStatusIcon({
super.key,
this.size = 24,
this.color,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final networkState = ref.watch(networkProvider);
return Icon(
_getNetworkIcon(networkState.status, networkState.type),
size: size,
color: color ?? _getNetworkColor(networkState.status),
);
}
IconData _getNetworkIcon(NetworkStatus status, NetworkType type) {
if (status == NetworkStatus.disconnected) {
return Icons.wifi_off;
}
switch (type) {
case NetworkType.wifi:
return Icons.wifi;
case NetworkType.mobile:
return Icons.signal_cellular_4_bar;
case NetworkType.ethernet:
return Icons.cable;
case NetworkType.unknown:
default:
return Icons.device_unknown;
}
}
Color _getNetworkColor(NetworkStatus status) {
switch (status) {
case NetworkStatus.connected:
return Colors.green;
case NetworkStatus.disconnected:
return Colors.red;
case NetworkStatus.unknown:
return Colors.grey;
}
}
}
/// 网络状态卡片
class NetworkStatusCard extends ConsumerWidget {
const NetworkStatusCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final networkState = ref.watch(networkProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
NetworkStatusIcon(
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getStatusText(networkState.status),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
_getTypeText(networkState.type),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'最后更新: ${_formatTime(networkState.lastChecked)}',
style: Theme.of(context).textTheme.bodySmall,
),
TextButton(
onPressed: () {
ref.read(networkProvider.notifier).refreshNetworkStatus();
},
child: const Text('刷新'),
),
],
),
],
),
),
);
}
String _getStatusText(NetworkStatus status) {
switch (status) {
case NetworkStatus.connected:
return '已连接';
case NetworkStatus.disconnected:
return '未连接';
case NetworkStatus.unknown:
return '未知状态';
}
}
String _getTypeText(NetworkType type) {
switch (type) {
case NetworkType.wifi:
return 'Wi-Fi';
case NetworkType.mobile:
return '移动网络';
case NetworkType.ethernet:
return '以太网';
case NetworkType.unknown:
default:
return '未知';
}
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final difference = now.difference(time);
if (difference.inMinutes < 1) {
return '刚刚';
} else if (difference.inHours < 1) {
return '${difference.inMinutes}分钟前';
} else if (difference.inDays < 1) {
return '${difference.inHours}小时前';
} else {
return '${difference.inDays}天前';
}
}
}