Showing
43 changed files
with
1791 additions
and
105 deletions
assets/images/common/ic_contact_us_copy.png
0 → 100644
465 Bytes
390 Bytes
assets/images/common/ic_google.png
0 → 100644
1.26 KB
assets/images/common/ic_hide_password.png
0 → 100644
908 Bytes
assets/images/common/ic_show_password.webp
0 → 100644
No preview for this file type
assets/images/my/zh/bg_my_unlock_vip.png
0 → 100644
10.1 KB
5.13 KB
9.28 KB
10.9 KB
9.96 KB
assets/images/today/zh/hrv_measure_watch.png
0 → 100644
18 KB
5.66 KB
48.3 KB
43 KB
| @@ -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( |
lib/app/utils/assets_helper.dart
0 → 100644
| 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/ |
-
Please register or login to post a comment