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
@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/utils/validator.dart';
import 'package:sunny_mochi/core/widgets/app_button.dart';
import 'package:sunny_mochi/core/widgets/app_text_field.dart';
import 'package:sunny_mochi/core/widgets/app_toast.dart';
import 'package:sunny_mochi/core/widgets/count_down_button.dart';
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_notifier.dart';
/// 通用登录页骨架 — 手机号 + 双模式(短信验证码 / 密码)。
///
/// TODO: 替换为项目品牌 UIlogo / 背景 / 色彩)。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
final _phoneCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _obscurePassword = true;
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabCtrl.dispose();
_phoneCtrl.dispose();
_codeCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
bool get _isSms => _tabCtrl.index == 0;
Future<void> _onSendCode() async {
final phone = _phoneCtrl.text.trim();
if (!Validator.isValidPhone(phone)) {
if (mounted) {
AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning);
}
return;
}
try {
final echoCode = await ref
.read(authProvider.notifier)
.sendSmsCode(phone);
if (echoCode != null && mounted) {
_codeCtrl.text = echoCode; // dev/test 环境自动填入
}
} on Object catch (e) {
if (mounted) {
AppToast.show(context, e.toString(), type: ToastType.error);
}
}
}
Future<void> _onLogin() async {
final phone = _phoneCtrl.text.trim();
if (!Validator.isValidPhone(phone)) {
AppToast.show(context, '请输入有效的手机号码', type: ToastType.warning);
return;
}
if (_isSms) {
final code = _codeCtrl.text.trim();
if (code.isEmpty) {
AppToast.show(context, '请输入验证码', type: ToastType.warning);
return;
}
await ref.read(authProvider.notifier).loginWithPhone(phone, code);
} else {
final pwd = _passwordCtrl.text;
if (pwd.isEmpty) {
AppToast.show(context, '请输入密码', type: ToastType.warning);
return;
}
await ref
.read(authProvider.notifier)
.loginWithPassword(phone, pwd);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AuthLoading;
ref.listen(authProvider, (_, next) {
if (next is AuthError && mounted) {
AppToast.show(context, next.message, type: ToastType.error);
}
});
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 48),
// TODO: 替换为项目 Logo
const FlutterLogo(size: 64),
const SizedBox(height: 32),
Text(
'欢迎登录',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
// 手机号
AppTextField(
controller: _phoneCtrl,
label: '手机号',
hint: '请输入手机号',
keyboardType: TextInputType.phone,
maxLength: 11,
prefixIcon: const Icon(Icons.phone_outlined),
),
const SizedBox(height: 16),
// 模式切换 Tab
TabBar(
controller: _tabCtrl,
onTap: (_) => setState(() {}),
tabs: const [
Tab(text: '短信验证码'),
Tab(text: '密码登录'),
],
),
const SizedBox(height: 16),
// 验证码 / 密码输入
if (_isSms)
Row(
children: [
Expanded(
child: AppTextField(
controller: _codeCtrl,
label: '验证码',
hint: '请输入验证码',
keyboardType: TextInputType.number,
maxLength: 6,
),
),
const SizedBox(width: 12),
CountDownButton(onSend: _onSendCode),
],
)
else
AppTextField(
controller: _passwordCtrl,
label: '密码',
hint: '请输入密码',
obscureText: _obscurePassword,
suffix: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
const SizedBox(height: 32),
AppButton(
label: '登录',
onPressed: _onLogin,
loading: isLoading,
),
],
),
),
),
);
}
}