init
This commit is contained in:
37
lib/shared/widgets/app_avatar.dart
Normal file
37
lib/shared/widgets/app_avatar.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/shared/widgets/app_button.dart
Normal file
100
lib/shared/widgets/app_button.dart
Normal 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/shared/widgets/app_card.dart
Normal file
39
lib/shared/widgets/app_card.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/shared/widgets/app_dialog.dart
Normal file
33
lib/shared/widgets/app_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/shared/widgets/app_empty_view.dart
Normal file
45
lib/shared/widgets/app_empty_view.dart
Normal 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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/shared/widgets/app_error_view.dart
Normal file
53
lib/shared/widgets/app_error_view.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/shared/widgets/app_loading_view.dart
Normal file
30
lib/shared/widgets/app_loading_view.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/shared/widgets/app_network_image.dart
Normal file
48
lib/shared/widgets/app_network_image.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
80
lib/shared/widgets/app_refresh_list.dart
Normal file
80
lib/shared/widgets/app_refresh_list.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/shared/widgets/app_search_bar.dart
Normal file
62
lib/shared/widgets/app_search_bar.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/shared/widgets/app_status_view.dart
Normal file
35
lib/shared/widgets/app_status_view.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
lib/shared/widgets/app_tag.dart
Normal file
58
lib/shared/widgets/app_tag.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
113
lib/shared/widgets/app_text_field.dart
Normal file
113
lib/shared/widgets/app_text_field.dart
Normal 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: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/shared/widgets/app_toast.dart
Normal file
87
lib/shared/widgets/app_toast.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/shared/widgets/safe_area_wrapper.dart
Normal file
26
lib/shared/widgets/safe_area_wrapper.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/shared/widgets/widgets.dart
Normal file
15
lib/shared/widgets/widgets.dart
Normal 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';
|
||||
Reference in New Issue
Block a user