Initial commit: Flutter 无书应用项目

This commit is contained in:
Developer
2026-03-30 02:35:31 +08:00
commit 9175ff9905
566 changed files with 103261 additions and 0 deletions

View 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,
),
],
],
),
),
);
}
}

View 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,
);
}
}

View 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),
),
),
),
);
}
}