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:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+79
View File
@@ -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),
),
);
}
}
+60
View File
@@ -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: '',
),
);
}
}
+173
View File
@@ -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,
),
),
),
],
),
),
),
],
),
),
),
),
),
),
);
}
}
+53
View File
@@ -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,
),
),
);
}
}
+73
View File
@@ -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),
),
],
);
}
}
+60
View File
@@ -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,
),
);
}
}
+44
View File
@@ -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!],
],
),
),
);
}
}
+47
View File
@@ -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('重试'),
),
],
],
),
),
);
}
}
+46
View File
@@ -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,
),
),
],
),
);
}
}
+57
View File
@@ -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),
],
],
),
),
),
),
),
),
],
);
}
}
+34
View File
@@ -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),
);
}
}
+40
View File
@@ -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!,
],
),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 底部导航 Shell — 2 TabHome / 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
),
],
),
);
}
}
+55
View File
@@ -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,
),
);
}
}
+40
View File
@@ -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,
),
),
);
}
}