Template
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
189 lines
5.9 KiB
Dart
189 lines
5.9 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|