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,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: 替换为项目品牌 UI(logo / 背景 / 色彩)。
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user