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 @@ -5,6 +5,7 @@ import 'package:doublefeel_flutter/app/modules/home/controllers/my_controller.da
5 import 'package:doublefeel_flutter/app/modules/home/widgets/my/account_setting_view.dart'; 5 import 'package:doublefeel_flutter/app/modules/home/widgets/my/account_setting_view.dart';
6 import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart'; 6 import 'package:doublefeel_flutter/app/modules/watch_theme/models/watch_theme_models.dart';
7 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 7 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  8 +import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
8 import 'package:doublefeel_flutter/app/utils/dialog_utils.dart'; 9 import 'package:doublefeel_flutter/app/utils/dialog_utils.dart';
9 import 'package:doublefeel_flutter/core/config/app_environment_config.dart'; 10 import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
10 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 11 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
@@ -444,7 +445,7 @@ class _UnlockPremiumCard extends StatelessWidget { @@ -444,7 +445,7 @@ class _UnlockPremiumCard extends StatelessWidget {
444 ), 445 ),
445 ), 446 ),
446 Image.asset( 447 Image.asset(
447 - 'assets/images/my/bg_my_unlock_vip.png', 448 + assetCompatPath('assets/images/my/bg_my_unlock_vip.png'),
448 width: 149.w, 449 width: 149.w,
449 height: 128.w, 450 height: 128.w,
450 ) 451 )
  1 +import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
1 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; 2 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
2 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 3 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
@@ -164,7 +165,7 @@ class _HrvInstructionCard extends StatelessWidget { @@ -164,7 +165,7 @@ class _HrvInstructionCard extends StatelessWidget {
164 ), 165 ),
165 ), 166 ),
166 Image.asset( 167 Image.asset(
167 - 'assets/images/today/hrv_measure_watch.png', 168 + assetCompatPath('assets/images/today/hrv_measure_watch.png'),
168 width: 110, 169 width: 110,
169 height: 172, 170 height: 172,
170 fit: BoxFit.contain, 171 fit: BoxFit.contain,
1 import 'package:doublefeel_flutter/app/modules/user_onboarding/views/user_onboarding_pages.dart'; 1 import 'package:doublefeel_flutter/app/modules/user_onboarding/views/user_onboarding_pages.dart';
2 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart'; 2 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/guide_common_scaffold.dart';
3 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart'; 3 import 'package:doublefeel_flutter/app/modules/user_onboarding/widget/onboarding_common_widgets.dart';
  4 +import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
4 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 5 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
6 import 'package:flutter/material.dart'; 7 import 'package:flutter/material.dart';
@@ -273,7 +274,7 @@ class _AuthorizationCard extends StatelessWidget { @@ -273,7 +274,7 @@ class _AuthorizationCard extends StatelessWidget {
273 ), 274 ),
274 SizedBox(height: 37), 275 SizedBox(height: 37),
275 Image.asset( 276 Image.asset(
276 - 'assets/images/permission/apple_privacy_cn.png', 277 + assetCompatPath('assets/images/permission/apple_privacy_cn.png'),
277 height: 154, 278 height: 154,
278 width: 303, 279 width: 303,
279 ), 280 ),
@@ -281,19 +282,22 @@ class _AuthorizationCard extends StatelessWidget { @@ -281,19 +282,22 @@ class _AuthorizationCard extends StatelessWidget {
281 height: 10, 282 height: 10,
282 ), 283 ),
283 Image.asset( 284 Image.asset(
284 - 'assets/images/permission/apple_health_item_cn.png', 285 + assetCompatPath(
  286 + 'assets/images/permission/apple_health_item_cn.png'),
285 height: 172, 287 height: 172,
286 width: 303, 288 width: 303,
287 ), 289 ),
288 SizedBox(height: 20), 290 SizedBox(height: 20),
289 Image.asset( 291 Image.asset(
290 - 'assets/images/permission/apple_double_feel_cn.png', 292 + assetCompatPath(
  293 + 'assets/images/permission/apple_double_feel_cn.png'),
291 height: 210, 294 height: 210,
292 width: 303, 295 width: 303,
293 ), 296 ),
294 SizedBox(height: 20), 297 SizedBox(height: 20),
295 Image.asset( 298 Image.asset(
296 - 'assets/images/permission/apple_health_step_cn.png', 299 + assetCompatPath(
  300 + 'assets/images/permission/apple_health_step_cn.png'),
297 height: 167, 301 height: 167,
298 width: 303, 302 width: 303,
299 ), 303 ),
1 import 'dart:async'; 1 import 'dart:async';
2 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 2 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
  3 +import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
3 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart'; 4 import 'package:doublefeel_flutter/core/services/thinking_data_service.dart';
4 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 5 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
@@ -46,7 +47,8 @@ class TodayHrvAdBanner extends StatelessWidget { @@ -46,7 +47,8 @@ class TodayHrvAdBanner extends StatelessWidget {
46 color: context.colors.primary, 47 color: context.colors.primary,
47 ), 48 ),
48 ), 49 ),
49 - leftImagePath: 'assets/images/today/ic_banner_watch_hrv_theme.png', 50 + leftImagePath: assetCompatPath(
  51 + 'assets/images/today/ic_banner_watch_hrv_theme.png'),
50 onClose: controller.dismissHrvAdBanner, 52 onClose: controller.dismissHrvAdBanner,
51 showMargin: showMargin, 53 showMargin: showMargin,
52 ), 54 ),
1 import 'dart:async'; 1 import 'dart:async';
2 import 'dart:convert'; 2 import 'dart:convert';
  3 +import 'package:doublefeel_flutter/app/modules/login/views/email_login_new_password_view.dart';
  4 +import 'package:doublefeel_flutter/app/modules/login/views/email_login_view.dart';
3 import 'package:doublefeel_flutter/core/config/app_environment_config.dart'; 5 import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
4 import 'package:doublefeel_flutter/core/constants/app_const.dart'; 6 import 'package:doublefeel_flutter/core/constants/app_const.dart';
5 import 'package:doublefeel_flutter/core/logging/app_logger.dart'; 7 import 'package:doublefeel_flutter/core/logging/app_logger.dart';
@@ -33,12 +35,18 @@ class LoginController extends GetxController { @@ -33,12 +35,18 @@ class LoginController extends GetxController {
33 final environmentConfig = Get.find<AppEnvironmentConfig>(); 35 final environmentConfig = Get.find<AppEnvironmentConfig>();
34 36
35 final phoneController = TextEditingController(); 37 final phoneController = TextEditingController();
  38 + final emailController = TextEditingController();
  39 + final passwordController = TextEditingController();
  40 + final resetPasswordCodeController = TextEditingController();
36 final codeController = TextEditingController(); 41 final codeController = TextEditingController();
37 final codeFocusNode = FocusNode(); 42 final codeFocusNode = FocusNode();
38 43
39 final phoneInput = ''.obs; 44 final phoneInput = ''.obs;
40 final codeInput = ''.obs; 45 final codeInput = ''.obs;
41 46
  47 + final emailInput = ''.obs;
  48 + final passwordInput = ''.obs;
  49 +
42 final isSendingCode = false.obs; 50 final isSendingCode = false.obs;
43 final countdownSeconds = 0.obs; 51 final countdownSeconds = 0.obs;
44 Timer? _countdownTimer; 52 Timer? _countdownTimer;
@@ -47,6 +55,34 @@ class LoginController extends GetxController { @@ -47,6 +55,34 @@ class LoginController extends GetxController {
47 final hasSentCode = false.obs; 55 final hasSentCode = false.obs;
48 56
49 final termsChecked = false.obs; 57 final termsChecked = false.obs;
  58 + final isPasswordObscured = true.obs;
  59 +
  60 + /// 是否已经点击过「Continue」(用于控制提示文字是否变红)
  61 + final hasTriedEmailLogin = false.obs;
  62 + final resetPasswordCode = ''.obs;
  63 +
  64 + void togglePasswordVisibility() {
  65 + isPasswordObscured.value = !isPasswordObscured.value;
  66 + }
  67 +
  68 + static final _emailRegex = RegExp(
  69 + r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$',
  70 + );
  71 +
  72 + static final _passwordRegex = RegExp(
  73 + r'^(?=.*[0-9])(?=.*[A-Z]).{6,}$',
  74 + );
  75 +
  76 + bool get isEmailValid =>
  77 + _emailRegex.hasMatch(emailInput.value.trim()) &&
  78 + emailInput.value.isNotEmpty;
  79 + bool get isPasswordValid =>
  80 + _passwordRegex.hasMatch(passwordInput.value) &&
  81 + passwordInput.value.isNotEmpty;
  82 + final resetPasswordError = false.obs;
  83 +
  84 + /// 邮箱格式正确且密码符合要求时,按钮可点击
  85 + bool get canEmailLogin => isEmailValid && isPasswordValid;
50 86
51 AppleSignInModel? appleSignInModel; 87 AppleSignInModel? appleSignInModel;
52 88
@@ -90,7 +126,8 @@ class LoginController extends GetxController { @@ -90,7 +126,8 @@ class LoginController extends GetxController {
90 } 126 }
91 127
92 void onPhoneLoginPressed() { 128 void onPhoneLoginPressed() {
93 - if (!termsChecked.value) { 129 + if (!termsChecked.value &&
  130 + environmentConfig.region.value == AppRegion.china) {
94 AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast); 131 AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
95 return; 132 return;
96 } 133 }
@@ -98,8 +135,14 @@ class LoginController extends GetxController { @@ -98,8 +135,14 @@ class LoginController extends GetxController {
98 Get.toNamed(AppRoutes.phoneLogin); 135 Get.toNamed(AppRoutes.phoneLogin);
99 } 136 }
100 137
  138 + void onEmailLoginPressed() {
  139 + ta.track('enter_doublefeel_email_login_page');
  140 + Get.to(() => const EmailLoginView());
  141 + }
  142 +
101 void onAppleLoginPressed() async { 143 void onAppleLoginPressed() async {
102 - if (!termsChecked.value) { 144 + if (!termsChecked.value &&
  145 + environmentConfig.region.value == AppRegion.china) {
103 AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast); 146 AppToast.show(AppLocalizations.of(Get.context!)!.loginAgreeToTermsToast);
104 return; 147 return;
105 } 148 }
@@ -122,6 +165,26 @@ class LoginController extends GetxController { @@ -122,6 +165,26 @@ class LoginController extends GetxController {
122 } 165 }
123 } 166 }
124 167
  168 + void onGoogleLoginPressed() async {
  169 + try {
  170 + final result = await LoadingService.instance.run(() async {
  171 + // return await PlatformHostApi().requestGoogleSignIn();
  172 + }, type: LoadingType.circular);
  173 + // result 为 null 或 identityToken 为空,说明用户取消或苹果授权失败
  174 + if (result == null ||
  175 + result.identityToken?.isNotEmpty != true ||
  176 + result.userId.isNotEmpty != true) {
  177 + appleSignInModel = null;
  178 + return;
  179 + }
  180 + appleSignInModel = result;
  181 + await _loginWithApple(result);
  182 + } catch (e) {
  183 + appleSignInModel = null;
  184 + AppToast.show(e.toString());
  185 + }
  186 + }
  187 +
125 void onDebugPressed() { 188 void onDebugPressed() {
126 Get.toNamed(AppRoutes.debugEnvironment); 189 Get.toNamed(AppRoutes.debugEnvironment);
127 } 190 }
@@ -151,6 +214,13 @@ class LoginController extends GetxController { @@ -151,6 +214,13 @@ class LoginController extends GetxController {
151 codeController.addListener(() { 214 codeController.addListener(() {
152 codeInput.value = codeController.text; 215 codeInput.value = codeController.text;
153 }); 216 });
  217 +
  218 + emailController.addListener(() {
  219 + emailInput.value = emailController.text;
  220 + });
  221 + passwordController.addListener(() {
  222 + passwordInput.value = passwordController.text;
  223 + });
154 } 224 }
155 225
156 @override 226 @override
@@ -252,6 +322,95 @@ class LoginController extends GetxController { @@ -252,6 +322,95 @@ class LoginController extends GetxController {
252 } 322 }
253 } 323 }
254 324
  325 + /// 邮箱登录 Continue 按钮点击
  326 + Future<void> emailLogin() async {
  327 + hasTriedEmailLogin.value = true;
  328 +
  329 + if (!isEmailValid) {
  330 + AppToast.show('Invalid email format');
  331 + return;
  332 + }
  333 +
  334 + if (!isPasswordValid) {
  335 + return;
  336 + }
  337 +
  338 + // TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
  339 + // isLoggingIn.value = true;
  340 + // try {
  341 + // await LoadingService.instance.run(() async {
  342 + // final loginRes = await _userApi.loginWithEmail(
  343 + // email: emailInput.value.trim(),
  344 + // password: passwordInput.value,
  345 + // );
  346 + // if (loginRes is AppSuccess<LoginResponse>) {
  347 + // await _handleLoginSuccess(loginRes.data);
  348 + // await Get.find<LocalStorage>().setLastLoginMethod('email');
  349 + // }
  350 + // });
  351 + // } finally {
  352 + // isLoggingIn.value = false;
  353 + // }
  354 + }
  355 +
  356 + Future<void> sendRestEmail() async {
  357 + if (!isEmailValid) {
  358 + AppToast.show('Invalid email format');
  359 + return;
  360 + }
  361 +
  362 + // TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
  363 + // isLoggingIn.value = true;
  364 + // try {
  365 + // await LoadingService.instance.run(() async {
  366 + // final loginRes = await _userApi.loginWithEmail(
  367 + // email: emailInput.value.trim(),
  368 + // password: passwordInput.value,
  369 + // );
  370 + // if (loginRes is AppSuccess<LoginResponse>) {
  371 + // await _handleLoginSuccess(loginRes.data);
  372 + // await Get.find<LocalStorage>().setLastLoginMethod('email');
  373 + // }
  374 + // });
  375 + // } finally {
  376 + // isLoggingIn.value = false;
  377 + // }
  378 + Get.to(EmailLoginNewPasswordView());
  379 + }
  380 +
  381 + Future<void> resetPassword() async {
  382 + if (!isEmailValid) {
  383 + AppToast.show('Invalid email format');
  384 + return;
  385 + }
  386 + if (!isPasswordValid) {
  387 + AppToast.show('Invalid password format');
  388 + return;
  389 + }
  390 + if (resetPasswordCodeController.text.isEmpty) {
  391 + AppToast.show('Invalid code');
  392 + return;
  393 + }
  394 + // TODO: 邮箱登录接口待接入,API 路径由后端确认后在此调用
  395 + // isLoggingIn.value = true;
  396 + // try {
  397 + // await LoadingService.instance.run(() async {
  398 + // final loginRes = await _userApi.loginWithEmail(
  399 + // email: emailInput.value.trim(),
  400 + // password: passwordInput.value,
  401 + // );
  402 + // if (loginRes is AppSuccess<LoginResponse>) {
  403 + // await _handleLoginSuccess(loginRes.data);
  404 + // await Get.find<LocalStorage>().setLastLoginMethod('email');
  405 + // }
  406 + // });
  407 + // } finally {
  408 + // isLoggingIn.value = false;
  409 + // }
  410 + Get.back();
  411 + Get.back();
  412 + }
  413 +
255 Future<void> _loginWithApple(AppleSignInModel appleModel) async { 414 Future<void> _loginWithApple(AppleSignInModel appleModel) async {
256 isLoggingIn.value = true; 415 isLoggingIn.value = true;
257 try { 416 try {
  1 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
  2 +import 'package:flutter/material.dart';
  3 +import 'package:flutter/services.dart';
  4 +import 'package:get/get.dart';
  5 +
  6 +void showEmailLoginContactUsBottomSheet({
  7 + required String userId,
  8 + VoidCallback? onSendEmail,
  9 +}) {
  10 + Get.bottomSheet(
  11 + EmailLoginContactUsBottomSheet(
  12 + userId: userId,
  13 + onSendEmail: onSendEmail,
  14 + ),
  15 + barrierColor: Colors.black.withValues(alpha: 0.7),
  16 + enableDrag: true,
  17 + isScrollControlled: true,
  18 + persistent: false,
  19 + );
  20 +}
  21 +
  22 +class EmailLoginContactUsBottomSheet extends StatelessWidget {
  23 + const EmailLoginContactUsBottomSheet({
  24 + super.key,
  25 + required this.userId,
  26 + this.onSendEmail,
  27 + });
  28 +
  29 + final String userId;
  30 + final VoidCallback? onSendEmail;
  31 +
  32 + @override
  33 + Widget build(BuildContext context) {
  34 + return Container(
  35 + height: 346,
  36 + decoration: const BoxDecoration(
  37 + color: Color(0xFFF9F9FB),
  38 + borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
  39 + ),
  40 + child: SafeArea(
  41 + top: false,
  42 + child: Column(
  43 + children: [
  44 + const SizedBox(height: 8),
  45 + Container(
  46 + width: 50,
  47 + height: 3,
  48 + decoration: BoxDecoration(
  49 + color: const Color(0xFFD3D3D8),
  50 + borderRadius: BorderRadius.circular(2),
  51 + ),
  52 + ),
  53 + const SizedBox(height: 17),
  54 + const Text(
  55 + 'Contact us',
  56 + style: TextStyle(
  57 + color: Color(0xFF141414),
  58 + fontSize: 16,
  59 + fontWeight: FontWeight.w700,
  60 + ),
  61 + ),
  62 + const SizedBox(height: 22),
  63 + const Padding(
  64 + padding: EdgeInsets.symmetric(horizontal: 16),
  65 + child: Align(
  66 + alignment: Alignment.centerLeft,
  67 + child: Text(
  68 + 'Please describe the problem clearly and include screen '
  69 + 'recordings if possible.',
  70 + style: TextStyle(
  71 + color: Color(0xFF141414),
  72 + fontSize: 15,
  73 + fontWeight: FontWeight.w400,
  74 + ),
  75 + ),
  76 + ),
  77 + ),
  78 + const SizedBox(height: 8),
  79 + const Padding(
  80 + padding: EdgeInsets.symmetric(horizontal: 16),
  81 + child: Align(
  82 + alignment: Alignment.centerLeft,
  83 + child: Text(
  84 + 'Send us your User ID as it will help us identify\n'
  85 + 'the problem faster.',
  86 + style: TextStyle(
  87 + color: Color(0xFF141414),
  88 + fontSize: 15,
  89 + fontWeight: FontWeight.w400,
  90 + height: 1.2,
  91 + ),
  92 + ),
  93 + ),
  94 + ),
  95 + const SizedBox(height: 16),
  96 + _UserIdField(userId: userId),
  97 + const SizedBox(height: 29),
  98 + _SendEmailButton(
  99 + onTap: () {
  100 + Get.back();
  101 + onSendEmail?.call();
  102 + },
  103 + ),
  104 + const Spacer(),
  105 + ],
  106 + ),
  107 + ),
  108 + );
  109 + }
  110 +}
  111 +
  112 +class _UserIdField extends StatelessWidget {
  113 + const _UserIdField({required this.userId});
  114 +
  115 + final String userId;
  116 +
  117 + @override
  118 + Widget build(BuildContext context) {
  119 + return Container(
  120 + height: 48,
  121 + margin: const EdgeInsets.symmetric(horizontal: 16),
  122 + decoration: BoxDecoration(
  123 + color: Colors.white,
  124 + border: Border.all(color: const Color(0xFFE7E7E8)),
  125 + borderRadius: BorderRadius.circular(12),
  126 + ),
  127 + child: ClipRRect(
  128 + borderRadius: BorderRadius.circular(12),
  129 + child: Row(
  130 + children: [
  131 + Expanded(
  132 + child: Padding(
  133 + padding: const EdgeInsets.only(left: 16, right: 8),
  134 + child: Text(
  135 + userId,
  136 + maxLines: 1,
  137 + overflow: TextOverflow.ellipsis,
  138 + style: const TextStyle(
  139 + color: Color(0xFF141414),
  140 + fontSize: 14,
  141 + fontWeight: FontWeight.w500,
  142 + ),
  143 + ),
  144 + ),
  145 + ),
  146 + Material(
  147 + color: const Color(0xFF7B9BFB),
  148 + child: InkWell(
  149 + onTap: () {
  150 + Clipboard.setData(ClipboardData(text: userId));
  151 + AppToast.show('Copied');
  152 + },
  153 + child: SizedBox(
  154 + width: 48,
  155 + height: 48,
  156 + child: Center(
  157 + child: Image.asset(
  158 + 'assets/images/common/ic_contact_us_copy.png',
  159 + width: 24,
  160 + height: 24,
  161 + ),
  162 + ),
  163 + ),
  164 + ),
  165 + ),
  166 + ],
  167 + ),
  168 + ),
  169 + );
  170 + }
  171 +}
  172 +
  173 +class _SendEmailButton extends StatelessWidget {
  174 + const _SendEmailButton({required this.onTap});
  175 +
  176 + final VoidCallback onTap;
  177 +
  178 + @override
  179 + Widget build(BuildContext context) {
  180 + return Material(
  181 + color: const Color(0xFF845EEE),
  182 + borderRadius: BorderRadius.circular(24),
  183 + child: InkWell(
  184 + onTap: onTap,
  185 + borderRadius: BorderRadius.circular(24),
  186 + child: const SizedBox(
  187 + width: 220,
  188 + height: 48,
  189 + child: Row(
  190 + mainAxisAlignment: MainAxisAlignment.center,
  191 + children: [
  192 + Image(
  193 + image: AssetImage(
  194 + 'assets/images/common/ic_contact_us_lightning.png',
  195 + ),
  196 + width: 24,
  197 + height: 24,
  198 + ),
  199 + SizedBox(width: 6),
  200 + Text(
  201 + 'Send Email',
  202 + style: TextStyle(
  203 + color: Colors.white,
  204 + fontSize: 14,
  205 + fontWeight: FontWeight.w500,
  206 + ),
  207 + ),
  208 + ],
  209 + ),
  210 + ),
  211 + ),
  212 + );
  213 + }
  214 +}
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/material.dart';
  4 +import 'package:flutter/services.dart';
  5 +import 'package:get/get.dart';
  6 +
  7 +import '../../../routes/app_pages.dart';
  8 +import '../controllers/login_controller.dart';
  9 +import 'email_login_contact_us_bottom_sheet.dart';
  10 +
  11 +class EmailLoginNewPasswordView extends GetView<LoginController> {
  12 + const EmailLoginNewPasswordView({super.key});
  13 +
  14 + @override
  15 + Widget build(BuildContext context) {
  16 + final l10n = context.l10n;
  17 + return AnnotatedRegion<SystemUiOverlayStyle>(
  18 + value: const SystemUiOverlayStyle(
  19 + statusBarColor: Colors.transparent,
  20 + statusBarIconBrightness: Brightness.dark,
  21 + statusBarBrightness: Brightness.light,
  22 + systemNavigationBarColor: Colors.transparent,
  23 + systemNavigationBarIconBrightness: Brightness.dark,
  24 + ),
  25 + child: Scaffold(
  26 + resizeToAvoidBottomInset: false,
  27 + backgroundColor: Colors.white,
  28 + body: Stack(
  29 + fit: StackFit.expand,
  30 + children: [
  31 + SingleChildScrollView(
  32 + physics: const ClampingScrollPhysics(),
  33 + padding: EdgeInsets.only(
  34 + bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
  35 + ),
  36 + child: Column(
  37 + crossAxisAlignment: CrossAxisAlignment.stretch,
  38 + children: [
  39 + Stack(
  40 + children: [
  41 + Image.asset(
  42 + 'assets/images/user_onboarding/bg_phone_login_top.png',
  43 + fit: BoxFit.fitWidth,
  44 + ),
  45 + Positioned(
  46 + bottom: 67,
  47 + left: 32,
  48 + child: Column(
  49 + crossAxisAlignment: CrossAxisAlignment.start,
  50 + children: [
  51 + Text(
  52 + l10n.phoneLoginHello,
  53 + style: TextStyle(
  54 + color: Colors.white,
  55 + fontSize: 28,
  56 + fontWeight: FontWeight.w900,
  57 + ),
  58 + ),
  59 + const SizedBox(height: 4),
  60 + Text(
  61 + l10n.phoneLoginWelcome,
  62 + style: TextStyle(
  63 + color: Colors.white,
  64 + fontSize: 16,
  65 + fontWeight: FontWeight.w600,
  66 + ),
  67 + ),
  68 + ],
  69 + ),
  70 + )
  71 + ],
  72 + ),
  73 + Transform.translate(
  74 + offset: const Offset(0, -8),
  75 + child: Text(
  76 + 'Check your email',
  77 + textAlign: TextAlign.center,
  78 + style: TextStyle(
  79 + color: const Color(0xFF0F0F11),
  80 + fontSize: 24,
  81 + fontWeight: FontWeight.w600,
  82 + ),
  83 + ),
  84 + ),
  85 + Padding(
  86 + padding: const EdgeInsets.symmetric(horizontal: 40),
  87 + child: Text.rich(
  88 + TextSpan(
  89 + children: [
  90 + TextSpan(
  91 + text: 'We’ve sent a code to ',
  92 + style: TextStyle(
  93 + color: const Color(0xFF78787D),
  94 + fontSize: 14,
  95 + fontWeight: FontWeight.w400,
  96 + ),
  97 + ),
  98 + TextSpan(
  99 + text: controller.emailInput.value,
  100 + style: TextStyle(
  101 + color: const Color(0xFF0F0F11),
  102 + fontSize: 14,
  103 + fontWeight: FontWeight.w400,
  104 + ),
  105 + ),
  106 + TextSpan(
  107 + text:
  108 + '. Didn’t get it? Check your spam folder or try again.',
  109 + style: TextStyle(
  110 + color: const Color(0xFF78787D),
  111 + fontSize: 14,
  112 + fontWeight: FontWeight.w400,
  113 + ),
  114 + ),
  115 + ],
  116 + ),
  117 + textAlign: TextAlign.center,
  118 + ),
  119 + ),
  120 +
  121 + const SizedBox(height: 32),
  122 + // 输入区
  123 + Padding(
  124 + padding: const EdgeInsets.symmetric(horizontal: 28),
  125 + child: Column(
  126 + crossAxisAlignment: CrossAxisAlignment.stretch,
  127 + children: [
  128 + Padding(
  129 + padding: const EdgeInsets.only(left: 16, bottom: 15),
  130 + child: Text(
  131 + 'Code from email',
  132 + style: TextStyle(
  133 + color: const Color(0xFF141414),
  134 + fontSize: 14,
  135 + fontWeight: FontWeight.w500,
  136 + ),
  137 + ),
  138 + ),
  139 + Obx(() {
  140 + controller.resetPasswordCode.value;
  141 + return TextField(
  142 + controller: controller.resetPasswordCodeController,
  143 + keyboardType: TextInputType.text,
  144 + contextMenuBuilder: (BuildContext context,
  145 + EditableTextState editableTextState) {
  146 + // 获取默认的菜单项列表
  147 + List<ContextMenuButtonItem> buttonItems =
  148 + editableTextState.contextMenuButtonItems;
  149 +
  150 + // 例如:只保留“粘贴” (Paste)
  151 + buttonItems.removeWhere((item) =>
  152 + item.type != ContextMenuButtonType.paste);
  153 + return AdaptiveTextSelectionToolbar.buttonItems(
  154 + anchors: editableTextState.contextMenuAnchors,
  155 + buttonItems: buttonItems,
  156 + );
  157 + },
  158 + inputFormatters: [
  159 + FilteringTextInputFormatter.deny(RegExp(r'\s')),
  160 + FilteringTextInputFormatter.allow(
  161 + RegExp(r'[a-zA-Z0-9@._\-+]'),
  162 + ),
  163 + ],
  164 + style: TextStyle(
  165 + color: context.colors.textPrimary,
  166 + fontSize: 16,
  167 + fontWeight: FontWeight.w400,
  168 + ),
  169 + decoration: InputDecoration(
  170 + hintText: 'Code',
  171 + hintStyle: TextStyle(
  172 + color: context.colors.textTertiary,
  173 + fontSize: 16,
  174 + ),
  175 + counterText: '',
  176 + filled: true,
  177 + fillColor: Color(0xFFF3F3F3),
  178 + contentPadding: const EdgeInsets.symmetric(
  179 + horizontal: 20,
  180 + vertical: 16,
  181 + ),
  182 + border: OutlineInputBorder(
  183 + borderRadius: BorderRadius.circular(27),
  184 + borderSide: BorderSide.none,
  185 + ),
  186 + enabledBorder: OutlineInputBorder(
  187 + borderRadius: BorderRadius.circular(27),
  188 + borderSide: BorderSide.none,
  189 + ),
  190 + focusedBorder: OutlineInputBorder(
  191 + borderRadius: BorderRadius.circular(27),
  192 + borderSide: BorderSide(
  193 + color: context.colors.primary,
  194 + width: 1,
  195 + ),
  196 + ),
  197 + ),
  198 + );
  199 + }),
  200 +
  201 + SizedBox(
  202 + height: 20,
  203 + ),
  204 + Padding(
  205 + padding: const EdgeInsets.only(left: 16, bottom: 15),
  206 + child: Text(
  207 + 'New password',
  208 + style: TextStyle(
  209 + color: const Color(0xFF141414),
  210 + fontSize: 14,
  211 + fontWeight: FontWeight.w500,
  212 + ),
  213 + ),
  214 + ),
  215 + Obx(() {
  216 + final isObscured =
  217 + controller.isPasswordObscured.value;
  218 + return TextField(
  219 + controller: controller.passwordController,
  220 + keyboardType: TextInputType.visiblePassword,
  221 + obscureText: isObscured,
  222 + obscuringCharacter: '*',
  223 + // maxLength: 13,
  224 + contextMenuBuilder: (BuildContext context,
  225 + EditableTextState editableTextState) {
  226 + // 获取默认的菜单项列表
  227 + List<ContextMenuButtonItem> buttonItems =
  228 + editableTextState.contextMenuButtonItems;
  229 +
  230 + // 例如:只保留"粘贴" (Paste)
  231 + buttonItems.removeWhere((item) =>
  232 + item.type != ContextMenuButtonType.paste);
  233 + return AdaptiveTextSelectionToolbar.buttonItems(
  234 + anchors: editableTextState.contextMenuAnchors,
  235 + buttonItems: buttonItems,
  236 + );
  237 + },
  238 + inputFormatters: [
  239 + FilteringTextInputFormatter.deny(RegExp(r'\s')),
  240 + FilteringTextInputFormatter.allow(
  241 + RegExp(r'[\x21-\x7E]'),
  242 + ),
  243 + ],
  244 + style: TextStyle(
  245 + color: context.colors.textPrimary,
  246 + fontSize: 16,
  247 + fontWeight: FontWeight.w400,
  248 + ),
  249 + decoration: InputDecoration(
  250 + hintText: l10n.emailLoginYourPassword,
  251 + hintStyle: TextStyle(
  252 + color: context.colors.textTertiary,
  253 + fontSize: 16,
  254 + ),
  255 + counterText: '',
  256 + filled: true,
  257 + fillColor: Color(0xFFF3F3F3),
  258 + contentPadding: const EdgeInsets.symmetric(
  259 + horizontal: 20,
  260 + vertical: 16,
  261 + ),
  262 + border: OutlineInputBorder(
  263 + borderRadius: BorderRadius.circular(27),
  264 + borderSide: BorderSide.none,
  265 + ),
  266 + enabledBorder: OutlineInputBorder(
  267 + borderRadius: BorderRadius.circular(27),
  268 + borderSide: BorderSide.none,
  269 + ),
  270 + focusedBorder: OutlineInputBorder(
  271 + borderRadius: BorderRadius.circular(27),
  272 + borderSide: BorderSide(
  273 + color: context.colors.primary,
  274 + width: 1,
  275 + ),
  276 + ),
  277 + suffixIconConstraints: const BoxConstraints(
  278 + minWidth: 24,
  279 + minHeight: 24,
  280 + ),
  281 + suffixIcon: GestureDetector(
  282 + onTap: controller.togglePasswordVisibility,
  283 + child: Padding(
  284 + padding: const EdgeInsets.only(right: 16),
  285 + child: Image.asset(
  286 + isObscured
  287 + ? 'assets/images/common/ic_hide_password.png'
  288 + : 'assets/images/common/ic_show_password.webp',
  289 + color: context.colors.textTertiary,
  290 + width: 24,
  291 + height: 24,
  292 + ),
  293 + ),
  294 + )));
  295 + }),
  296 +
  297 + const SizedBox(height: 8),
  298 +
  299 + // 密码格式提示
  300 + Obx(() {
  301 + final hasTried = controller.hasTriedEmailLogin.value;
  302 + final pwOk = controller.isPasswordValid;
  303 + final isError = hasTried &&
  304 + !pwOk &&
  305 + controller.passwordInput.value.isNotEmpty;
  306 + return Text(
  307 + 'Password must be at least 6 characters and include 1 number and 1 uppercase letter.',
  308 + style: TextStyle(
  309 + color: isError
  310 + ? Colors.red
  311 + : context.colors.textTertiary,
  312 + fontSize: 12,
  313 + fontWeight: FontWeight.w400,
  314 + ),
  315 + textAlign: TextAlign.center,
  316 + );
  317 + }),
  318 +
  319 + const SizedBox(height: 39),
  320 + Obx(() {
  321 + final canProceed = controller
  322 + .resetPasswordCodeController
  323 + .text
  324 + .isNotEmpty &&
  325 + controller.isPasswordValid;
  326 + return SizedBox(
  327 + height: 48,
  328 + child: ElevatedButton(
  329 + onPressed:
  330 + canProceed ? controller.resetPassword : null,
  331 + style: ElevatedButton.styleFrom(
  332 + backgroundColor: context.colors.primary,
  333 + foregroundColor: Colors.white,
  334 + disabledBackgroundColor: context.colors.primary
  335 + .withValues(alpha: 0.4),
  336 + disabledForegroundColor: Colors.white,
  337 + elevation: 0,
  338 + shape: RoundedRectangleBorder(
  339 + borderRadius: BorderRadius.circular(24),
  340 + ),
  341 + textStyle: const TextStyle(
  342 + fontSize: 16,
  343 + fontWeight: FontWeight.w600,
  344 + ),
  345 + ),
  346 + child: Text('Reset password'),
  347 + ),
  348 + );
  349 + }),
  350 + const SizedBox(height: 12),
  351 +
  352 + Center(
  353 + child: GestureDetector(
  354 + onTap: () => showEmailLoginContactUsBottomSheet(
  355 + userId: controller.emailInput.value.trim(),
  356 + onSendEmail: () =>
  357 + Get.toNamed(Routes.SUBMIT_FEEDBACK),
  358 + ),
  359 + child: Text(
  360 + 'Having trouble? Contact us',
  361 + style: TextStyle(
  362 + color: const Color(0xFF39CAF4),
  363 + fontSize: 14,
  364 + fontWeight: FontWeight.w500,
  365 + decoration: TextDecoration.underline,
  366 + decorationColor: const Color(0xFF39CAF4),
  367 + ),
  368 + ),
  369 + ),
  370 + ),
  371 + // Continue 按钮
  372 + ],
  373 + ),
  374 + ),
  375 + ],
  376 + ),
  377 + ),
  378 + Positioned(
  379 + top: MediaQuery.paddingOf(context).top,
  380 + left: 0,
  381 + child: IconButton(
  382 + highlightColor: Colors.transparent,
  383 + splashColor: Colors.transparent,
  384 + padding: EdgeInsets.zero,
  385 + onPressed: Get.back,
  386 + icon: Image.asset(
  387 + 'assets/images/common/ic_nav_back.webp',
  388 + width: 24,
  389 + height: 24,
  390 + )),
  391 + ),
  392 + ],
  393 + ),
  394 + ),
  395 + );
  396 + }
  397 +}
  1 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  2 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/material.dart';
  4 +import 'package:flutter/services.dart';
  5 +import 'package:get/get.dart';
  6 +
  7 +import '../controllers/login_controller.dart';
  8 +
  9 +class EmailLoginResetPasswordView extends GetView<LoginController> {
  10 + const EmailLoginResetPasswordView({super.key});
  11 +
  12 + @override
  13 + Widget build(BuildContext context) {
  14 + final l10n = context.l10n;
  15 + return AnnotatedRegion<SystemUiOverlayStyle>(
  16 + value: const SystemUiOverlayStyle(
  17 + statusBarColor: Colors.transparent,
  18 + statusBarIconBrightness: Brightness.dark,
  19 + statusBarBrightness: Brightness.light,
  20 + systemNavigationBarColor: Colors.transparent,
  21 + systemNavigationBarIconBrightness: Brightness.dark,
  22 + ),
  23 + child: Scaffold(
  24 + resizeToAvoidBottomInset: false,
  25 + backgroundColor: Colors.white,
  26 + body: Stack(
  27 + fit: StackFit.expand,
  28 + children: [
  29 + SingleChildScrollView(
  30 + physics: const ClampingScrollPhysics(),
  31 + padding: EdgeInsets.only(
  32 + bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
  33 + ),
  34 + child: Column(
  35 + crossAxisAlignment: CrossAxisAlignment.stretch,
  36 + children: [
  37 + Stack(
  38 + children: [
  39 + Image.asset(
  40 + 'assets/images/user_onboarding/bg_phone_login_top.png',
  41 + fit: BoxFit.fitWidth,
  42 + ),
  43 + Positioned(
  44 + bottom: 67,
  45 + left: 32,
  46 + child: Column(
  47 + crossAxisAlignment: CrossAxisAlignment.start,
  48 + children: [
  49 + Text(
  50 + l10n.phoneLoginHello,
  51 + style: TextStyle(
  52 + color: Colors.white,
  53 + fontSize: 28,
  54 + fontWeight: FontWeight.w900,
  55 + ),
  56 + ),
  57 + const SizedBox(height: 4),
  58 + Text(
  59 + l10n.phoneLoginWelcome,
  60 + style: TextStyle(
  61 + color: Colors.white,
  62 + fontSize: 16,
  63 + fontWeight: FontWeight.w600,
  64 + ),
  65 + ),
  66 + ],
  67 + ),
  68 + )
  69 + ],
  70 + ),
  71 + Transform.translate(
  72 + offset: const Offset(0, -8),
  73 + child: Text(
  74 + 'Reset password',
  75 + textAlign: TextAlign.center,
  76 + style: TextStyle(
  77 + color: const Color(0xFF0F0F11),
  78 + fontSize: 24,
  79 + fontWeight: FontWeight.w600,
  80 + ),
  81 + ),
  82 + ),
  83 +
  84 + Text(
  85 + '''You'll receive a code via email to reset your password.''',
  86 + textAlign: TextAlign.center,
  87 + style: TextStyle(
  88 + color: const Color(0xFF78787D),
  89 + fontSize: 14,
  90 + fontWeight: FontWeight.w400,
  91 + ),
  92 + ),
  93 + const SizedBox(height: 32),
  94 +
  95 + // 输入区
  96 + Padding(
  97 + padding: const EdgeInsets.symmetric(horizontal: 28),
  98 + child: Column(
  99 + crossAxisAlignment: CrossAxisAlignment.stretch,
  100 + children: [
  101 + // email输入框
  102 + Obx(() {
  103 + final emailNotEmpty =
  104 + controller.emailInput.value.isNotEmpty;
  105 + return TextField(
  106 + controller: controller.emailController,
  107 + keyboardType: TextInputType.emailAddress,
  108 + // maxLength: 13,
  109 + contextMenuBuilder: (BuildContext context,
  110 + EditableTextState editableTextState) {
  111 + // 获取默认的菜单项列表
  112 + List<ContextMenuButtonItem> buttonItems =
  113 + editableTextState.contextMenuButtonItems;
  114 +
  115 + // 例如:只保留“粘贴” (Paste)
  116 + buttonItems.removeWhere((item) =>
  117 + item.type != ContextMenuButtonType.paste);
  118 + return AdaptiveTextSelectionToolbar.buttonItems(
  119 + anchors: editableTextState.contextMenuAnchors,
  120 + buttonItems: buttonItems,
  121 + );
  122 + },
  123 + inputFormatters: [
  124 + FilteringTextInputFormatter.deny(RegExp(r'\s')),
  125 + FilteringTextInputFormatter.allow(
  126 + RegExp(r'[a-zA-Z0-9@._\-+]'),
  127 + ),
  128 + ],
  129 + style: TextStyle(
  130 + color: context.colors.textPrimary,
  131 + fontSize: 16,
  132 + fontWeight: FontWeight.w400,
  133 + ),
  134 + decoration: InputDecoration(
  135 + hintText: l10n.emailLoginEmailHint,
  136 + hintStyle: TextStyle(
  137 + color: context.colors.textTertiary,
  138 + fontSize: 16,
  139 + ),
  140 + counterText: '',
  141 + filled: true,
  142 + fillColor: Color(0xFFF3F3F3),
  143 + contentPadding: const EdgeInsets.symmetric(
  144 + horizontal: 20,
  145 + vertical: 16,
  146 + ),
  147 + border: OutlineInputBorder(
  148 + borderRadius: BorderRadius.circular(27),
  149 + borderSide: BorderSide.none,
  150 + ),
  151 + enabledBorder: OutlineInputBorder(
  152 + borderRadius: BorderRadius.circular(27),
  153 + borderSide: BorderSide.none,
  154 + ),
  155 + focusedBorder: OutlineInputBorder(
  156 + borderRadius: BorderRadius.circular(27),
  157 + borderSide: BorderSide(
  158 + color: context.colors.primary,
  159 + width: 1,
  160 + ),
  161 + ),
  162 + suffixIcon: emailNotEmpty
  163 + ? GestureDetector(
  164 + onTap: controller.emailController.clear,
  165 + child: Icon(
  166 + Icons.cancel,
  167 + color: context.colors.textTertiary,
  168 + size: 18,
  169 + ),
  170 + )
  171 + : null,
  172 + ),
  173 + );
  174 + }),
  175 +
  176 + SizedBox(
  177 + height: 20,
  178 + child: Obx(() {
  179 + // final emailOk = controller.isEmailValid;
  180 + // final showError = !emailOk &&
  181 + // controller.emailInput.value.isNotEmpty;
  182 + return Visibility(
  183 + visible: controller.resetPasswordError.value,
  184 + maintainSize: true,
  185 + maintainAnimation: true,
  186 + maintainState: true,
  187 + child: Padding(
  188 + padding: const EdgeInsets.only(left: 8),
  189 + child: Text(
  190 + 'This email is not registered. Please check and try again.',
  191 + style: TextStyle(
  192 + color: Colors.red,
  193 + fontSize: 12,
  194 + fontWeight: FontWeight.w400,
  195 + ),
  196 + ),
  197 + ),
  198 + );
  199 + }),
  200 + ),
  201 +
  202 + const SizedBox(height: 48),
  203 +
  204 + // Continue 按钮
  205 + Obx(() {
  206 + final canProceed = controller.isEmailValid;
  207 + return SizedBox(
  208 + height: 48,
  209 + child: ElevatedButton(
  210 + onPressed:
  211 + canProceed ? controller.sendRestEmail : null,
  212 + style: ElevatedButton.styleFrom(
  213 + backgroundColor: context.colors.primary,
  214 + foregroundColor: Colors.white,
  215 + disabledBackgroundColor: context.colors.primary
  216 + .withValues(alpha: 0.4),
  217 + disabledForegroundColor: Colors.white,
  218 + elevation: 0,
  219 + shape: RoundedRectangleBorder(
  220 + borderRadius: BorderRadius.circular(24),
  221 + ),
  222 + textStyle: const TextStyle(
  223 + fontSize: 16,
  224 + fontWeight: FontWeight.w600,
  225 + ),
  226 + ),
  227 + child: Text('Send reset email'),
  228 + ),
  229 + );
  230 + }),
  231 + ],
  232 + ),
  233 + ),
  234 + ],
  235 + ),
  236 + ),
  237 + Positioned(
  238 + top: MediaQuery.paddingOf(context).top,
  239 + left: 0,
  240 + child: IconButton(
  241 + highlightColor: Colors.transparent,
  242 + splashColor: Colors.transparent,
  243 + padding: EdgeInsets.zero,
  244 + onPressed: Get.back,
  245 + icon: Image.asset(
  246 + 'assets/images/common/ic_nav_back.webp',
  247 + width: 24,
  248 + height: 24,
  249 + )),
  250 + ),
  251 + ],
  252 + ),
  253 + ),
  254 + );
  255 + }
  256 +}
  1 +import 'package:doublefeel_flutter/app/modules/login/views/email_login_reset_password_view.dart';
  2 +import 'package:doublefeel_flutter/core/theme/app_theme.dart';
  3 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  4 +import 'package:flutter/material.dart';
  5 +import 'package:flutter/services.dart';
  6 +import 'package:get/get.dart';
  7 +
  8 +import '../controllers/login_controller.dart';
  9 +
  10 +class EmailLoginView extends GetView<LoginController> {
  11 + const EmailLoginView({super.key});
  12 +
  13 + @override
  14 + Widget build(BuildContext context) {
  15 + final l10n = context.l10n;
  16 + return AnnotatedRegion<SystemUiOverlayStyle>(
  17 + value: const SystemUiOverlayStyle(
  18 + statusBarColor: Colors.transparent,
  19 + statusBarIconBrightness: Brightness.dark,
  20 + statusBarBrightness: Brightness.light,
  21 + systemNavigationBarColor: Colors.transparent,
  22 + systemNavigationBarIconBrightness: Brightness.dark,
  23 + ),
  24 + child: Scaffold(
  25 + resizeToAvoidBottomInset: false,
  26 + backgroundColor: Colors.white,
  27 + body: Stack(
  28 + fit: StackFit.expand,
  29 + children: [
  30 + SingleChildScrollView(
  31 + physics: const ClampingScrollPhysics(),
  32 + padding: EdgeInsets.only(
  33 + bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
  34 + ),
  35 + child: Column(
  36 + crossAxisAlignment: CrossAxisAlignment.stretch,
  37 + children: [
  38 + Stack(
  39 + children: [
  40 + Image.asset(
  41 + 'assets/images/user_onboarding/bg_phone_login_top.png',
  42 + fit: BoxFit.fitWidth,
  43 + ),
  44 + Positioned(
  45 + bottom: 67,
  46 + left: 32,
  47 + child: Column(
  48 + crossAxisAlignment: CrossAxisAlignment.start,
  49 + children: [
  50 + Text(
  51 + l10n.phoneLoginHello,
  52 + style: TextStyle(
  53 + color: Colors.white,
  54 + fontSize: 28,
  55 + fontWeight: FontWeight.w900,
  56 + ),
  57 + ),
  58 + const SizedBox(height: 4),
  59 + Text(
  60 + l10n.phoneLoginWelcome,
  61 + style: TextStyle(
  62 + color: Colors.white,
  63 + fontSize: 16,
  64 + fontWeight: FontWeight.w600,
  65 + ),
  66 + ),
  67 + ],
  68 + ),
  69 + )
  70 + ],
  71 + ),
  72 + Transform.translate(
  73 + offset: const Offset(0, -8),
  74 + child: Text(
  75 + 'Log In or Sign Up',
  76 + textAlign: TextAlign.center,
  77 + style: TextStyle(
  78 + color: const Color(0xFF0F0F11),
  79 + fontSize: 24,
  80 + fontWeight: FontWeight.w600,
  81 + ),
  82 + ),
  83 + ),
  84 +
  85 + Text(
  86 + 'Enter your email and password',
  87 + textAlign: TextAlign.center,
  88 + style: TextStyle(
  89 + color: const Color(0xFF78787D),
  90 + fontSize: 14,
  91 + fontWeight: FontWeight.w400,
  92 + ),
  93 + ),
  94 + const SizedBox(height: 32),
  95 +
  96 + // 输入区
  97 + Padding(
  98 + padding: const EdgeInsets.symmetric(horizontal: 28),
  99 + child: Column(
  100 + crossAxisAlignment: CrossAxisAlignment.stretch,
  101 + children: [
  102 + // email输入框
  103 + Obx(() {
  104 + final emailNotEmpty =
  105 + controller.emailInput.value.isNotEmpty;
  106 + return TextField(
  107 + controller: controller.emailController,
  108 + keyboardType: TextInputType.emailAddress,
  109 + // maxLength: 13,
  110 + contextMenuBuilder: (BuildContext context,
  111 + EditableTextState editableTextState) {
  112 + // 获取默认的菜单项列表
  113 + List<ContextMenuButtonItem> buttonItems =
  114 + editableTextState.contextMenuButtonItems;
  115 +
  116 + // 例如:只保留“粘贴” (Paste)
  117 + buttonItems.removeWhere((item) =>
  118 + item.type != ContextMenuButtonType.paste);
  119 + return AdaptiveTextSelectionToolbar.buttonItems(
  120 + anchors: editableTextState.contextMenuAnchors,
  121 + buttonItems: buttonItems,
  122 + );
  123 + },
  124 + inputFormatters: [
  125 + FilteringTextInputFormatter.deny(RegExp(r'\s')),
  126 + FilteringTextInputFormatter.allow(
  127 + RegExp(r'[a-zA-Z0-9@._\-+]'),
  128 + ),
  129 + ],
  130 + style: TextStyle(
  131 + color: context.colors.textPrimary,
  132 + fontSize: 16,
  133 + fontWeight: FontWeight.w400,
  134 + ),
  135 + decoration: InputDecoration(
  136 + hintText: l10n.emailLoginEmailHint,
  137 + hintStyle: TextStyle(
  138 + color: context.colors.textTertiary,
  139 + fontSize: 16,
  140 + ),
  141 + counterText: '',
  142 + filled: true,
  143 + fillColor: Color(0xFFF3F3F3),
  144 + contentPadding: const EdgeInsets.symmetric(
  145 + horizontal: 20,
  146 + vertical: 16,
  147 + ),
  148 + border: OutlineInputBorder(
  149 + borderRadius: BorderRadius.circular(27),
  150 + borderSide: BorderSide.none,
  151 + ),
  152 + enabledBorder: OutlineInputBorder(
  153 + borderRadius: BorderRadius.circular(27),
  154 + borderSide: BorderSide.none,
  155 + ),
  156 + focusedBorder: OutlineInputBorder(
  157 + borderRadius: BorderRadius.circular(27),
  158 + borderSide: BorderSide(
  159 + color: context.colors.primary,
  160 + width: 1,
  161 + ),
  162 + ),
  163 + suffixIcon: emailNotEmpty
  164 + ? GestureDetector(
  165 + onTap: controller.emailController.clear,
  166 + child: Icon(
  167 + Icons.cancel,
  168 + color: context.colors.textTertiary,
  169 + size: 18,
  170 + ),
  171 + )
  172 + : null,
  173 + ),
  174 + );
  175 + }),
  176 +
  177 + SizedBox(
  178 + height: 20,
  179 + child: Obx(() {
  180 + final hasTried =
  181 + controller.hasTriedEmailLogin.value;
  182 + final emailOk = controller.isEmailValid;
  183 + final showError = hasTried && !emailOk;
  184 + return Visibility(
  185 + visible: showError,
  186 + maintainSize: true,
  187 + maintainAnimation: true,
  188 + maintainState: true,
  189 + child: Padding(
  190 + padding: const EdgeInsets.only(left: 8),
  191 + child: Text(
  192 + 'Invalid email format',
  193 + style: TextStyle(
  194 + color: Colors.red,
  195 + fontSize: 12,
  196 + fontWeight: FontWeight.w400,
  197 + ),
  198 + ),
  199 + ),
  200 + );
  201 + }),
  202 + ),
  203 + Obx(() {
  204 + final isObscured =
  205 + controller.isPasswordObscured.value;
  206 + return TextField(
  207 + controller: controller.passwordController,
  208 + keyboardType: TextInputType.visiblePassword,
  209 + obscureText: isObscured,
  210 + // maxLength: 13,
  211 + contextMenuBuilder: (BuildContext context,
  212 + EditableTextState editableTextState) {
  213 + // 获取默认的菜单项列表
  214 + List<ContextMenuButtonItem> buttonItems =
  215 + editableTextState.contextMenuButtonItems;
  216 +
  217 + // 例如:只保留"粘贴" (Paste)
  218 + buttonItems.removeWhere((item) =>
  219 + item.type != ContextMenuButtonType.paste);
  220 + return AdaptiveTextSelectionToolbar.buttonItems(
  221 + anchors: editableTextState.contextMenuAnchors,
  222 + buttonItems: buttonItems,
  223 + );
  224 + },
  225 + inputFormatters: [
  226 + FilteringTextInputFormatter.deny(RegExp(r'\s')),
  227 + FilteringTextInputFormatter.allow(
  228 + RegExp(r'[\x21-\x7E]'),
  229 + ),
  230 + ],
  231 + style: TextStyle(
  232 + color: context.colors.textPrimary,
  233 + fontSize: 16,
  234 + fontWeight: FontWeight.w400,
  235 + ),
  236 + decoration: InputDecoration(
  237 + hintText: l10n.emailLoginYourPassword,
  238 + hintStyle: TextStyle(
  239 + color: context.colors.textTertiary,
  240 + fontSize: 16,
  241 + ),
  242 + counterText: '',
  243 + filled: true,
  244 + fillColor: Color(0xFFF3F3F3),
  245 + contentPadding: const EdgeInsets.symmetric(
  246 + horizontal: 20,
  247 + vertical: 16,
  248 + ),
  249 + border: OutlineInputBorder(
  250 + borderRadius: BorderRadius.circular(27),
  251 + borderSide: BorderSide.none,
  252 + ),
  253 + enabledBorder: OutlineInputBorder(
  254 + borderRadius: BorderRadius.circular(27),
  255 + borderSide: BorderSide.none,
  256 + ),
  257 + focusedBorder: OutlineInputBorder(
  258 + borderRadius: BorderRadius.circular(27),
  259 + borderSide: BorderSide(
  260 + color: context.colors.primary,
  261 + width: 1,
  262 + ),
  263 + ),
  264 + suffixIconConstraints: const BoxConstraints(
  265 + minWidth: 24,
  266 + minHeight: 24,
  267 + ),
  268 + suffixIcon: GestureDetector(
  269 + onTap: controller.togglePasswordVisibility,
  270 + child: Padding(
  271 + padding: const EdgeInsets.only(right: 16),
  272 + child: Image.asset(
  273 + isObscured
  274 + ? 'assets/images/common/ic_hide_password.png'
  275 + : 'assets/images/common/ic_show_password.webp',
  276 + color: context.colors.textTertiary,
  277 + width: 24,
  278 + height: 24,
  279 + ),
  280 + ),
  281 + )));
  282 + }),
  283 +
  284 + const SizedBox(height: 8),
  285 +
  286 + // 密码格式提示
  287 + Obx(() {
  288 + final hasTried = controller.hasTriedEmailLogin.value;
  289 + final pwOk = controller.isPasswordValid;
  290 + final isError = hasTried && !pwOk;
  291 + return Text(
  292 + 'Password must be at least 6 characters and include 1 number and 1 uppercase letter.',
  293 + style: TextStyle(
  294 + color: isError
  295 + ? Colors.red
  296 + : context.colors.textTertiary,
  297 + fontSize: 12,
  298 + fontWeight: FontWeight.w400,
  299 + ),
  300 + textAlign: TextAlign.center,
  301 + );
  302 + }),
  303 +
  304 + const SizedBox(height: 24),
  305 + Center(
  306 + child: GestureDetector(
  307 + onTap: () {
  308 + Get.to(() => const EmailLoginResetPasswordView());
  309 + },
  310 + child: Text(
  311 + 'Forgot password?',
  312 + style: TextStyle(
  313 + color: const Color(0xFF7B9BFB),
  314 + fontSize: 14,
  315 + fontWeight: FontWeight.w500,
  316 + decoration: TextDecoration.underline,
  317 + decorationColor: const Color(0xFF7B9BFB),
  318 + ),
  319 + ),
  320 + ),
  321 + ),
  322 + const SizedBox(height: 48),
  323 +
  324 + // Continue 按钮
  325 + Obx(() {
  326 + final isLoggingIn = controller.isLoggingIn.value;
  327 + final canProceed = controller.isPasswordValid &&
  328 + controller.isEmailValid;
  329 + return SizedBox(
  330 + height: 48,
  331 + child: ElevatedButton(
  332 + onPressed: (!isLoggingIn && canProceed)
  333 + ? controller.emailLogin
  334 + : null,
  335 + style: ElevatedButton.styleFrom(
  336 + backgroundColor: context.colors.primary,
  337 + foregroundColor: Colors.white,
  338 + disabledBackgroundColor: context.colors.primary
  339 + .withValues(alpha: 0.4),
  340 + disabledForegroundColor: Colors.white,
  341 + elevation: 0,
  342 + shape: RoundedRectangleBorder(
  343 + borderRadius: BorderRadius.circular(24),
  344 + ),
  345 + textStyle: const TextStyle(
  346 + fontSize: 16,
  347 + fontWeight: FontWeight.w600,
  348 + ),
  349 + ),
  350 + child: isLoggingIn
  351 + ? Row(
  352 + mainAxisAlignment:
  353 + MainAxisAlignment.center,
  354 + children: [
  355 + const _SpinningLoader(),
  356 + const SizedBox(width: 8),
  357 + Text(l10n.phoneLoginLoggingIn),
  358 + ],
  359 + )
  360 + : Text(l10n.continueButton),
  361 + ),
  362 + );
  363 + }),
  364 + ],
  365 + ),
  366 + ),
  367 + ],
  368 + ),
  369 + ),
  370 + Positioned(
  371 + top: MediaQuery.paddingOf(context).top,
  372 + left: 0,
  373 + child: IconButton(
  374 + highlightColor: Colors.transparent,
  375 + splashColor: Colors.transparent,
  376 + padding: EdgeInsets.zero,
  377 + onPressed: Get.back,
  378 + icon: Image.asset(
  379 + 'assets/images/common/ic_nav_back.webp',
  380 + width: 24,
  381 + height: 24,
  382 + )),
  383 + ),
  384 + ],
  385 + ),
  386 + ),
  387 + );
  388 + }
  389 +}
  390 +
  391 +// ── 旋转 Loading 图标 ────────────────────────────────────────────
  392 +class _SpinningLoader extends StatefulWidget {
  393 + const _SpinningLoader();
  394 +
  395 + @override
  396 + State<_SpinningLoader> createState() => _SpinningLoaderState();
  397 +}
  398 +
  399 +class _SpinningLoaderState extends State<_SpinningLoader>
  400 + with SingleTickerProviderStateMixin {
  401 + late final AnimationController _ctrl;
  402 +
  403 + @override
  404 + void initState() {
  405 + super.initState();
  406 + _ctrl = AnimationController(
  407 + vsync: this,
  408 + duration: const Duration(milliseconds: 900),
  409 + )..repeat();
  410 + }
  411 +
  412 + @override
  413 + void dispose() {
  414 + _ctrl.dispose();
  415 + super.dispose();
  416 + }
  417 +
  418 + @override
  419 + Widget build(BuildContext context) {
  420 + return RotationTransition(
  421 + turns: _ctrl,
  422 + child: Image.asset(
  423 + 'assets/images/common/ic_loading_ios_style.png',
  424 + width: 24,
  425 + height: 24,
  426 + ),
  427 + );
  428 + }
  429 +}
  430 +
  431 +// ── 手机号格式化 ───────────────────────────────────────────────────
  432 +class _PhoneTextInputFormatter extends TextInputFormatter {
  433 + @override
  434 + TextEditingValue formatEditUpdate(
  435 + TextEditingValue oldValue,
  436 + TextEditingValue newValue,
  437 + ) {
  438 + final text = newValue.text.replaceAll(' ', '');
  439 + if (text.isEmpty) {
  440 + return newValue;
  441 + }
  442 +
  443 + final buffer = StringBuffer();
  444 + for (int i = 0; i < text.length; i++) {
  445 + buffer.write(text[i]);
  446 + final nonLeadingIndex = i + 1;
  447 + if (nonLeadingIndex == 3 || nonLeadingIndex == 7) {
  448 + if (nonLeadingIndex < text.length) {
  449 + buffer.write(' ');
  450 + }
  451 + }
  452 + }
  453 +
  454 + final string = buffer.toString();
  455 + return newValue.copyWith(
  456 + text: string,
  457 + selection: TextSelection.collapsed(offset: string.length),
  458 + );
  459 + }
  460 +}
@@ -83,97 +83,142 @@ class LoginView extends GetView<LoginController> { @@ -83,97 +83,142 @@ class LoginView extends GetView<LoginController> {
83 height: 42, 83 height: 42,
84 ), 84 ),
85 SizedBox(height: 8), 85 SizedBox(height: 8),
86 - Text(  
87 - l10n.loginSlogan,  
88 - textAlign: TextAlign.center,  
89 - style: TextStyle(  
90 - color: context.colors.textSecondary,  
91 - fontSize: 12,  
92 - fontWeight: FontWeight.w400,  
93 - height: 1.50,  
94 - letterSpacing: 6, 86 + Padding(
  87 + padding: EdgeInsets.symmetric(
  88 + horizontal:
  89 + controller.environmentConfig.region.value ==
  90 + AppRegion.china
  91 + ? 0
  92 + : 40),
  93 + child: Text(
  94 + l10n.loginSlogan,
  95 + textAlign: TextAlign.center,
  96 + style: TextStyle(
  97 + color: context.colors.textSecondary,
  98 + fontSize: 12,
  99 + fontWeight: FontWeight.w400,
  100 + height: 1.50,
  101 + letterSpacing:
  102 + controller.environmentConfig.region.value ==
  103 + AppRegion.china
  104 + ? 6
  105 + : 1,
  106 + ),
95 ), 107 ),
96 ), 108 ),
97 ], 109 ],
98 ), 110 ),
99 ), 111 ),
100 112
101 - // Phone login button  
102 - Positioned(  
103 - bottom: bottom(145),  
104 - left: 0,  
105 - right: 0,  
106 - child: Center(  
107 - child: _LoginButton(  
108 - width: buttonWidth,  
109 - label: l10n.loginWithPhone,  
110 - onPressed: controller.onPhoneLoginPressed,  
111 - ),  
112 - ),  
113 - ),  
114 -  
115 - // Apple login button 113 + // Login buttons – add new methods here as Column children
116 Positioned( 114 Positioned(
117 bottom: bottom(85), 115 bottom: bottom(85),
118 left: 0, 116 left: 0,
119 right: 0, 117 right: 0,
120 child: Center( 118 child: Center(
121 - child: _LoginButton(  
122 - width: buttonWidth,  
123 - label: l10n.loginWithApple,  
124 - onPressed: controller.onAppleLoginPressed,  
125 - icon: Image.asset(  
126 - 'assets/images/common/ic_apple.png',  
127 - width: 24,  
128 - height: 24,  
129 - color: context.colors.textPrimary,  
130 - ),  
131 - buttonStyle: ElevatedButton.styleFrom(  
132 - backgroundColor: Colors.white,  
133 - foregroundColor: context.colors.textPrimary,  
134 - disabledBackgroundColor:  
135 - context.colors.textPrimary.withValues(alpha: 0.5),  
136 - disabledForegroundColor: context.colors.textPrimary,  
137 - elevation: 0,  
138 - shadowColor: Colors.transparent,  
139 - padding: EdgeInsets.symmetric(horizontal: 12),  
140 - shape: RoundedRectangleBorder(  
141 - borderRadius: BorderRadius.circular(24),  
142 - ),  
143 - textStyle: const TextStyle(  
144 - fontSize: 16,  
145 - fontWeight: FontWeight.w700, 119 + child: Column(
  120 + mainAxisSize: MainAxisSize.min,
  121 + children: [
  122 + if (controller.environmentConfig.region.value ==
  123 + AppRegion.china) ...[
  124 + _LoginMethodButton(
  125 + width: buttonWidth,
  126 + label: l10n.loginWithPhone,
  127 + onPressed: controller.onPhoneLoginPressed,
  128 + isLastUsed:
  129 + Get.find<LocalStorage>().lastLoginMethod ==
  130 + 'phone',
  131 + lastUsedLabel: l10n.loginLastUsed,
  132 + ),
  133 + const SizedBox(height: 12)
  134 + ],
  135 + if (controller.environmentConfig.region.value ==
  136 + AppRegion.global) ...[
  137 + _LoginMethodButton(
  138 + width: buttonWidth,
  139 + label: l10n.continueWithEmail,
  140 + onPressed: controller.onEmailLoginPressed,
  141 + isLastUsed:
  142 + Get.find<LocalStorage>().lastLoginMethod ==
  143 + 'email',
  144 + lastUsedLabel: l10n.loginLastUsed,
  145 + ),
  146 + const SizedBox(height: 12)
  147 + ],
  148 + if (controller.environmentConfig.region.value ==
  149 + AppRegion.global) ...[
  150 + _LoginMethodButton(
  151 + width: buttonWidth,
  152 + label: l10n.continueWithGoogle,
  153 + onPressed: controller.onGoogleLoginPressed,
  154 + isLastUsed:
  155 + Get.find<LocalStorage>().lastLoginMethod ==
  156 + 'google',
  157 + lastUsedLabel: l10n.loginLastUsed,
  158 + icon: Image.asset(
  159 + 'assets/images/common/ic_google.png',
  160 + width: 20,
  161 + height: 20,
  162 + ),
  163 + buttonStyle: ElevatedButton.styleFrom(
  164 + backgroundColor: Colors.white,
  165 + foregroundColor: context.colors.textPrimary,
  166 + disabledBackgroundColor: context
  167 + .colors.textPrimary
  168 + .withValues(alpha: 0.5),
  169 + disabledForegroundColor:
  170 + context.colors.textPrimary,
  171 + elevation: 0,
  172 + shadowColor: Colors.transparent,
  173 + padding:
  174 + const EdgeInsets.symmetric(horizontal: 12),
  175 + shape: RoundedRectangleBorder(
  176 + borderRadius: BorderRadius.circular(24),
  177 + ),
  178 + textStyle: const TextStyle(
  179 + fontSize: 16,
  180 + fontWeight: FontWeight.w700,
  181 + ),
  182 + ),
  183 + ),
  184 + const SizedBox(height: 12)
  185 + ],
  186 + _LoginMethodButton(
  187 + width: buttonWidth,
  188 + label: l10n.loginWithApple,
  189 + onPressed: controller.onAppleLoginPressed,
  190 + isLastUsed:
  191 + Get.find<LocalStorage>().lastLoginMethod ==
  192 + 'apple',
  193 + lastUsedLabel: l10n.loginLastUsed,
  194 + icon: Image.asset(
  195 + 'assets/images/common/ic_apple.png',
  196 + width: 24,
  197 + height: 24,
  198 + color: context.colors.textPrimary,
  199 + ),
  200 + buttonStyle: ElevatedButton.styleFrom(
  201 + backgroundColor: Colors.white,
  202 + foregroundColor: context.colors.textPrimary,
  203 + disabledBackgroundColor: context.colors.textPrimary
  204 + .withValues(alpha: 0.5),
  205 + disabledForegroundColor: context.colors.textPrimary,
  206 + elevation: 0,
  207 + shadowColor: Colors.transparent,
  208 + padding: const EdgeInsets.symmetric(horizontal: 12),
  209 + shape: RoundedRectangleBorder(
  210 + borderRadius: BorderRadius.circular(24),
  211 + ),
  212 + textStyle: const TextStyle(
  213 + fontSize: 16,
  214 + fontWeight: FontWeight.w700,
  215 + ),
  216 + ),
146 ), 217 ),
147 - ), 218 + ],
148 ), 219 ),
149 ), 220 ),
150 ), 221 ),
151 - if (Get.find<LocalStorage>().lastLoginMethod.isNotEmpty)  
152 - Positioned(  
153 - right: rightOffset - 4,  
154 - bottom: Get.find<LocalStorage>().lastLoginMethod == 'apple'  
155 - ? bottom(117)  
156 - : bottom(188),  
157 - child: Container(  
158 - padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),  
159 - decoration: ShapeDecoration(  
160 - color: const Color(0xFFFF9A6E),  
161 - shape: RoundedRectangleBorder(  
162 - side: BorderSide(width: 1, color: Colors.white),  
163 - borderRadius: BorderRadius.circular(25),  
164 - ),  
165 - ),  
166 - child: Text(  
167 - l10n.loginLastUsed,  
168 - textAlign: TextAlign.center,  
169 - style: TextStyle(  
170 - color: Colors.white,  
171 - fontSize: 11,  
172 - fontWeight: FontWeight.w500,  
173 - ),  
174 - ),  
175 - ),  
176 - ),  
177 // Terms agreement text + checkbox 222 // Terms agreement text + checkbox
178 Positioned( 223 Positioned(
179 bottom: bottom(0), 224 bottom: bottom(0),
@@ -215,6 +260,67 @@ class LoginView extends GetView<LoginController> { @@ -215,6 +260,67 @@ class LoginView extends GetView<LoginController> {
215 } 260 }
216 } 261 }
217 262
  263 +/// A login button that optionally shows a "last used" badge at its top-right.
  264 +class _LoginMethodButton extends StatelessWidget {
  265 + const _LoginMethodButton({
  266 + required this.width,
  267 + required this.label,
  268 + required this.onPressed,
  269 + required this.isLastUsed,
  270 + required this.lastUsedLabel,
  271 + this.icon,
  272 + this.buttonStyle,
  273 + });
  274 +
  275 + final double width;
  276 + final String label;
  277 + final VoidCallback onPressed;
  278 + final bool isLastUsed;
  279 + final String lastUsedLabel;
  280 + final Image? icon;
  281 + final ButtonStyle? buttonStyle;
  282 +
  283 + @override
  284 + Widget build(BuildContext context) {
  285 + return Stack(
  286 + clipBehavior: Clip.none,
  287 + children: [
  288 + _LoginButton(
  289 + width: width,
  290 + label: label,
  291 + onPressed: onPressed,
  292 + icon: icon,
  293 + buttonStyle: buttonStyle,
  294 + ),
  295 + if (isLastUsed)
  296 + Positioned(
  297 + right: -4,
  298 + top: -8,
  299 + child: Container(
  300 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  301 + decoration: ShapeDecoration(
  302 + color: const Color(0xFFFF9A6E),
  303 + shape: RoundedRectangleBorder(
  304 + side: const BorderSide(width: 1, color: Colors.white),
  305 + borderRadius: BorderRadius.circular(25),
  306 + ),
  307 + ),
  308 + child: Text(
  309 + lastUsedLabel,
  310 + textAlign: TextAlign.center,
  311 + style: const TextStyle(
  312 + color: Colors.white,
  313 + fontSize: 11,
  314 + fontWeight: FontWeight.w500,
  315 + ),
  316 + ),
  317 + ),
  318 + ),
  319 + ],
  320 + );
  321 + }
  322 +}
  323 +
218 class _LoginButton extends StatelessWidget { 324 class _LoginButton extends StatelessWidget {
219 const _LoginButton({ 325 const _LoginButton({
220 required this.width, 326 required this.width,
@@ -311,21 +417,22 @@ class _AgreementText extends GetView<LoginController> { @@ -311,21 +417,22 @@ class _AgreementText extends GetView<LoginController> {
311 TextSpan( 417 TextSpan(
312 style: baseStyle, 418 style: baseStyle,
313 children: [ 419 children: [
314 - WidgetSpan(  
315 - alignment: PlaceholderAlignment.middle,  
316 - child: Obx(() {  
317 - final isChecked = controller.termsChecked.value;  
318 - return Icon(  
319 - isChecked  
320 - ? Icons.check_circle_rounded  
321 - : Icons.radio_button_unchecked_rounded,  
322 - color: isChecked  
323 - ? context.colors.primary  
324 - : context.colors.textSecondary,  
325 - size: 18,  
326 - );  
327 - }),  
328 - ), 420 + if (controller.environmentConfig.region.value == AppRegion.china)
  421 + WidgetSpan(
  422 + alignment: PlaceholderAlignment.middle,
  423 + child: Obx(() {
  424 + final isChecked = controller.termsChecked.value;
  425 + return Icon(
  426 + isChecked
  427 + ? Icons.check_circle_rounded
  428 + : Icons.radio_button_unchecked_rounded,
  429 + color: isChecked
  430 + ? context.colors.primary
  431 + : context.colors.textSecondary,
  432 + size: 18,
  433 + );
  434 + }),
  435 + ),
329 const WidgetSpan( 436 const WidgetSpan(
330 child: SizedBox(width: 8), 437 child: SizedBox(width: 8),
331 ), 438 ),
1 import 'dart:async'; 1 import 'dart:async';
2 2
  3 +import 'package:doublefeel_flutter/app/utils/assets_helper.dart';
3 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 4 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 import 'package:flutter/material.dart'; 6 import 'package:flutter/material.dart';
@@ -138,7 +139,8 @@ class _IntroPage extends StatelessWidget { @@ -138,7 +139,8 @@ class _IntroPage extends StatelessWidget {
138 OnboardingTitleText(title), 139 OnboardingTitleText(title),
139 const SizedBox(height: 12), 140 const SizedBox(height: 12),
140 Image.asset( 141 Image.asset(
141 - 'assets/images/user_onboarding/bg_onboarding_watch.png', 142 + assetCompatPath(
  143 + 'assets/images/user_onboarding/bg_onboarding_watch.png'),
142 height: Get.width / 375 * 384, 144 height: Get.width / 375 * 384,
143 width: Get.width, 145 width: Get.width,
144 fit: BoxFit.cover, 146 fit: BoxFit.cover,
@@ -641,7 +643,8 @@ class _NotificationPermissionPage extends StatelessWidget { @@ -641,7 +643,8 @@ class _NotificationPermissionPage extends StatelessWidget {
641 ), 643 ),
642 const SizedBox(height: 20), 644 const SizedBox(height: 20),
643 Image.asset( 645 Image.asset(
644 - 'assets/images/user_onboarding/bg_onboarding_notification.png', 646 + assetCompatPath(
  647 + 'assets/images/user_onboarding/bg_onboarding_notification.png'),
645 height: 383, 648 height: 383,
646 fit: BoxFit.fitHeight, 649 fit: BoxFit.fitHeight,
647 ), 650 ),
1 import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart'; 1 import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
2 import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart'; 2 import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart';
  3 +import 'package:doublefeel_flutter/app/modules/login/views/email_login_view.dart';
3 import 'package:doublefeel_flutter/app/modules/membership_offer/bindings/membership_detail_binding.dart'; 4 import 'package:doublefeel_flutter/app/modules/membership_offer/bindings/membership_detail_binding.dart';
4 import 'package:doublefeel_flutter/app/modules/membership_offer/views/membership_detail_view.dart'; 5 import 'package:doublefeel_flutter/app/modules/membership_offer/views/membership_detail_view.dart';
5 import 'package:doublefeel_flutter/core/config/app_environment_config.dart'; 6 import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
@@ -88,7 +89,6 @@ abstract final class AppPages { @@ -88,7 +89,6 @@ abstract final class AppPages {
88 GetPage( 89 GetPage(
89 name: AppRoutes.phoneLogin, 90 name: AppRoutes.phoneLogin,
90 page: () => const PhoneLoginView(), 91 page: () => const PhoneLoginView(),
91 - binding: LoginBinding(),  
92 ), 92 ),
93 if (environmentConfig.isDebug) 93 if (environmentConfig.isDebug)
94 GetPage( 94 GetPage(
  1 +// lib/core/utils/asset_helper.dart
  2 +import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
  3 +import 'package:get/get_core/src/get_main.dart';
  4 +import 'package:get/get_instance/src/extension_instance.dart';
  5 +
  6 +String assetCompatPath(String basePath) {
  7 + if (!basePath.contains('/')) return basePath;
  8 + if (Get.isRegistered<AppEnvironmentConfig>()) {
  9 + final region = Get.find<AppEnvironmentConfig>().region.value;
  10 + if (region == AppRegion.global) return basePath;
  11 + } else {
  12 + return basePath;
  13 + }
  14 + final parts = basePath.split('/');
  15 + parts.insert(parts.length - 1, 'zh');
  16 + return parts.join('/');
  17 +}
@@ -28,6 +28,9 @@ class AppEnvironmentConfig { @@ -28,6 +28,9 @@ class AppEnvironmentConfig {
28 } else { 28 } else {
29 region.value = stored; 29 region.value = stored;
30 } 30 }
  31 + if (stored == AppRegion.global) {
  32 + await _storage.setTermsAgreed(true);
  33 + }
31 return this; 34 return this;
32 } 35 }
33 36
@@ -784,5 +784,9 @@ @@ -784,5 +784,9 @@
784 "type": "String" 784 "type": "String"
785 } 785 }
786 } 786 }
787 - } 787 + },
  788 + "continueWithEmail": "Continue with email",
  789 + "continueWithGoogle": "Continue with Google",
  790 + "emailLoginEmailHint": "Your email",
  791 + "emailLoginYourPassword": "Your password"
788 } 792 }
@@ -1177,5 +1177,9 @@ @@ -1177,5 +1177,9 @@
1177 "type": "String" 1177 "type": "String"
1178 } 1178 }
1179 } 1179 }
1180 - } 1180 + },
  1181 + "continueWithEmail": "使用电子邮件",
  1182 + "continueWithGoogle": "使用 Google",
  1183 + "emailLoginEmailHint": "请输入邮箱",
  1184 + "emailLoginYourPassword": "请输入密码"
1181 } 1185 }
@@ -4179,6 +4179,30 @@ abstract class AppLocalizations { @@ -4179,6 +4179,30 @@ abstract class AppLocalizations {
4179 /// In zh, this message translates to: 4179 /// In zh, this message translates to:
4180 /// **'原价{price}/年'** 4180 /// **'原价{price}/年'**
4181 String originalPricePerYear(String price); 4181 String originalPricePerYear(String price);
  4182 +
  4183 + /// No description provided for @continueWithEmail.
  4184 + ///
  4185 + /// In zh, this message translates to:
  4186 + /// **'使用电子邮件'**
  4187 + String get continueWithEmail;
  4188 +
  4189 + /// No description provided for @continueWithGoogle.
  4190 + ///
  4191 + /// In zh, this message translates to:
  4192 + /// **'使用 Google'**
  4193 + String get continueWithGoogle;
  4194 +
  4195 + /// No description provided for @emailLoginEmailHint.
  4196 + ///
  4197 + /// In zh, this message translates to:
  4198 + /// **'请输入邮箱'**
  4199 + String get emailLoginEmailHint;
  4200 +
  4201 + /// No description provided for @emailLoginYourPassword.
  4202 + ///
  4203 + /// In zh, this message translates to:
  4204 + /// **'请输入密码'**
  4205 + String get emailLoginYourPassword;
4182 } 4206 }
4183 4207
4184 class _AppLocalizationsDelegate 4208 class _AppLocalizationsDelegate
@@ -2348,4 +2348,16 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2348,4 +2348,16 @@ class AppLocalizationsEn extends AppLocalizations {
2348 String originalPricePerYear(String price) { 2348 String originalPricePerYear(String price) {
2349 return 'Was: $price/year'; 2349 return 'Was: $price/year';
2350 } 2350 }
  2351 +
  2352 + @override
  2353 + String get continueWithEmail => 'Continue with email';
  2354 +
  2355 + @override
  2356 + String get continueWithGoogle => 'Continue with Google';
  2357 +
  2358 + @override
  2359 + String get emailLoginEmailHint => 'Your email';
  2360 +
  2361 + @override
  2362 + String get emailLoginYourPassword => 'Your password';
2351 } 2363 }
@@ -2249,4 +2249,16 @@ class AppLocalizationsZh extends AppLocalizations { @@ -2249,4 +2249,16 @@ class AppLocalizationsZh extends AppLocalizations {
2249 String originalPricePerYear(String price) { 2249 String originalPricePerYear(String price) {
2250 return '原价$price/年'; 2250 return '原价$price/年';
2251 } 2251 }
  2252 +
  2253 + @override
  2254 + String get continueWithEmail => '使用电子邮件';
  2255 +
  2256 + @override
  2257 + String get continueWithGoogle => '使用 Google';
  2258 +
  2259 + @override
  2260 + String get emailLoginEmailHint => '请输入邮箱';
  2261 +
  2262 + @override
  2263 + String get emailLoginYourPassword => '请输入密码';
2252 } 2264 }
@@ -97,9 +97,15 @@ flutter: @@ -97,9 +97,15 @@ flutter:
97 - assets/images/common/ 97 - assets/images/common/
98 - assets/images/tabbar/ 98 - assets/images/tabbar/
99 - assets/images/today/ 99 - assets/images/today/
  100 + - assets/images/today/zh/
100 - assets/images/friends/ 101 - assets/images/friends/
101 - assets/images/my/ 102 - assets/images/my/
  103 + - assets/images/my/zh/
102 - assets/images/permission/ 104 - assets/images/permission/
  105 + - assets/images/permission/zh/
103 - assets/images/watch_theme/ 106 - assets/images/watch_theme/
  107 + - assets/images/watch_theme/zh/
104 - assets/images/user_onboarding/ 108 - assets/images/user_onboarding/
  109 + - assets/images/user_onboarding/zh/
105 - assets/images/premium/ 110 - assets/images/premium/
  111 + - assets/images/premium/zh/