|
|
|
import 'package:doublefeel_flutter/app/modules/login/views/email_login_reset_password_view.dart';
|
|
|
|
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
|
|
|
|
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:get/get.dart';
|
|
|
|
|
|
|
|
import '../controllers/login_controller.dart';
|
|
|
|
|
|
|
|
class EmailLoginView extends GetView<LoginController> {
|
|
|
|
const EmailLoginView({super.key});
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final l10n = context.l10n;
|
|
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
|
|
value: const SystemUiOverlayStyle(
|
|
|
|
statusBarColor: Colors.transparent,
|
|
|
|
statusBarIconBrightness: Brightness.dark,
|
|
|
|
statusBarBrightness: Brightness.light,
|
|
|
|
systemNavigationBarColor: Colors.transparent,
|
|
|
|
systemNavigationBarIconBrightness: Brightness.dark,
|
|
|
|
),
|
|
|
|
child: Scaffold(
|
|
|
|
resizeToAvoidBottomInset: false,
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
body: Stack(
|
|
|
|
fit: StackFit.expand,
|
|
|
|
children: [
|
|
|
|
SingleChildScrollView(
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
padding: EdgeInsets.only(
|
|
|
|
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
|
|
|
|
),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
children: [
|
|
|
|
Stack(
|
|
|
|
children: [
|
|
|
|
Image.asset(
|
|
|
|
'assets/images/user_onboarding/bg_phone_login_top.png',
|
|
|
|
fit: BoxFit.fitWidth,
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
bottom: 67,
|
|
|
|
left: 32,
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
l10n.phoneLoginHello,
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontSize: 28,
|
|
|
|
fontWeight: FontWeight.w900,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
Text(
|
|
|
|
l10n.phoneLoginWelcome,
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
Transform.translate(
|
|
|
|
offset: const Offset(0, -8),
|
|
|
|
child: Text(
|
|
|
|
'Log In or Sign Up',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF0F0F11),
|
|
|
|
fontSize: 24,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
|
|
|
|
Text(
|
|
|
|
'Enter your email and password',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF78787D),
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
|
|
|
|
// 输入区
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
children: [
|
|
|
|
// email输入框
|
|
|
|
Obx(() {
|
|
|
|
final emailNotEmpty =
|
|
|
|
controller.emailInput.value.isNotEmpty;
|
|
|
|
return TextField(
|
|
|
|
controller: controller.emailController,
|
|
|
|
keyboardType: TextInputType.emailAddress,
|
|
|
|
// maxLength: 13,
|
|
|
|
contextMenuBuilder: (BuildContext context,
|
|
|
|
EditableTextState editableTextState) {
|
|
|
|
// 获取默认的菜单项列表
|
|
|
|
List<ContextMenuButtonItem> buttonItems =
|
|
|
|
editableTextState.contextMenuButtonItems;
|
|
|
|
|
|
|
|
// 例如:只保留“粘贴” (Paste)
|
|
|
|
buttonItems.removeWhere((item) =>
|
|
|
|
item.type != ContextMenuButtonType.paste);
|
|
|
|
return AdaptiveTextSelectionToolbar.buttonItems(
|
|
|
|
anchors: editableTextState.contextMenuAnchors,
|
|
|
|
buttonItems: buttonItems,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
inputFormatters: [
|
|
|
|
FilteringTextInputFormatter.deny(RegExp(r'\s')),
|
|
|
|
FilteringTextInputFormatter.allow(
|
|
|
|
RegExp(r'[a-zA-Z0-9@._\-+]'),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
style: TextStyle(
|
|
|
|
color: context.colors.textPrimary,
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
decoration: InputDecoration(
|
|
|
|
hintText: l10n.emailLoginEmailHint,
|
|
|
|
hintStyle: TextStyle(
|
|
|
|
color: context.colors.textTertiary,
|
|
|
|
fontSize: 16,
|
|
|
|
),
|
|
|
|
counterText: '',
|
|
|
|
filled: true,
|
|
|
|
fillColor: Color(0xFFF3F3F3),
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 20,
|
|
|
|
vertical: 16,
|
|
|
|
),
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide.none,
|
|
|
|
),
|
|
|
|
enabledBorder: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide.none,
|
|
|
|
),
|
|
|
|
focusedBorder: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide(
|
|
|
|
color: context.colors.primary,
|
|
|
|
width: 1,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
suffixIcon: emailNotEmpty
|
|
|
|
? GestureDetector(
|
|
|
|
onTap: controller.emailController.clear,
|
|
|
|
child: Icon(
|
|
|
|
Icons.cancel,
|
|
|
|
color: context.colors.textTertiary,
|
|
|
|
size: 18,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: null,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
|
|
|
|
SizedBox(
|
|
|
|
height: 20,
|
|
|
|
child: Obx(() {
|
|
|
|
final hasTried =
|
|
|
|
controller.hasTriedEmailLogin.value;
|
|
|
|
final emailOk = controller.isEmailValid;
|
|
|
|
final showError = hasTried && !emailOk;
|
|
|
|
return Visibility(
|
|
|
|
visible: showError,
|
|
|
|
maintainSize: true,
|
|
|
|
maintainAnimation: true,
|
|
|
|
maintainState: true,
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.only(left: 8),
|
|
|
|
child: Text(
|
|
|
|
'Invalid email format',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.red,
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
Obx(() {
|
|
|
|
final isObscured =
|
|
|
|
controller.isPasswordObscured.value;
|
|
|
|
return TextField(
|
|
|
|
controller: controller.passwordController,
|
|
|
|
keyboardType: TextInputType.visiblePassword,
|
|
|
|
obscureText: isObscured,
|
|
|
|
// maxLength: 13,
|
|
|
|
contextMenuBuilder: (BuildContext context,
|
|
|
|
EditableTextState editableTextState) {
|
|
|
|
// 获取默认的菜单项列表
|
|
|
|
List<ContextMenuButtonItem> buttonItems =
|
|
|
|
editableTextState.contextMenuButtonItems;
|
|
|
|
|
|
|
|
// 例如:只保留"粘贴" (Paste)
|
|
|
|
buttonItems.removeWhere((item) =>
|
|
|
|
item.type != ContextMenuButtonType.paste);
|
|
|
|
return AdaptiveTextSelectionToolbar.buttonItems(
|
|
|
|
anchors: editableTextState.contextMenuAnchors,
|
|
|
|
buttonItems: buttonItems,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
inputFormatters: [
|
|
|
|
FilteringTextInputFormatter.deny(RegExp(r'\s')),
|
|
|
|
FilteringTextInputFormatter.allow(
|
|
|
|
RegExp(r'[\x21-\x7E]'),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
style: TextStyle(
|
|
|
|
color: context.colors.textPrimary,
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
decoration: InputDecoration(
|
|
|
|
hintText: l10n.emailLoginYourPassword,
|
|
|
|
hintStyle: TextStyle(
|
|
|
|
color: context.colors.textTertiary,
|
|
|
|
fontSize: 16,
|
|
|
|
),
|
|
|
|
counterText: '',
|
|
|
|
filled: true,
|
|
|
|
fillColor: Color(0xFFF3F3F3),
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 20,
|
|
|
|
vertical: 16,
|
|
|
|
),
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide.none,
|
|
|
|
),
|
|
|
|
enabledBorder: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide.none,
|
|
|
|
),
|
|
|
|
focusedBorder: OutlineInputBorder(
|
|
|
|
borderRadius: BorderRadius.circular(27),
|
|
|
|
borderSide: BorderSide(
|
|
|
|
color: context.colors.primary,
|
|
|
|
width: 1,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
suffixIconConstraints: const BoxConstraints(
|
|
|
|
minWidth: 24,
|
|
|
|
minHeight: 24,
|
|
|
|
),
|
|
|
|
suffixIcon: GestureDetector(
|
|
|
|
onTap: controller.togglePasswordVisibility,
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.only(right: 16),
|
|
|
|
child: Image.asset(
|
|
|
|
isObscured
|
|
|
|
? 'assets/images/common/ic_hide_password.png'
|
|
|
|
: 'assets/images/common/ic_show_password.webp',
|
|
|
|
color: context.colors.textTertiary,
|
|
|
|
width: 24,
|
|
|
|
height: 24,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
)));
|
|
|
|
}),
|
|
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
|
|
// 密码格式提示
|
|
|
|
Obx(() {
|
|
|
|
final hasTried = controller.hasTriedEmailLogin.value;
|
|
|
|
final pwOk = controller.isPasswordValid;
|
|
|
|
final isError = hasTried && !pwOk;
|
|
|
|
return Text(
|
|
|
|
'Password must be at least 6 characters and include 1 number and 1 uppercase letter.',
|
|
|
|
style: TextStyle(
|
|
|
|
color: isError
|
|
|
|
? Colors.red
|
|
|
|
: context.colors.textTertiary,
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
Center(
|
|
|
|
child: GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
Get.to(() => const EmailLoginResetPasswordView());
|
|
|
|
},
|
|
|
|
child: Text(
|
|
|
|
'Forgot password?',
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF7B9BFB),
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
decoration: TextDecoration.underline,
|
|
|
|
decorationColor: const Color(0xFF7B9BFB),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 48),
|
|
|
|
|
|
|
|
// Continue 按钮
|
|
|
|
Obx(() {
|
|
|
|
final isLoggingIn = controller.isLoggingIn.value;
|
|
|
|
final canProceed = controller.isPasswordValid &&
|
|
|
|
controller.isEmailValid;
|
|
|
|
return SizedBox(
|
|
|
|
height: 48,
|
|
|
|
child: ElevatedButton(
|
|
|
|
onPressed: (!isLoggingIn && canProceed)
|
|
|
|
? controller.emailLogin
|
|
|
|
: null,
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
backgroundColor: context.colors.primary,
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
disabledBackgroundColor: context.colors.primary
|
|
|
|
.withValues(alpha: 0.4),
|
|
|
|
disabledForegroundColor: Colors.white,
|
|
|
|
elevation: 0,
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.circular(24),
|
|
|
|
),
|
|
|
|
textStyle: const TextStyle(
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: isLoggingIn
|
|
|
|
? Row(
|
|
|
|
mainAxisAlignment:
|
|
|
|
MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
const _SpinningLoader(),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
Text(l10n.phoneLoginLoggingIn),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
: Text(l10n.continueButton),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
top: MediaQuery.paddingOf(context).top,
|
|
|
|
left: 0,
|
|
|
|
child: IconButton(
|
|
|
|
highlightColor: Colors.transparent,
|
|
|
|
splashColor: Colors.transparent,
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
onPressed: Get.back,
|
|
|
|
icon: Image.asset(
|
|
|
|
'assets/images/common/ic_nav_back.webp',
|
|
|
|
width: 24,
|
|
|
|
height: 24,
|
|
|
|
)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ── 旋转 Loading 图标 ────────────────────────────────────────────
|
|
|
|
class _SpinningLoader extends StatefulWidget {
|
|
|
|
const _SpinningLoader();
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_SpinningLoader> createState() => _SpinningLoaderState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _SpinningLoaderState extends State<_SpinningLoader>
|
|
|
|
with SingleTickerProviderStateMixin {
|
|
|
|
late final AnimationController _ctrl;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_ctrl = AnimationController(
|
|
|
|
vsync: this,
|
|
|
|
duration: const Duration(milliseconds: 900),
|
|
|
|
)..repeat();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_ctrl.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return RotationTransition(
|
|
|
|
turns: _ctrl,
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/common/ic_loading_ios_style.png',
|
|
|
|
width: 24,
|
|
|
|
height: 24,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ── 手机号格式化 ───────────────────────────────────────────────────
|
|
|
|
class _PhoneTextInputFormatter extends TextInputFormatter {
|
|
|
|
@override
|
|
|
|
TextEditingValue formatEditUpdate(
|
|
|
|
TextEditingValue oldValue,
|
|
|
|
TextEditingValue newValue,
|
|
|
|
) {
|
|
|
|
final text = newValue.text.replaceAll(' ', '');
|
|
|
|
if (text.isEmpty) {
|
|
|
|
return newValue;
|
|
|
|
}
|
|
|
|
|
|
|
|
final buffer = StringBuffer();
|
|
|
|
for (int i = 0; i < text.length; i++) {
|
|
|
|
buffer.write(text[i]);
|
|
|
|
final nonLeadingIndex = i + 1;
|
|
|
|
if (nonLeadingIndex == 3 || nonLeadingIndex == 7) {
|
|
|
|
if (nonLeadingIndex < text.length) {
|
|
|
|
buffer.write(' ');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
final string = buffer.toString();
|
|
|
|
return newValue.copyWith(
|
|
|
|
text: string,
|
|
|
|
selection: TextSelection.collapsed(offset: string.length),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|