This commit is contained in:
2026-06-03 14:07:10 +08:00
parent 3bdece45c3
commit 9eb8d1cc37
118 changed files with 5689 additions and 2 deletions

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_network_image.dart';
class AppAvatar extends StatelessWidget {
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});
final String? imageUrl;
final String? initials;
final double size;
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size / 2);
return ClipRRect(
borderRadius: radius,
child: SizedBox.square(
dimension: size,
child: imageUrl == null || imageUrl!.isEmpty
? ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Text(
(initials == null || initials!.isEmpty)
? 'A'
: initials!.characters.first.toUpperCase(),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
)
: AppNetworkImage(url: imageUrl!, fit: BoxFit.cover),
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
enum AppButtonVariant { primary, secondary, outline, text, danger }
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.variant = AppButtonVariant.primary,
this.isLoading = false,
this.expand = false,
this.height = 44,
});
final String label;
final VoidCallback? onPressed;
final Widget? icon;
final AppButtonVariant variant;
final bool isLoading;
final bool expand;
final double height;
@override
Widget build(BuildContext context) {
final child = _ButtonContent(
label: label,
icon: icon,
isLoading: isLoading,
);
final enabled = isLoading ? null : onPressed;
final size = Size(expand ? double.infinity : 0, height);
return switch (variant) {
AppButtonVariant.primary => ElevatedButton(
onPressed: enabled,
style: ElevatedButton.styleFrom(minimumSize: size),
child: child,
),
AppButtonVariant.secondary => FilledButton.tonal(
onPressed: enabled,
style: FilledButton.styleFrom(minimumSize: size),
child: child,
),
AppButtonVariant.outline => OutlinedButton(
onPressed: enabled,
style: OutlinedButton.styleFrom(minimumSize: size),
child: child,
),
AppButtonVariant.text => TextButton(
onPressed: enabled,
style: TextButton.styleFrom(minimumSize: size),
child: child,
),
AppButtonVariant.danger => FilledButton(
onPressed: enabled,
style: FilledButton.styleFrom(
minimumSize: size,
backgroundColor: Theme.of(context).colorScheme.error,
),
child: child,
),
};
}
}
class _ButtonContent extends StatelessWidget {
const _ButtonContent({
required this.label,
required this.isLoading,
this.icon,
});
final String label;
final Widget? icon;
final bool isLoading;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (icon == null) return Text(label);
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon!,
const SizedBox(width: 8),
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
],
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/app/theme/app_theme.dart';
class AppCard extends StatelessWidget {
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppSpacing.lg),
this.margin,
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = Container(
margin: margin,
padding: padding,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.border),
),
child: child,
);
if (onTap == null) return card;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: card,
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class AppDialog {
AppDialog._();
static Future<bool?> confirm(
BuildContext context, {
String title = '确认操作',
String message = '是否继续?',
String cancelText = '取消',
String confirmText = '确认',
}) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelText),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmText),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
class AppEmptyView extends StatelessWidget {
const AppEmptyView({
super.key,
this.title = '暂无数据',
this.message,
this.icon = Icons.inbox_outlined,
this.action,
});
final String title;
final String? message;
final IconData icon;
final Widget? action;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: colors.outline),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
if (message != null) ...[
const SizedBox(height: 6),
Text(
message!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_button.dart';
class AppErrorView extends StatelessWidget {
const AppErrorView({
super.key,
this.title = '加载失败',
this.message = '请稍后重试',
this.onRetry,
});
final String title;
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 56,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (onRetry != null) ...[
const SizedBox(height: 16),
AppButton(
label: '重试',
icon: const Icon(Icons.refresh, size: 18),
variant: AppButtonVariant.outline,
onPressed: onRetry,
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class AppLoadingView extends StatelessWidget {
const AppLoadingView({super.key, this.message = '加载中', this.size = 24});
final String message;
final double size;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: size,
child: const CircularProgressIndicator(strokeWidth: 2.4),
),
const SizedBox(height: 12),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class AppNetworkImage extends StatelessWidget {
const AppNetworkImage({
super.key,
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
});
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final image = CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).colorScheme.outline,
),
),
);
if (borderRadius == null) return image;
return ClipRRect(borderRadius: borderRadius!, child: image);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class AppRefreshList<T> extends StatefulWidget {
const AppRefreshList({
super.key,
required this.items,
required this.itemBuilder,
this.onRefresh,
this.onLoadMore,
this.enablePullUp = false,
this.separator,
this.empty,
this.padding,
});
final List<T> items;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
final Future<void> Function()? onRefresh;
final Future<void> Function()? onLoadMore;
final bool enablePullUp;
final Widget? separator;
final Widget? empty;
final EdgeInsetsGeometry? padding;
@override
State<AppRefreshList<T>> createState() => _AppRefreshListState<T>();
}
class _AppRefreshListState<T> extends State<AppRefreshList<T>> {
final _controller = RefreshController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _refresh() async {
try {
await widget.onRefresh?.call();
_controller.refreshCompleted();
} catch (_) {
_controller.refreshFailed();
}
}
Future<void> _loadMore() async {
try {
await widget.onLoadMore?.call();
_controller.loadComplete();
} catch (_) {
_controller.loadFailed();
}
}
@override
Widget build(BuildContext context) {
final child = widget.items.isEmpty && widget.empty != null
? widget.empty!
: ListView.separated(
padding: widget.padding,
itemCount: widget.items.length,
separatorBuilder: (_, __) =>
widget.separator ?? const SizedBox(height: 8),
itemBuilder: (context, index) {
return widget.itemBuilder(context, widget.items[index], index);
},
);
return SmartRefresher(
controller: _controller,
enablePullDown: widget.onRefresh != null,
enablePullUp: widget.enablePullUp && widget.onLoadMore != null,
onRefresh: _refresh,
onLoading: _loadMore,
child: child,
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/core/utils/rate_limiter.dart';
class AppSearchBar extends StatefulWidget {
const AppSearchBar({
super.key,
this.hint = '搜索',
this.onChanged,
this.onSubmitted,
this.debounceDuration = const Duration(milliseconds: 300),
});
final String hint;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final Duration debounceDuration;
@override
State<AppSearchBar> createState() => _AppSearchBarState();
}
class _AppSearchBarState extends State<AppSearchBar> {
final _controller = TextEditingController();
final _rateLimit = RateLimitHub();
@override
void dispose() {
_rateLimit.clear();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SearchBar(
controller: _controller,
hintText: widget.hint,
leading: const Icon(Icons.search),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
tooltip: '清除',
onPressed: () {
setState(_controller.clear);
widget.onChanged?.call('');
},
icon: const Icon(Icons.close),
),
],
onChanged: (value) {
setState(() {});
_rateLimit.debounce<String>(
key: 'search',
value: value,
duration: widget.debounceDuration,
onCallback: (text) => widget.onChanged?.call(text),
);
},
onSubmitted: widget.onSubmitted,
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_template/shared/widgets/app_empty_view.dart';
import 'package:flutter_template/shared/widgets/app_error_view.dart';
import 'package:flutter_template/shared/widgets/app_loading_view.dart';
enum AppViewStatus { loading, empty, error, content }
class AppStatusView extends StatelessWidget {
const AppStatusView({
super.key,
required this.status,
required this.child,
this.empty,
this.error,
this.loading,
this.onRetry,
});
final AppViewStatus status;
final Widget child;
final Widget? empty;
final Widget? error;
final Widget? loading;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return switch (status) {
AppViewStatus.loading => loading ?? const AppLoadingView(),
AppViewStatus.empty => empty ?? const AppEmptyView(),
AppViewStatus.error => error ?? AppErrorView(onRetry: onRetry),
AppViewStatus.content => child,
};
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
enum AppTagTone { neutral, success, warning, danger, info }
class AppTag extends StatelessWidget {
const AppTag({
super.key,
required this.label,
this.tone = AppTagTone.neutral,
this.icon,
});
final String label;
final AppTagTone tone;
final IconData? icon;
@override
Widget build(BuildContext context) {
final (foreground, background) = _colors(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: foreground),
const SizedBox(width: 4),
],
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
(Color, Color) _colors(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return switch (tone) {
AppTagTone.neutral => (
colors.onSurfaceVariant,
colors.surfaceContainerHighest,
),
AppTagTone.success => (const Color(0xFF166534), const Color(0xFFDCFCE7)),
AppTagTone.warning => (const Color(0xFF92400E), const Color(0xFFFEF3C7)),
AppTagTone.danger => (colors.error, colors.errorContainer),
AppTagTone.info => (colors.primary, colors.primaryContainer),
};
}
}

View File

@@ -0,0 +1,113 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTextField extends StatefulWidget {
const AppTextField({
super.key,
this.controller,
this.initialValue,
this.label,
this.hint,
this.prefixIcon,
this.suffixIcon,
this.keyboardType,
this.textInputAction,
this.obscureText = false,
this.readOnly = false,
this.maxLines = 1,
this.maxLength,
this.validator,
this.inputFormatters,
this.onChanged,
this.onSubmitted,
this.onFinished,
this.finishLength,
this.debounceDuration = const Duration(milliseconds: 500),
});
final TextEditingController? controller;
final String? initialValue;
final String? label;
final String? hint;
final Widget? prefixIcon;
final Widget? suffixIcon;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final bool obscureText;
final bool readOnly;
final int maxLines;
final int? maxLength;
final FormFieldValidator<String>? validator;
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onFinished;
final int? finishLength;
final Duration debounceDuration;
@override
State<AppTextField> createState() => _AppTextFieldState();
}
class _AppTextFieldState extends State<AppTextField> {
late final TextEditingController _controller;
Timer? _debounce;
@override
void initState() {
super.initState();
_controller =
widget.controller ??
TextEditingController(text: widget.initialValue ?? '');
_controller.addListener(_handleInput);
}
void _handleInput() {
final text = _controller.text;
if (widget.finishLength != null && text.length == widget.finishLength) {
widget.onFinished?.call(text);
return;
}
_debounce?.cancel();
_debounce = Timer(widget.debounceDuration, () {
widget.onFinished?.call(text);
});
}
@override
void dispose() {
_debounce?.cancel();
_controller.removeListener(_handleInput);
if (widget.controller == null) _controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction ?? TextInputAction.done,
obscureText: widget.obscureText,
readOnly: widget.readOnly,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
validator: widget.validator,
inputFormatters: widget.inputFormatters,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmitted,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hint,
prefixIcon: widget.prefixIcon,
suffixIcon: widget.suffixIcon,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
counterText: '',
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_template/app/router/app_navigator.dart';
class AppToast {
AppToast._();
static OverlayEntry? _entry;
static Timer? _timer;
static String messageOf(Object? error) {
if (error == null) return '未知错误';
if (error is FormatException) return error.message;
if (error is TimeoutException) return error.message ?? '请求超时';
var text = error.toString().trim();
const prefix = 'Exception: ';
while (text.startsWith(prefix)) {
text = text.substring(prefix.length).trimLeft();
}
return text.isEmpty ? '未知错误' : text;
}
static void show(
String message, {
Duration duration = const Duration(seconds: 2),
}) {
final context = AppNavigator.context;
if (context == null) return;
_dismiss();
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return;
_entry = OverlayEntry(builder: (_) => _ToastWidget(message: message));
overlay.insert(_entry!);
_timer = Timer(duration, _dismiss);
}
static void showError(Object error) => show(messageOf(error));
static void _dismiss() {
_timer?.cancel();
_timer = null;
try {
_entry?.remove();
} catch (_) {
// OverlayEntry may already be removed.
}
_entry = null;
}
}
class _ToastWidget extends StatelessWidget {
const _ToastWidget({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class SafeAreaWrapper extends StatelessWidget {
const SafeAreaWrapper({
super.key,
required this.child,
this.backgroundColor,
this.top = true,
this.bottom = true,
this.minimum = EdgeInsets.zero,
});
final Widget child;
final Color? backgroundColor;
final bool top;
final bool bottom;
final EdgeInsets minimum;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: backgroundColor ?? Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(top: top, bottom: bottom, minimum: minimum, child: child),
);
}
}

View File

@@ -0,0 +1,15 @@
export 'app_avatar.dart';
export 'app_button.dart';
export 'app_card.dart';
export 'app_dialog.dart';
export 'app_empty_view.dart';
export 'app_error_view.dart';
export 'app_loading_view.dart';
export 'app_network_image.dart';
export 'app_refresh_list.dart';
export 'app_search_bar.dart';
export 'app_status_view.dart';
export 'app_tag.dart';
export 'app_text_field.dart';
export 'app_toast.dart';
export 'safe_area_wrapper.dart';