Initial commit: Flutter 无书应用项目
This commit is contained in:
328
lib/widgets/common_widgets.dart
Normal file
328
lib/widgets/common_widgets.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
// 自定义按钮组件
|
||||
class CustomButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final double? borderRadius;
|
||||
final bool isLoading;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
this.borderRadius,
|
||||
this.isLoading = false,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height ?? 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? AppConstants.primaryColor,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(borderRadius ?? 8),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义卡片组件
|
||||
class CustomCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? borderRadius;
|
||||
final Color? backgroundColor;
|
||||
final VoidCallback? onTap;
|
||||
final BoxBorder? border;
|
||||
|
||||
const CustomCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.borderRadius,
|
||||
this.backgroundColor,
|
||||
this.onTap,
|
||||
this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.all(8),
|
||||
child: Material(
|
||||
color: backgroundColor ?? AppConstants.surfaceColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius ?? 12),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius ?? 12),
|
||||
child: Container(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius ?? 12),
|
||||
border: border,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义输入框组件
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final TextEditingController? controller;
|
||||
final IconData? prefixIcon;
|
||||
final IconData? suffixIcon;
|
||||
final VoidCallback? onSuffixIconTap;
|
||||
final bool obscureText;
|
||||
final String? Function(String?)? validator;
|
||||
final void Function(String)? onChanged;
|
||||
final TextInputType? keyboardType;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.controller,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.onSuffixIconTap,
|
||||
this.obscureText = false,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.keyboardType,
|
||||
this.maxLines = 1,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: maxLines,
|
||||
enabled: enabled,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon != null
|
||||
? IconButton(
|
||||
icon: Icon(suffixIcon),
|
||||
onPressed: onSuffixIconTap,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载指示器组件
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
final String? message;
|
||||
final Color? color;
|
||||
|
||||
const LoadingIndicator({
|
||||
super.key,
|
||||
this.message,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
color ?? AppConstants.primaryColor,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态组件
|
||||
class EmptyState extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final IconData? icon;
|
||||
final Widget? action;
|
||||
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.inbox_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 错误状态组件
|
||||
class ErrorState extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppConstants.errorColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
CustomButton(
|
||||
text: AppConstants.retryText,
|
||||
onPressed: onRetry,
|
||||
icon: AppConstants.refreshIcon,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
382
lib/widgets/responsive_widgets.dart
Normal file
382
lib/widgets/responsive_widgets.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/responsive_layout.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
class ResponsiveButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final bool isLoading;
|
||||
final ButtonSize? size;
|
||||
|
||||
const ResponsiveButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.icon,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
this.isLoading = false,
|
||||
this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final buttonSize = size ?? (ResponsiveLayout.isMobile(context) ? ButtonSize.small : ButtonSize.medium);
|
||||
|
||||
return SizedBox(
|
||||
width: ResponsiveLayout.isMobile(context) ? double.infinity : null,
|
||||
height: _getHeight(buttonSize),
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? AppConstants.primaryColor,
|
||||
foregroundColor: textColor ?? Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_getBorderRadius(buttonSize)),
|
||||
),
|
||||
elevation: 2,
|
||||
padding: _getPadding(buttonSize),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: _getIconSize(buttonSize),
|
||||
height: _getIconSize(buttonSize),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: _getIconSize(buttonSize)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: _getFontSize(buttonSize)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _getHeight(ButtonSize size) {
|
||||
switch (size) {
|
||||
case ButtonSize.small:
|
||||
return 36;
|
||||
case ButtonSize.medium:
|
||||
return 44;
|
||||
case ButtonSize.large:
|
||||
return 52;
|
||||
}
|
||||
}
|
||||
|
||||
double _getBorderRadius(ButtonSize size) {
|
||||
switch (size) {
|
||||
case ButtonSize.small:
|
||||
return 6;
|
||||
case ButtonSize.medium:
|
||||
return 8;
|
||||
case ButtonSize.large:
|
||||
return 12;
|
||||
}
|
||||
}
|
||||
|
||||
EdgeInsets _getPadding(ButtonSize size) {
|
||||
switch (size) {
|
||||
case ButtonSize.small:
|
||||
return const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
|
||||
case ButtonSize.medium:
|
||||
return const EdgeInsets.symmetric(horizontal: 24, vertical: 12);
|
||||
case ButtonSize.large:
|
||||
return const EdgeInsets.symmetric(horizontal: 32, vertical: 16);
|
||||
}
|
||||
}
|
||||
|
||||
double _getIconSize(ButtonSize size) {
|
||||
switch (size) {
|
||||
case ButtonSize.small:
|
||||
return 16;
|
||||
case ButtonSize.medium:
|
||||
return 20;
|
||||
case ButtonSize.large:
|
||||
return 24;
|
||||
}
|
||||
}
|
||||
|
||||
double _getFontSize(ButtonSize size) {
|
||||
switch (size) {
|
||||
case ButtonSize.small:
|
||||
return 12;
|
||||
case ButtonSize.medium:
|
||||
return 14;
|
||||
case ButtonSize.large:
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ButtonSize { small, medium, large }
|
||||
|
||||
class ResponsiveText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
|
||||
const ResponsiveText({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final responsiveStyle = _getResponsiveStyle(context, style);
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: responsiveStyle,
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow ?? TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _getResponsiveStyle(BuildContext context, TextStyle? baseStyle) {
|
||||
final baseTextStyle = baseStyle ?? Theme.of(context).textTheme.bodyMedium!;
|
||||
|
||||
if (ResponsiveLayout.isMobile(context)) {
|
||||
return baseTextStyle.copyWith(fontSize: (baseTextStyle.fontSize ?? 14) * 0.9);
|
||||
} else if (ResponsiveLayout.isLargeDesktop(context)) {
|
||||
return baseTextStyle.copyWith(fontSize: (baseTextStyle.fontSize ?? 14) * 1.1);
|
||||
}
|
||||
|
||||
return baseTextStyle;
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
|
||||
const ResponsiveImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final effectiveWidth = width ?? constraints.maxWidth;
|
||||
final effectiveHeight = height;
|
||||
|
||||
return Container(
|
||||
width: effectiveWidth,
|
||||
height: effectiveHeight,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: effectiveWidth,
|
||||
maxHeight: effectiveHeight ?? double.infinity,
|
||||
),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: fit,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return placeholder ?? _buildDefaultPlaceholder();
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return errorWidget ?? _buildDefaultError();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultPlaceholder() {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultError() {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error_outline, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
|
||||
const ResponsiveAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = ResponsiveLayout.isMobile(context);
|
||||
final isLandscape = ResponsiveLayout.isLandscape(context);
|
||||
|
||||
return AppBar(
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: foregroundColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
actions: _buildActions(context),
|
||||
leading: leading,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
backgroundColor: backgroundColor ?? AppConstants.primaryColor,
|
||||
foregroundColor: foregroundColor ?? Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: !isMobile || isLandscape,
|
||||
titleSpacing: isMobile ? 16 : 24,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget>? _buildActions(BuildContext context) {
|
||||
if (actions == null) return null;
|
||||
|
||||
final isMobile = ResponsiveLayout.isMobile(context);
|
||||
|
||||
if (isMobile && actions!.length > 2) {
|
||||
// 移动端如果操作按钮太多,显示省略菜单
|
||||
return [
|
||||
PopupMenuButton<int>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (index) {
|
||||
// 处理菜单选择
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
return actions!.asMap().entries.map((entry) {
|
||||
return PopupMenuItem<int>(
|
||||
value: entry.key,
|
||||
child: entry.value,
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class ResponsiveScaffold extends StatelessWidget {
|
||||
final Widget body;
|
||||
final String? title;
|
||||
final Widget? floatingActionButton;
|
||||
final Widget? bottomNavigationBar;
|
||||
final Widget? drawer;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const ResponsiveScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
this.title,
|
||||
this.floatingActionButton,
|
||||
this.bottomNavigationBar,
|
||||
this.drawer,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = ResponsiveLayout.isDesktop(context);
|
||||
final isLandscape = ResponsiveLayout.isLandscape(context);
|
||||
|
||||
Widget scaffoldBody = ResponsiveContainer(
|
||||
child: body,
|
||||
);
|
||||
|
||||
// 桌面端或横屏模式添加侧边间距
|
||||
if (isDesktop || isLandscape) {
|
||||
scaffoldBody = Row(
|
||||
children: [
|
||||
if (drawer != null && isDesktop) ...[
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: drawer,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
],
|
||||
Expanded(child: scaffoldBody),
|
||||
if (isDesktop) const SizedBox(width: 280),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: title != null
|
||||
? ResponsiveAppBar(
|
||||
title: title!,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
)
|
||||
: null,
|
||||
body: scaffoldBody,
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
drawer: isDesktop ? null : drawer,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/widgets/tabbed_nav_app_bar.dart
Normal file
67
lib/widgets/tabbed_nav_app_bar.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// 时间: 2026-03-22
|
||||
/// 功能: 主导航内「标题 + Tab」共用 AppBar 构造
|
||||
/// 介绍: 压缩工具栏与 Tab 行高度,关闭 M3 卷动 surface tint,统一收藏页与发现页顶部观感
|
||||
/// 最新变化: 初始提取,减少两页相同结构的顶部留白
|
||||
|
||||
/// 主导航子页(IndexedStack 内)带 [TabBar] 的页面共用 [AppBar] 配置
|
||||
class TabbedNavAppBar {
|
||||
TabbedNavAppBar._();
|
||||
|
||||
static AppBar build({
|
||||
required String title,
|
||||
required TabController tabController,
|
||||
required List<String> tabLabels,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
bool tabBarScrollable = false,
|
||||
EdgeInsetsGeometry? tabPadding,
|
||||
EdgeInsetsGeometry? tabLabelPadding,
|
||||
}) {
|
||||
return AppBar(
|
||||
toolbarHeight: AppConstants.tabbedPageToolbarHeight,
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
iconTheme: const IconThemeData(color: Colors.black87),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
centerTitle: true,
|
||||
leading: leading,
|
||||
actions: actions,
|
||||
// 用 PreferredSize + 固定高度略小于默认 Tab 行,减少两页相同的「标题+Tab」总高度(当前 SDK 无 tabHeight 参数)
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(AppConstants.tabbedPageTabBarHeight),
|
||||
child: SizedBox(
|
||||
height: AppConstants.tabbedPageTabBarHeight,
|
||||
child: TabBar(
|
||||
controller: tabController,
|
||||
isScrollable: tabBarScrollable,
|
||||
padding:
|
||||
tabPadding ??
|
||||
(tabBarScrollable
|
||||
? const EdgeInsets.only(
|
||||
left: AppConstants.pageHorizontalPadding,
|
||||
right: 8,
|
||||
)
|
||||
: null),
|
||||
labelPadding: tabLabelPadding,
|
||||
tabAlignment: tabBarScrollable ? TabAlignment.start : null,
|
||||
dividerHeight: 0,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: tabLabels.map((String e) => Tab(text: e)).toList(),
|
||||
labelColor: AppConstants.primaryColor,
|
||||
unselectedLabelColor: Colors.grey[600],
|
||||
indicatorColor: AppConstants.primaryColor,
|
||||
indicatorWeight: 3,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user