Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 带 loading 状态的通用 FilledButton。
|
||||
class AppButton extends StatelessWidget {
|
||||
const AppButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.loading = false,
|
||||
this.width = double.infinity,
|
||||
this.height = 50.0,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: FilledButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带 loading 状态的通用 OutlinedButton。
|
||||
class AppOutlinedButton extends StatelessWidget {
|
||||
const AppOutlinedButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.loading = false,
|
||||
this.width = double.infinity,
|
||||
this.height = 50.0,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: OutlinedButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
child: loading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 带标签、错误提示、前/后缀的通用输入框。
|
||||
class AppTextField extends StatelessWidget {
|
||||
const AppTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.errorText,
|
||||
this.prefixIcon,
|
||||
this.suffix,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final String? errorText;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffix;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
maxLength: maxLength,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: errorText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffix: suffix,
|
||||
counterText: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
enum ToastType { info, success, error, warning }
|
||||
|
||||
abstract final class AppToast {
|
||||
static OverlayEntry? _current;
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
ToastType type = ToastType.info,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
_current?.remove();
|
||||
_current = null;
|
||||
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _ToastBanner(
|
||||
message: message,
|
||||
type: type,
|
||||
duration: duration,
|
||||
onDismiss: () {
|
||||
entry.remove();
|
||||
if (_current == entry) _current = null;
|
||||
},
|
||||
),
|
||||
);
|
||||
_current = entry;
|
||||
overlay.insert(entry);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastBanner extends StatefulWidget {
|
||||
const _ToastBanner({
|
||||
required this.message,
|
||||
required this.type,
|
||||
required this.duration,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final ToastType type;
|
||||
final Duration duration;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<_ToastBanner> createState() => _ToastBannerState();
|
||||
}
|
||||
|
||||
class _ToastBannerState extends State<_ToastBanner>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<Offset> _slide;
|
||||
late final Animation<double> _fade;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
);
|
||||
_slide = Tween<Offset>(
|
||||
begin: const Offset(0, -1.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
_fade = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
|
||||
|
||||
unawaited(_ctrl.forward());
|
||||
_timer = Timer(widget.duration, _dismiss);
|
||||
}
|
||||
|
||||
void _dismiss() {
|
||||
_timer?.cancel();
|
||||
unawaited(_ctrl.reverse().then((_) => widget.onDismiss()));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (accentColor, icon) = switch (widget.type) {
|
||||
ToastType.success =>
|
||||
(const Color(0xFF34C759), Icons.check_circle_outline_rounded),
|
||||
ToastType.error =>
|
||||
(const Color(0xFFFF3B30), Icons.error_outline_rounded),
|
||||
ToastType.warning =>
|
||||
(const Color(0xFFFF9F0A), Icons.warning_amber_rounded),
|
||||
ToastType.info =>
|
||||
(const Color(0xFF6B63D9), Icons.info_outline_rounded),
|
||||
};
|
||||
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Positioned(
|
||||
top: topPadding + 10.h,
|
||||
left: 16.w,
|
||||
right: 16.w,
|
||||
child: SlideTransition(
|
||||
position: _slide,
|
||||
child: FadeTransition(
|
||||
opacity: _fade,
|
||||
child: Material(
|
||||
elevation: 12,
|
||||
shadowColor: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
color: Colors.white,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(width: 4.w, color: accentColor),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 13.h,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: accentColor, size: 20.sp),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.message,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF1C1C1E),
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
GestureDetector(
|
||||
onTap: _dismiss,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4.w),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
color: const Color(0xFFAEAEB2),
|
||||
size: 16.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 用户头像,支持网络图片 + 首字母 fallback。
|
||||
class AvatarWidget extends StatelessWidget {
|
||||
const AvatarWidget({
|
||||
super.key,
|
||||
this.url,
|
||||
this.name,
|
||||
this.size = 40,
|
||||
});
|
||||
|
||||
final String? url;
|
||||
final String? name;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final radius = size / 2;
|
||||
|
||||
if (url != null && url!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: url!,
|
||||
imageBuilder: (_, img) => CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: img,
|
||||
),
|
||||
placeholder: (_, __) => _placeholder(context),
|
||||
errorWidget: (_, __, ___) => _placeholder(context),
|
||||
);
|
||||
}
|
||||
return _placeholder(context);
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final initial =
|
||||
(name?.isNotEmpty == true) ? name![0].toUpperCase() : '?';
|
||||
return CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.4,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用确认对话框。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// final ok = await ConfirmDialog.show(
|
||||
/// context,
|
||||
/// title: '删除确认',
|
||||
/// message: '确定要删除该记录吗?',
|
||||
/// confirmLabel: '删除',
|
||||
/// destructive: true,
|
||||
/// );
|
||||
/// if (ok == true) { /* do it */ }
|
||||
/// ```
|
||||
class ConfirmDialog extends StatelessWidget {
|
||||
const ConfirmDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.confirmLabel = '确定',
|
||||
this.cancelLabel = '取消',
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmLabel;
|
||||
final String cancelLabel;
|
||||
final bool destructive;
|
||||
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = '确定',
|
||||
String cancelLabel = '取消',
|
||||
bool destructive = false,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
destructive: destructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
style: destructive
|
||||
? TextButton.styleFrom(foregroundColor: colorScheme.error)
|
||||
: null,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 带倒计时的发送按钮(常用于短信验证码)。
|
||||
///
|
||||
/// 倒计时结束后自动恢复可点击状态。
|
||||
class CountDownButton extends StatefulWidget {
|
||||
const CountDownButton({
|
||||
super.key,
|
||||
required this.onSend,
|
||||
this.label = '发送验证码',
|
||||
this.countingLabel = '重新发送',
|
||||
this.seconds = 60,
|
||||
});
|
||||
|
||||
final VoidCallback onSend;
|
||||
final String label;
|
||||
final String countingLabel;
|
||||
final int seconds;
|
||||
|
||||
@override
|
||||
State<CountDownButton> createState() => _CountDownButtonState();
|
||||
}
|
||||
|
||||
class _CountDownButtonState extends State<CountDownButton> {
|
||||
int _remaining = 0;
|
||||
Timer? _timer;
|
||||
|
||||
bool get _isCounting => _remaining > 0;
|
||||
|
||||
void _start() {
|
||||
widget.onSend();
|
||||
setState(() => _remaining = widget.seconds);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_remaining <= 1) {
|
||||
t.cancel();
|
||||
if (mounted) setState(() => _remaining = 0);
|
||||
} else {
|
||||
if (mounted) setState(() => _remaining--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: _isCounting ? null : _start,
|
||||
child: Text(
|
||||
_isCounting ? '${widget.countingLabel}(${_remaining}s)' : widget.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 空状态占位视图。
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
this.message = '暂无数据',
|
||||
this.icon,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final Widget? icon;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon ??
|
||||
Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[const SizedBox(height: 24), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 错误状态视图,提供重试按钮。
|
||||
class ErrorView extends StatelessWidget {
|
||||
const ErrorView({
|
||||
super.key,
|
||||
this.message = '加载失败,请重试',
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 标签 + 值 的水平信息行,常用于详情页和调试面板。
|
||||
class InfoRow extends StatelessWidget {
|
||||
const InfoRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.valueStyle,
|
||||
this.crossAxisAlignment = CrossAxisAlignment.center,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final TextStyle? valueStyle;
|
||||
final CrossAxisAlignment crossAxisAlignment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: valueStyle ?? theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全屏/局部 loading 遮罩。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// LoadingOverlay(
|
||||
/// loading: _isLoading,
|
||||
/// child: MyWidget(),
|
||||
/// )
|
||||
/// ```
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
const LoadingOverlay({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.loading = false,
|
||||
this.label,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool loading;
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (loading)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black26,
|
||||
child: Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32, vertical: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(label!,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 带圆角边框的内容分组卡片。
|
||||
class SectionCard extends StatelessWidget {
|
||||
const SectionCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 带左侧装饰条的区块标题。
|
||||
class SectionTitle extends StatelessWidget {
|
||||
const SectionTitle(this.title, {super.key, this.trailing});
|
||||
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 底部导航 Shell — 2 Tab(Home / Mine)。
|
||||
/// Tab 标签文字在 Task 5 替换为 i18n 字符串。
|
||||
class ShellScaffold extends StatelessWidget {
|
||||
const ShellScaffold({super.key, required this.shell});
|
||||
|
||||
final StatefulNavigationShell shell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: shell,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: shell.currentIndex,
|
||||
onDestinationSelected: (index) => shell.goBranch(
|
||||
index,
|
||||
initialLocation: index == shell.currentIndex,
|
||||
),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home', // TODO: i18n → t.tab.home
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Mine', // TODO: i18n → t.tab.mine
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 骨架屏占位矩形。
|
||||
///
|
||||
/// 用法(配合 shimmer 包):
|
||||
/// ```dart
|
||||
/// Shimmer.fromColors(
|
||||
/// baseColor: Colors.grey[300]!,
|
||||
/// highlightColor: Colors.grey[100]!,
|
||||
/// child: SkeletonBox(width: double.infinity, height: 20),
|
||||
/// )
|
||||
/// ```
|
||||
class SkeletonBox extends StatelessWidget {
|
||||
const SkeletonBox({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 14,
|
||||
this.borderRadius = 6,
|
||||
});
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final double borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 骨架屏圆形(头像占位)。
|
||||
class SkeletonCircle extends StatelessWidget {
|
||||
const SkeletonCircle({super.key, this.size = 40});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 彩色标签 Chip,常用于状态、分类标注。
|
||||
class TagChip extends StatelessWidget {
|
||||
const TagChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color,
|
||||
this.textColor,
|
||||
this.fontSize = 12,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color? color;
|
||||
final Color? textColor;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final bg = color ?? primary.withValues(alpha: 0.12);
|
||||
final fg = textColor ?? primary;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user