init
This commit is contained in:
245
client/lib/shared/widgets/custom_app_bar.dart
Normal file
245
client/lib/shared/widgets/custom_app_bar.dart
Normal 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);
|
||||
}
|
||||
440
client/lib/shared/widgets/custom_card.dart
Normal file
440
client/lib/shared/widgets/custom_card.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
client/lib/shared/widgets/error_handler.dart
Normal file
243
client/lib/shared/widgets/error_handler.dart
Normal 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('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
412
client/lib/shared/widgets/error_widget.dart
Normal file
412
client/lib/shared/widgets/error_widget.dart
Normal 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);
|
||||
}
|
||||
390
client/lib/shared/widgets/loading_widget.dart
Normal file
390
client/lib/shared/widgets/loading_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
224
client/lib/shared/widgets/network_indicator.dart
Normal file
224
client/lib/shared/widgets/network_indicator.dart
Normal 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}天前';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user