Commit 035f1ece4a229307f2c1b48dc10e164ef9560b53

Authored by 常守达
1 parent 6ddd21eb

feat(l10n): 图片资源

Showing 43 changed files with 1791 additions and 105 deletions
No preview for this file type

10.1 KB | W: | H:

8.64 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

5.13 KB | W: | H:

5.09 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

9.28 KB | W: | H:

9.19 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

10.9 KB | W: | H:

2.4 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

9.96 KB | W: | H:

11.6 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

18 KB | W: | H:

19.1 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

5.66 KB | W: | H:

5.57 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

48.3 KB | W: | H:

46.3 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin

43 KB | W: | H:

40 KB | W: | H:

  • 2-up
  • Swipe
  • Onion skin
... ... @@ -5,6 +5,7 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.da
import 'package:doublefeel_flutter/app/modules/home/widgets/my/account_setting_view.dart';
import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
... ... @@ -444,7 +445,7 @@ class _UnlockPremiumCard extends StatelessWidget {
),
),
Image.asset(
'assets/images/my/bg_my_unlock_vip.png',
assetCompatPath('assets/images/my/bg_my_unlock_vip.png'),
width: 149.w,
height: 128.w,
)
... ...
import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -164,7 +165,7 @@ class _HrvInstructionCard extends StatelessWidget {
),
),
Image.asset(
'assets/images/today/hrv_measure_watch.png',
assetCompatPath('assets/images/today/hrv_measure_watch.png'),
width: 110,
height: 172,
fit: BoxFit.contain,
... ...
import 'package:doublefeel_flutter/app/modules/user_onboarding/views/user_onboarding_pages.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -273,7 +274,7 @@ class _AuthorizationCard extends StatelessWidget {
),
SizedBox(height: 37),
Image.asset(
'assets/images/permission/apple_privacy_cn.png',
assetCompatPath('assets/images/permission/apple_privacy_cn.png'),
height: 154,
width: 303,
),
... ... @@ -281,19 +282,22 @@ class _AuthorizationCard extends StatelessWidget {
height: 10,
),
Image.asset(
'assets/images/permission/apple_health_item_cn.png',
assetCompatPath(
'assets/images/permission/apple_health_item_cn.png'),
height: 172,
width: 303,
),
SizedBox(height: 20),
Image.asset(
'assets/images/permission/apple_double_feel_cn.png',
assetCompatPath(
'assets/images/permission/apple_double_feel_cn.png'),
height: 210,
width: 303,
),
SizedBox(height: 20),
Image.asset(
'assets/images/permission/apple_health_step_cn.png',
assetCompatPath(
'assets/images/permission/apple_health_step_cn.png'),
height: 167,
width: 303,
),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -46,7 +47,8 @@ class TodayHrvAdBanner extends StatelessWidget {
color: context.colors.primary,
),
),
leftImagePath: 'assets/images/today/ic_banner_watch_hrv_theme.png',
leftImagePath: assetCompatPath(
'assets/images/today/ic_banner_watch_hrv_theme.png'),
onClose: controller.dismissHrvAdBanner,
showMargin: showMargin,
),
... ...
import 'dart:async';
import 'dart:convert';
import 'package:doublefeel_flutter/app/modules/login/views/email_login_new_password_view.dart';
import 'package:doublefeel_flutter/app/modules/login/views/email_login_view.dart';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:doublefeel_flutter/core/constants/app_const.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
... ... @@ -33,12 +35,18 @@ class LoginController extends GetxController {
final environmentConfig = Get.find<AppEnvironmentConfig>();
final phoneController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final resetPasswordCodeController = TextEditingController();
final codeController = TextEditingController();
final codeFocusNode = FocusNode();
final phoneInput = ''.obs;
final codeInput = ''.obs;
final emailInput = ''.obs;
final passwordInput = ''.obs;
final isSendingCode = false.obs;
final countdownSeconds = 0.obs;
Timer? _countdownTimer;
... ... @@ -47,6 +55,34 @@ class LoginController extends GetxController {
final hasSentCode = false.obs;
final termsChecked = false.obs;
final isPasswordObscured = true.obs;
/// 是否已经点击过「Continue」(用于控制提示文字是否变红)
final hasTriedEmailLogin = false.obs;
final resetPasswordCode = ''.obs;
void togglePasswordVisibility() {
isPasswordObscured.value = !isPasswordObscured.value;
}
static final _emailRegex = RegExp(
r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$',
);
static final _passwordRegex = RegExp(
r'^(?=.*[0-9])(?=.*[A-Z]).{6,}$',
);
bool get isEmailValid =>
_emailRegex.hasMatch(emailInput.value.trim()) &&
emailInput.value.isNotEmpty;
bool get isPasswordValid =>
_passwordRegex.hasMatch(passwordInput.value) &&
passwordInput.value.isNotEmpty;
final resetPasswordError = false.obs;
/// 邮箱格式正确且密码符合要求时,按钮可点击
bool get canEmailLogin => isEmailValid && isPasswordValid;
AppleSignInModel? appleSignInModel;
... ... @@ -90,7 +126,8 @@ class LoginController extends GetxController {
}
void onPhoneLoginPressed() {
if (!termsChecked.value) {
if (!termsChecked.value &&
environmentConfig.region.value == AppRegion.china) {
AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
return;
}
... ... @@ -98,8 +135,14 @@ class LoginController extends GetxController {
Get.toNamed(AppRoutes.phoneLogin);
}
void onEmailLoginPressed() {
ta.track('enter_doublefeel_email_login_page');
Get.to(() => const EmailLoginView());
}
void onAppleLoginPressed() async {
if (!termsChecked.value) {
if (!termsChecked.value &&
environmentConfig.region.value == AppRegion.china) {
AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
return;
}
... ... @@ -122,6 +165,26 @@ class LoginController extends GetxController {
}
}
void onGoogleLoginPressed() async {
try {
final result = await LoadingService.instance.run(() async {
// return await PlatformHostApi().requestGoogleSignIn();
}, type: LoadingType.circular);
// result 为 null 或 identityToken 为空,说明用户取消或苹果授权失败
if (result == null ||
result.identityToken?.isNotEmpty != true ||
result.userId.isNotEmpty != true) {
appleSignInModel = null;
return;
}
appleSignInModel = result;
await _loginWithApple(result);
} catch (e) {
appleSignInModel = null;
AppToast.show(e.toString());
}
}
void onDebugPressed() {
Get.toNamed(AppRoutes.debugEnvironment);
}
... ... @@ -151,6 +214,13 @@ class LoginController extends GetxController {
codeController.addListener(() {
codeInput.value = codeController.text;
});
emailController.addListener(() {
emailInput.value = emailController.text;
});
passwordController.addListener(() {
passwordInput.value = passwordController.text;
});
}
@override
... ... @@ -252,6 +322,95 @@ class LoginController extends GetxController {
}
}
/// 邮箱登录 Continue 按钮点击
Future<void> emailLogin() async {
hasTriedEmailLogin.value = true;
if (!isEmailValid) {
AppToast.show('Invalid email format');
return;
}
if (!isPasswordValid) {
return;
}
// TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
// isLoggingIn.value = true;
// try {
// await LoadingService.instance.run(() async {
// final loginRes = await _userApi.loginWithEmail(
// email: emailInput.value.trim(),
// password: passwordInput.value,
// );
// if (loginRes is AppSuccess<LoginResponse>) {
// await _handleLoginSuccess(loginRes.data);
// await Get.find<LocalStorage>().setLastLoginMethod('email');
// }
// });
// } finally {
// isLoggingIn.value = false;
// }
}
Future<void> sendRestEmail() async {
if (!isEmailValid) {
AppToast.show('Invalid email format');
return;
}
// TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
// isLoggingIn.value = true;
// try {
// await LoadingService.instance.run(() async {
// final loginRes = await _userApi.loginWithEmail(
// email: emailInput.value.trim(),
// password: passwordInput.value,
// );
// if (loginRes is AppSuccess<LoginResponse>) {
// await _handleLoginSuccess(loginRes.data);
// await Get.find<LocalStorage>().setLastLoginMethod('email');
// }
// });
// } finally {
// isLoggingIn.value = false;
// }
Get.to(EmailLoginNewPasswordView());
}
Future<void> resetPassword() async {
if (!isEmailValid) {
AppToast.show('Invalid email format');
return;
}
if (!isPasswordValid) {
AppToast.show('Invalid password format');
return;
}
if (resetPasswordCodeController.text.isEmpty) {
AppToast.show('Invalid code');
return;
}
// TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
// isLoggingIn.value = true;
// try {
// await LoadingService.instance.run(() async {
// final loginRes = await _userApi.loginWithEmail(
// email: emailInput.value.trim(),
// password: passwordInput.value,
// );
// if (loginRes is AppSuccess<LoginResponse>) {
// await _handleLoginSuccess(loginRes.data);
// await Get.find<LocalStorage>().setLastLoginMethod('email');
// }
// });
// } finally {
// isLoggingIn.value = false;
// }
Get.back();
Get.back();
}
Future<void> _loginWithApple(AppleSignInModel appleModel) async {
isLoggingIn.value = true;
try {
... ...
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
void showEmailLoginContactUsBottomSheet({
required String userId,
VoidCallback? onSendEmail,
}) {
Get.bottomSheet(
EmailLoginContactUsBottomSheet(
userId: userId,
onSendEmail: onSendEmail,
),
barrierColor: Colors.black.withValues(alpha: 0.7),
enableDrag: true,
isScrollControlled: true,
persistent: false,
);
}
class EmailLoginContactUsBottomSheet extends StatelessWidget {
const EmailLoginContactUsBottomSheet({
super.key,
required this.userId,
this.onSendEmail,
});
final String userId;
final VoidCallback? onSendEmail;
@override
Widget build(BuildContext context) {
return Container(
height: 346,
decoration: const BoxDecoration(
color: Color(0xFFF9F9FB),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Column(
children: [
const SizedBox(height: 8),
Container(
width: 50,
height: 3,
decoration: BoxDecoration(
color: const Color(0xFFD3D3D8),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 17),
const Text(
'Contact us',
style: TextStyle(
color: Color(0xFF141414),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 22),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Please describe the problem clearly and include screen '
'recordings if possible.',
style: TextStyle(
color: Color(0xFF141414),
fontSize: 15,
fontWeight: FontWeight.w400,
),
),
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Send us your User ID as it will help us identify\n'
'the problem faster.',
style: TextStyle(
color: Color(0xFF141414),
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
),
),
const SizedBox(height: 16),
_UserIdField(userId: userId),
const SizedBox(height: 29),
_SendEmailButton(
onTap: () {
Get.back();
onSendEmail?.call();
},
),
const Spacer(),
],
),
),
);
}
}
class _UserIdField extends StatelessWidget {
const _UserIdField({required this.userId});
final String userId;
@override
Widget build(BuildContext context) {
return Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE7E7E8)),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Text(
userId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF141414),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
Material(
color: const Color(0xFF7B9BFB),
child: InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: userId));
AppToast.show('Copied');
},
child: SizedBox(
width: 48,
height: 48,
child: Center(
child: Image.asset(
'assets/images/common/ic_contact_us_copy.png',
width: 24,
height: 24,
),
),
),
),
),
],
),
),
);
}
}
class _SendEmailButton extends StatelessWidget {
const _SendEmailButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: const SizedBox(
width: 220,
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image(
image: AssetImage(
'assets/images/common/ic_contact_us_lightning.png',
),
width: 24,
height: 24,
),
SizedBox(width: 6),
Text(
'Send Email',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
... ...
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 '../../../routes/app_pages.dart';
import '../controllers/login_controller.dart';
import 'email_login_contact_us_bottom_sheet.dart';
class EmailLoginNewPasswordView extends GetView<LoginController> {
const EmailLoginNewPasswordView({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(
'Check your email',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'We’ve sent a code to ',
style: TextStyle(
color: const Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
TextSpan(
text: controller.emailInput.value,
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
TextSpan(
text:
'. Didn’t get it? Check your spam folder or try again.',
style: TextStyle(
color: const Color(0xFF78787D),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
// 输入区
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 15),
child: Text(
'Code from email',
style: TextStyle(
color: const Color(0xFF141414),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
Obx(() {
controller.resetPasswordCode.value;
return TextField(
controller: controller.resetPasswordCodeController,
keyboardType: TextInputType.text,
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: 'Code',
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,
),
),
),
);
}),
SizedBox(
height: 20,
),
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 15),
child: Text(
'New password',
style: TextStyle(
color: const Color(0xFF141414),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
Obx(() {
final isObscured =
controller.isPasswordObscured.value;
return TextField(
controller: controller.passwordController,
keyboardType: TextInputType.visiblePassword,
obscureText: isObscured,
obscuringCharacter: '*',
// 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 &&
controller.passwordInput.value.isNotEmpty;
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: 39),
Obx(() {
final canProceed = controller
.resetPasswordCodeController
.text
.isNotEmpty &&
controller.isPasswordValid;
return SizedBox(
height: 48,
child: ElevatedButton(
onPressed:
canProceed ? controller.resetPassword : 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: Text('Reset password'),
),
);
}),
const SizedBox(height: 12),
Center(
child: GestureDetector(
onTap: () => showEmailLoginContactUsBottomSheet(
userId: controller.emailInput.value.trim(),
onSendEmail: () =>
Get.toNamed(Routes.SUBMIT_FEEDBACK),
),
child: Text(
'Having trouble? Contact us',
style: TextStyle(
color: const Color(0xFF39CAF4),
fontSize: 14,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: const Color(0xFF39CAF4),
),
),
),
),
// Continue 按钮
],
),
),
],
),
),
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,
)),
),
],
),
),
);
}
}
... ...
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 EmailLoginResetPasswordView extends GetView<LoginController> {
const EmailLoginResetPasswordView({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(
'Reset password',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF0F0F11),
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
),
Text(
'''You'll receive a code via email to reset your 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 emailOk = controller.isEmailValid;
// final showError = !emailOk &&
// controller.emailInput.value.isNotEmpty;
return Visibility(
visible: controller.resetPasswordError.value,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'This email is not registered. Please check and try again.',
style: TextStyle(
color: Colors.red,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
);
}),
),
const SizedBox(height: 48),
// Continue 按钮
Obx(() {
final canProceed = controller.isEmailValid;
return SizedBox(
height: 48,
child: ElevatedButton(
onPressed:
canProceed ? controller.sendRestEmail : 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: Text('Send reset email'),
),
);
}),
],
),
),
],
),
),
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,
)),
),
],
),
),
);
}
}
... ...
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),
);
}
}
... ...
... ... @@ -83,97 +83,142 @@ class LoginView extends GetView<LoginController> {
height: 42,
),
SizedBox(height: 8),
Text(
l10n.loginSlogan,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.50,
letterSpacing: 6,
Padding(
padding: EdgeInsets.symmetric(
horizontal:
controller.environmentConfig.region.value ==
AppRegion.china
? 0
: 40),
child: Text(
l10n.loginSlogan,
textAlign: TextAlign.center,
style: TextStyle(
color: context.colors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.50,
letterSpacing:
controller.environmentConfig.region.value ==
AppRegion.china
? 6
: 1,
),
),
),
],
),
),
// Phone login button
Positioned(
bottom: bottom(145),
left: 0,
right: 0,
child: Center(
child: _LoginButton(
width: buttonWidth,
label: l10n.loginWithPhone,
onPressed: controller.onPhoneLoginPressed,
),
),
),
// Apple login button
// Login buttons – add new methods here as Column children
Positioned(
bottom: bottom(85),
left: 0,
right: 0,
child: Center(
child: _LoginButton(
width: buttonWidth,
label: l10n.loginWithApple,
onPressed: controller.onAppleLoginPressed,
icon: Image.asset(
'assets/images/common/ic_apple.png',
width: 24,
height: 24,
color: context.colors.textPrimary,
),
buttonStyle: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: context.colors.textPrimary,
disabledBackgroundColor:
context.colors.textPrimary.withValues(alpha: 0.5),
disabledForegroundColor: context.colors.textPrimary,
elevation: 0,
shadowColor: Colors.transparent,
padding: EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (controller.environmentConfig.region.value ==
AppRegion.china) ...[
_LoginMethodButton(
width: buttonWidth,
label: l10n.loginWithPhone,
onPressed: controller.onPhoneLoginPressed,
isLastUsed:
Get.find<LocalStorage>().lastLoginMethod ==
'phone',
lastUsedLabel: l10n.loginLastUsed,
),
const SizedBox(height: 12)
],
if (controller.environmentConfig.region.value ==
AppRegion.global) ...[
_LoginMethodButton(
width: buttonWidth,
label: l10n.continueWithEmail,
onPressed: controller.onEmailLoginPressed,
isLastUsed:
Get.find<LocalStorage>().lastLoginMethod ==
'email',
lastUsedLabel: l10n.loginLastUsed,
),
const SizedBox(height: 12)
],
if (controller.environmentConfig.region.value ==
AppRegion.global) ...[
_LoginMethodButton(
width: buttonWidth,
label: l10n.continueWithGoogle,
onPressed: controller.onGoogleLoginPressed,
isLastUsed:
Get.find<LocalStorage>().lastLoginMethod ==
'google',
lastUsedLabel: l10n.loginLastUsed,
icon: Image.asset(
'assets/images/common/ic_google.png',
width: 20,
height: 20,
),
buttonStyle: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: context.colors.textPrimary,
disabledBackgroundColor: context
.colors.textPrimary
.withValues(alpha: 0.5),
disabledForegroundColor:
context.colors.textPrimary,
elevation: 0,
shadowColor: Colors.transparent,
padding:
const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 12)
],
_LoginMethodButton(
width: buttonWidth,
label: l10n.loginWithApple,
onPressed: controller.onAppleLoginPressed,
isLastUsed:
Get.find<LocalStorage>().lastLoginMethod ==
'apple',
lastUsedLabel: l10n.loginLastUsed,
icon: Image.asset(
'assets/images/common/ic_apple.png',
width: 24,
height: 24,
color: context.colors.textPrimary,
),
buttonStyle: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: context.colors.textPrimary,
disabledBackgroundColor: context.colors.textPrimary
.withValues(alpha: 0.5),
disabledForegroundColor: context.colors.textPrimary,
elevation: 0,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
if (Get.find<LocalStorage>().lastLoginMethod.isNotEmpty)
Positioned(
right: rightOffset - 4,
bottom: Get.find<LocalStorage>().lastLoginMethod == 'apple'
? bottom(117)
: bottom(188),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: ShapeDecoration(
color: const Color(0xFFFF9A6E),
shape: RoundedRectangleBorder(
side: BorderSide(width: 1, color: Colors.white),
borderRadius: BorderRadius.circular(25),
),
),
child: Text(
l10n.loginLastUsed,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
// Terms agreement text + checkbox
Positioned(
bottom: bottom(0),
... ... @@ -215,6 +260,67 @@ class LoginView extends GetView<LoginController> {
}
}
/// A login button that optionally shows a "last used" badge at its top-right.
class _LoginMethodButton extends StatelessWidget {
const _LoginMethodButton({
required this.width,
required this.label,
required this.onPressed,
required this.isLastUsed,
required this.lastUsedLabel,
this.icon,
this.buttonStyle,
});
final double width;
final String label;
final VoidCallback onPressed;
final bool isLastUsed;
final String lastUsedLabel;
final Image? icon;
final ButtonStyle? buttonStyle;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
_LoginButton(
width: width,
label: label,
onPressed: onPressed,
icon: icon,
buttonStyle: buttonStyle,
),
if (isLastUsed)
Positioned(
right: -4,
top: -8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: ShapeDecoration(
color: const Color(0xFFFF9A6E),
shape: RoundedRectangleBorder(
side: const BorderSide(width: 1, color: Colors.white),
borderRadius: BorderRadius.circular(25),
),
),
child: Text(
lastUsedLabel,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
}
class _LoginButton extends StatelessWidget {
const _LoginButton({
required this.width,
... ... @@ -311,21 +417,22 @@ class _AgreementText extends GetView<LoginController> {
TextSpan(
style: baseStyle,
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Obx(() {
final isChecked = controller.termsChecked.value;
return Icon(
isChecked
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: isChecked
? context.colors.primary
: context.colors.textSecondary,
size: 18,
);
}),
),
if (controller.environmentConfig.region.value == AppRegion.china)
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Obx(() {
final isChecked = controller.termsChecked.value;
return Icon(
isChecked
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: isChecked
? context.colors.primary
: context.colors.textSecondary,
size: 18,
);
}),
),
const WidgetSpan(
child: SizedBox(width: 8),
),
... ...
import 'dart:async';
import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -138,7 +139,8 @@ class _IntroPage extends StatelessWidget {
OnboardingTitleText(title),
const SizedBox(height: 12),
Image.asset(
'assets/images/user_onboarding/bg_onboarding_watch.png',
assetCompatPath(
'assets/images/user_onboarding/bg_onboarding_watch.png'),
height: Get.width / 375 * 384,
width: Get.width,
fit: BoxFit.cover,
... ... @@ -641,7 +643,8 @@ class _NotificationPermissionPage extends StatelessWidget {
),
const SizedBox(height: 20),
Image.asset(
'assets/images/user_onboarding/bg_onboarding_notification.png',
assetCompatPath(
'assets/images/user_onboarding/bg_onboarding_notification.png'),
height: 383,
fit: BoxFit.fitHeight,
),
... ...
import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart';
import 'package:doublefeel_flutter/app/modules/login/views/email_login_view.dart';
import 'package:doublefeel_flutter/app/modules/membership_offer/bindings/membership_detail_binding.dart';
import 'package:doublefeel_flutter/app/modules/membership_offer/views/membership_detail_view.dart';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
... ... @@ -88,7 +89,6 @@ abstract final class AppPages {
GetPage(
name: AppRoutes.phoneLogin,
page: () => const PhoneLoginView(),
binding: LoginBinding(),
),
if (environmentConfig.isDebug)
GetPage(
... ...
// lib/core/utils/asset_helper.dart
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_instance/src/extension_instance.dart';
String assetCompatPath(String basePath) {
if (!basePath.contains('/')) return basePath;
if (Get.isRegistered<AppEnvironmentConfig>()) {
final region = Get.find<AppEnvironmentConfig>().region.value;
if (region == AppRegion.global) return basePath;
} else {
return basePath;
}
final parts = basePath.split('/');
parts.insert(parts.length - 1, 'zh');
return parts.join('/');
}
... ...
... ... @@ -28,6 +28,9 @@ class AppEnvironmentConfig {
} else {
region.value = stored;
}
if (stored == AppRegion.global) {
await _storage.setTermsAgreed(true);
}
return this;
}
... ...
... ... @@ -784,5 +784,9 @@
"type": "String"
}
}
}
},
"continueWithEmail": "Continue with email",
"continueWithGoogle": "Continue with Google",
"emailLoginEmailHint": "Your email",
"emailLoginYourPassword": "Your password"
}
... ...
... ... @@ -1177,5 +1177,9 @@
"type": "String"
}
}
}
},
"continueWithEmail": "使用电子邮件",
"continueWithGoogle": "使用 Google",
"emailLoginEmailHint": "请输入邮箱",
"emailLoginYourPassword": "请输入密码"
}
... ...
... ... @@ -4179,6 +4179,30 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'原价{price}/年'**
String originalPricePerYear(String price);
/// No description provided for @continueWithEmail.
///
/// In zh, this message translates to:
/// **'使用电子邮件'**
String get continueWithEmail;
/// No description provided for @continueWithGoogle.
///
/// In zh, this message translates to:
/// **'使用 Google'**
String get continueWithGoogle;
/// No description provided for @emailLoginEmailHint.
///
/// In zh, this message translates to:
/// **'请输入邮箱'**
String get emailLoginEmailHint;
/// No description provided for @emailLoginYourPassword.
///
/// In zh, this message translates to:
/// **'请输入密码'**
String get emailLoginYourPassword;
}
class _AppLocalizationsDelegate
... ...
... ... @@ -2348,4 +2348,16 @@ class AppLocalizationsEn extends AppLocalizations {
String originalPricePerYear(String price) {
return 'Was: $price/year';
}
@override
String get continueWithEmail => 'Continue with email';
@override
String get continueWithGoogle => 'Continue with Google';
@override
String get emailLoginEmailHint => 'Your email';
@override
String get emailLoginYourPassword => 'Your password';
}
... ...
... ... @@ -2249,4 +2249,16 @@ class AppLocalizationsZh extends AppLocalizations {
String originalPricePerYear(String price) {
return '原价$price/年';
}
@override
String get continueWithEmail => '使用电子邮件';
@override
String get continueWithGoogle => '使用 Google';
@override
String get emailLoginEmailHint => '请输入邮箱';
@override
String get emailLoginYourPassword => '请输入密码';
}
... ...
... ... @@ -97,9 +97,15 @@ flutter:
- assets/images/common/
- assets/images/tabbar/
- assets/images/today/
- assets/images/today/zh/
- assets/images/friends/
- assets/images/my/
- assets/images/my/zh/
- assets/images/permission/
- assets/images/permission/zh/
- assets/images/watch_theme/
- assets/images/watch_theme/zh/
- assets/images/user_onboarding/
- assets/images/user_onboarding/zh/
- assets/images/premium/
- assets/images/premium/zh/
... ...