Commit 1257980d authored by DatHV's avatar DatHV
Browse files

update screen otp, login

parent abd9f02e
...@@ -4,4 +4,5 @@ ...@@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
</manifest> </manifest>
...@@ -4,4 +4,5 @@ ...@@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
</manifest> </manifest>
...@@ -45,5 +45,9 @@ ...@@ -45,5 +45,9 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSFaceIDUsageDescription</key>
<string>We need Face ID to authenticate your identity</string>
<key>NSLocalAuthenticationUseFaceID</key>
<string>true</string>
</dict> </dict>
</plist> </plist>
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/splash_screen/splash_screen.dart'; import 'package:mypoint_flutter_app/screen/onboarding/onboarding_view_model.dart';
import 'onboading/onboarding_screen.dart'; import 'package:mypoint_flutter_app/screen/splash/splash_screen.dart';
import 'onboading/onboarding_viewmodel.dart';
void main() { void main() {
Get.put(OnboardingViewModel()); Get.put(OnboardingViewModel());
......
...@@ -5,8 +5,8 @@ import 'package:mypoint_flutter_app/extensions/string_extension.dart'; ...@@ -5,8 +5,8 @@ import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/networking/restful_api.dart'; import 'package:mypoint_flutter_app/networking/restful_api.dart';
import '../configs/device_info.dart'; import '../configs/device_info.dart';
import '../model/update_response_object.dart'; import '../model/update_response_object.dart';
import '../onboading/model/check_phone_response_model.dart'; import '../screen/onboarding/model/check_phone_response_model.dart';
import '../onboading/model/onboarding_info_model.dart'; import '../screen/onboarding/model/onboarding_info_model.dart';
import 'model_maker.dart'; import 'model_maker.dart';
extension RestfullAPIClientAllApi on RestfulAPIClient { extension RestfullAPIClientAllApi on RestfulAPIClient {
......
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:local_auth/local_auth.dart';
enum BiometricTypeEnum {
none,
fingerprint,
faceId,
}
class BiometricManager {
final LocalAuthentication _localAuth = LocalAuthentication();
/// Kiểm tra xem thiết bị hỗ trợ loại sinh trắc học nào: faceID, fingerprint, none
Future<BiometricTypeEnum> checkDeviceBiometric() async {
try {
final availableBiometrics = await _localAuth.getAvailableBiometrics();
if (availableBiometrics.contains(BiometricType.face)) {
return BiometricTypeEnum.faceId;
} else if (availableBiometrics.contains(BiometricType.fingerprint)) {
return BiometricTypeEnum.fingerprint;
}
return BiometricTypeEnum.none;
} catch (e) {
debugPrint("Lỗi checkDeviceBiometric: $e");
return BiometricTypeEnum.none;
}
}
/// Kiểm tra nhanh thiết bị có thể dùng sinh trắc học hay không
Future<bool> canCheckBiometrics() async {
try {
final canCheck = await _localAuth.canCheckBiometrics;
final isSupported = await _localAuth.isDeviceSupported();
return canCheck && isSupported;
} catch (e) {
debugPrint("Lỗi canCheckBiometrics/isDeviceSupported: $e");
return false;
}
}
/// Thực hiện xác thực bằng sinh trắc
/// - `localizedReason` là chuỗi yêu cầu xác thực hiển thị mặc định trên hệ thống
/// - Trả về true nếu user xác thực thành công, false nếu user huỷ hoặc thất bại
Future<bool> authenticateBiometric({String localizedReason = "Xác thực để đăng nhập"}) async {
try {
final didAuthenticate = await _localAuth.authenticate(
localizedReason: localizedReason,
options: const AuthenticationOptions(biometricOnly: true),
);
return didAuthenticate;
} catch (e) {
debugPrint("Lỗi authenticateBiometric: $e");
return false;
}
}
/// (Tuỳ chọn) Hiển thị trước một dialog hỏi "Có muốn xác thực bằng vân tay/FaceID hay không?"
/// Nếu user bấm "Đồng ý", mới gọi authenticateBiometric
Future<bool> showCustomBiometricDialog(
BuildContext context, {
String title = "Sử dụng sinh trắc học",
String content = "Bạn có muốn đăng nhập bằng vân tay/Face ID không?",
String confirmText = "Đồng ý",
String cancelText = "Huỷ",
}) async {
final result = await Get.dialog<bool>(
AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: Text(cancelText),
),
TextButton(
onPressed: () => Get.back(result: true),
child: Text(confirmText),
),
],
),
);
if (result == true) {
// Chỉ khi user chọn Đồng ý thì mới gọi authenticateBiometric
return await authenticateBiometric();
}
// Người dùng huỷ dialog => false
return false;
}
}
// login_screen.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../base/base_screen.dart';
import '../../base/basic_state.dart';
import '../../permission/biometric_manager.dart';
import '../../resouce/base_color.dart';
import 'login_view_model.dart';
class LoginScreen extends BaseScreen {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
final TextEditingController _phoneController = TextEditingController();
@override
Widget createBody() {
// Khởi tạo hoặc lấy LoginViewModel
final loginVM = Get.put(LoginViewModel());
return GestureDetector(
onTap: hideKeyboard,
child: Scaffold(
// Để nội dung nâng lên khi bàn phím xuất hiện
resizeToAvoidBottomInset: false,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.white,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios),
color: Colors.black,
onPressed: () => Navigator.pop(context),
),
actions: [
Container(
margin: const EdgeInsets.only(right: 16),
height: 36,
decoration: BoxDecoration(
border: Border.all(
color: BaseColor.second400,
width: 1,
),
borderRadius: BorderRadius.circular(18),
color: Colors.white,
),
child: TextButton.icon(
onPressed: () {
// Xử lý mở màn hình hỗ trợ hoặc gọi hotline...
},
icon: const Icon(Icons.headset_mic, size: 18, color: BaseColor.second600,),
label: const Text("Hỗ trợ"),
style: TextButton.styleFrom(
foregroundColor: BaseColor.second600,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
],
),
backgroundColor: Colors.white,
body: SafeArea(
child: Stack(
children: [
// Nội dung cuộn ở dưới
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"Đăng nhập",
style: TextStyle(color: BaseColor.second600, fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildWelcomeText(loginVM),
const SizedBox(height: 16),
_buildPasswordField(loginVM),
_buildErrorText(loginVM),
const SizedBox(height: 8),
_buildActionRow(loginVM),
const SizedBox(height: 8),
_buildBiometricSection(loginVM),
],
),
),
SizedBox.expand(),
Positioned(left: 0, right: 0, bottom: 16, child: _buildLoginButton(loginVM)),
],
),
),
),
);
}
Widget _buildWelcomeText(LoginViewModel vm) {
return Obx(() {
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 14, color: BaseColor.second500),
children: [
const TextSpan(text: "Chào mừng "),
TextSpan(text: "${vm.userName}"),
const TextSpan(text: " "),
TextSpan(
text: "${vm.phoneNumber}",
style: const TextStyle(fontWeight: FontWeight.w500, color: BaseColor.primary500),
),
],
),
);
});
}
Widget _buildPasswordField(LoginViewModel vm) {
return Obx(() {
return TextField(
controller: _phoneController,
keyboardType: TextInputType.number,
obscureText: !vm.isPasswordVisible.value,
onChanged: vm.onPasswordChanged,
decoration: InputDecoration(
hintText: "Nhập mật khẩu",
prefixIcon: const Icon(Icons.password, color: BaseColor.second500),
hintStyle: const TextStyle(color: BaseColor.second200),
fillColor: Colors.white,
filled: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.red), //BaseColor.second200),
),
suffixIcon: IconButton(
icon: Icon(
vm.isPasswordVisible.value ? Icons.visibility : Icons.visibility_off,
color: BaseColor.second500,
),
onPressed: vm.togglePasswordVisibility,
),
),
);
});
}
Widget _buildErrorText(LoginViewModel vm) {
return Obx(() {
if (vm.loginState.value == LoginState.error) {
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text("Sai mật khẩu, vui lòng thử lại!", style: TextStyle(color: BaseColor.primary400)),
);
}
return const SizedBox.shrink();
});
}
Widget _buildActionRow(LoginViewModel vm) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: vm.onChangePhonePressed,
child: const Text("Đổi số điện thoại", style: TextStyle(fontSize: 14, color: Color(0xFF3662FE))),
),
TextButton(
onPressed: vm.onForgotPassPressed,
child: const Text("Quên mật khẩu?", style: TextStyle(fontSize: 14, color: Color(0xFF3662FE))),
),
],
);
}
Widget _buildBiometricSection(LoginViewModel vm) {
return Obx(() {
// Nếu thiết bị không hỗ trợ => ẩn
// if (vm.biometricType.value == BiometricTypeEnum.none) {
// return const SizedBox.shrink();
// }
// Hiển thị 1 icon tuỳ loại
IconData icon = Icons.fingerprint;
String label = "Vân tay";
if (vm.biometricType.value == BiometricTypeEnum.faceId) {
icon = Icons.face;
label = "Face ID";
}
return Column(
children: [
IconButton(icon: Icon(icon, size: 36), onPressed: () => vm.onBiometricLoginPressed(Get.context!)),
Text("Đăng nhập bằng $label"),
],
);
});
}
Widget _buildLoginButton(LoginViewModel vm) {
return Obx(() {
bool enabled = false;
Color color = BaseColor.second400;
switch (vm.loginState.value) {
case LoginState.typing:
if (vm.password.value.isNotEmpty) {
color = BaseColor.primary500;
enabled = true;
} else {
enabled = false;
color = BaseColor.second400;
}
break;
case LoginState.done:
color = BaseColor.primary500;
enabled = true;
break;
case LoginState.error:
case LoginState.idle:
color = BaseColor.second400;
break;
}
return Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: color,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: enabled ? vm.onLoginPressed : null,
child: const Text(
"Đăng nhập",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
),
);
});
}
}
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import '../../base/restful_api_viewmodel.dart';
import '../../permission/biometric_manager.dart';
// login_state_enum.dart
enum LoginState {
idle,
typing,
done,
error,
}
class LoginViewModel extends RestfulApiViewModel {
final BiometricManager _biometricManager = BiometricManager();
var loginState = LoginState.idle.obs;
var isPasswordVisible = false.obs;
var password = "".obs;
// Giả lập userName và phoneNumber
final userName = "Phạm Duy Đức".obs;
final phoneNumber = "0987654321".obs;
// Loại sinh trắc học mà thiết bị hỗ trợ
var biometricType = BiometricTypeEnum.none.obs;
@override
void onInit() {
super.onInit();
_initBiometric();
}
Future<void> _initBiometric() async {
final type = await _biometricManager.checkDeviceBiometric();
biometricType.value = type;
}
// Kiểm tra thiết bị có cho phép check biometrics không
Future<bool> canUseBiometrics() async {
return _biometricManager.canCheckBiometrics();
}
void onPasswordChanged(String value) {
password.value = value;
if (value.isEmpty) {
loginState.value = LoginState.idle;
} else {
loginState.value = LoginState.typing;
}
}
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
void onLoginPressed() {
if (password.value.isEmpty) return;
// Ví dụ: Mật khẩu chuẩn là "123456"
if (password.value == "123456") {
loginState.value = LoginState.done;
debugPrint("Đăng nhập thành công!");
// TODO: Chuyển màn hình
} else {
loginState.value = LoginState.error;
debugPrint("Sai mật khẩu!");
}
}
void onChangePhonePressed() {
debugPrint("Người dùng chọn Đổi số điện thoại");
// TODO: Logic đổi SĐT hoặc chuyển sang màn hình khác
}
void onForgotPassPressed() {
debugPrint("Người dùng chọn Quên mật khẩu?");
// TODO: Logic quên mật khẩu, ví dụ chuyển sang màn hình recovery
}
/// Xác thực đăng nhập bằng sinh trắc
Future<void> onBiometricLoginPressed(BuildContext context) async {
// Kiểm tra thiết bị hỗ trợ
final canUse = await canUseBiometrics();
if (!canUse || biometricType.value == BiometricTypeEnum.none) {
Get.snackbar("Thông báo", "Thiết bị không hỗ trợ sinh trắc học",
snackPosition: SnackPosition.BOTTOM);
return;
}
// Tuỳ chọn: hiển thị dialog xác nhận trước khi gọi authenticate
final success = await _biometricManager.showCustomBiometricDialog(
context,
title: "Xác thực sinh trắc học",
content: (biometricType.value == BiometricTypeEnum.faceId)
? "Bạn có muốn đăng nhập bằng Face ID không?"
: "Bạn có muốn đăng nhập bằng vân tay không?",
confirmText: "Đồng ý",
cancelText: "Huỷ",
);
if (success) {
loginState.value = LoginState.done;
debugPrint("Đăng nhập bằng sinh trắc thành công!");
// TODO: Chuyển màn hình
} else {
debugPrint("Xác thực thất bại hoặc người dùng huỷ.");
}
}
}
...@@ -3,12 +3,15 @@ import 'package:flutter/services.dart'; ...@@ -3,12 +3,15 @@ import 'package:flutter/services.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; // Hiển thị HTML import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; // Hiển thị HTML
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/base_response_model.dart'; import 'package:mypoint_flutter_app/base/base_response_model.dart';
import '../base/base_screen.dart'; import '../../base/base_screen.dart';
import '../base/basic_state.dart'; import '../../base/basic_state.dart';
import '../configs/constants.dart'; import '../../configs/constants.dart';
import '../resouce/base_color.dart'; import '../../resouce/base_color.dart';
import '../login/login_screen.dart';
import '../otp/otp_screen.dart';
import '../signup/signup_otp_repository.dart';
import 'model/check_phone_response_model.dart'; import 'model/check_phone_response_model.dart';
import 'onboarding_viewmodel.dart'; import 'onboarding_view_model.dart';
class OnboardingScreen extends BaseScreen { class OnboardingScreen extends BaseScreen {
const OnboardingScreen({super.key}); const OnboardingScreen({super.key});
...@@ -27,8 +30,15 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState ...@@ -27,8 +30,15 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
super.initState(); super.initState();
_viewModel.fetchOnboardingContent(); _viewModel.fetchOnboardingContent();
_viewModel.checkPhoneRes.listen((response) { _viewModel.checkPhoneRes.listen((response) {
_handleResponseCheckPhoneNumber(response.data); WidgetsBinding.instance.addPostFrameCallback((_) {
_handleResponseError(response); hideKeyboard();
// Get.to(() => const LoginScreen());
Get.to(() => OtpScreen(
repository: SignUpOtpRepository(_viewModel.phoneNumber.value),
));
});
// _handleResponseCheckPhoneNumber(response.data);
// _handleResponseError(response);
}); });
} }
...@@ -64,14 +74,11 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState ...@@ -64,14 +74,11 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
} }
} }
void _hideKeyboard() {
FocusScope.of(Get.context!).unfocus();
}
@override @override
Widget createBody() { Widget createBody() {
return GestureDetector( final double keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
onTap: _hideKeyboard, return GestureDetector(
onTap: hideKeyboard,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
body: Stack( body: Stack(
...@@ -87,150 +94,148 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState ...@@ -87,150 +94,148 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
), ),
/// 📌 Nội dung chính /// 📌 Nội dung chính
AnimatedPadding( SafeArea(
duration: const Duration(milliseconds: 120),
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, // mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Container( Spacer(),
// Expanded(child: Container()),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
// decoration: const BoxDecoration( child: SingleChildScrollView(
// color: Colors.redAccent, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
// borderRadius: BorderRadius.only( physics: BouncingScrollPhysics(),
// topLeft: Radius.circular(30), child: Column(
// topRight: Radius.circular(30), crossAxisAlignment: CrossAxisAlignment.start,
// ), mainAxisSize: MainAxisSize.min,
// ), children: [
child: Column( /// 📌 Tiêu đề (Hiển thị nội dung HTML từ API hoặc mặc định)
crossAxisAlignment: CrossAxisAlignment.start, Obx(
mainAxisSize: MainAxisSize.min, () => Visibility(
children: [ visible: !_focusNode.hasFocus,
/// 📌 Tiêu đề (Hiển thị nội dung HTML từ API hoặc mặc định) child: HtmlWidget(
Obx( _viewModel.content.isNotEmpty
() => Visibility( ? _viewModel.content
visible: !_focusNode.hasFocus, : """<h4 style="color: white;">Tiêu điểm dễ - Trừ tiền mê</h4>
child: HtmlWidget(
_viewModel.content.isNotEmpty
? _viewModel.content
: """<h4 style="color: white;">Tiêu điểm dễ - Trừ tiền mê</h4>
<p style="color: white;">Đừng bỏ lỡ cơ hội tích tới 30% tất cả giao dịch viễn thông <p style="color: white;">Đừng bỏ lỡ cơ hội tích tới 30% tất cả giao dịch viễn thông
của các nhà mạng và đổi phiếu giảm giá tại hơn 200 thương hiệu được yêu thích nhất.</p>""", của các nhà mạng và đổi phiếu giảm giá tại hơn 200 thương hiệu được yêu thích nhất.</p>""",
textStyle: TextStyle(color: Colors.white), textStyle: TextStyle(color: Colors.white),
),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16),
/// 📌 Ô nhập số điện thoại /// 📌 Ô nhập số điện thoại
TextField( TextField(
inputFormatters: [ inputFormatters: [
LengthLimitingTextInputFormatter(10) LengthLimitingTextInputFormatter(10)
], ],
// maxLength: 10, // maxLength: 10,
focusNode: _focusNode, focusNode: _focusNode,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
style: const TextStyle(color: BaseColor.second600), style: const TextStyle(color: BaseColor.second600),
decoration: InputDecoration( decoration: InputDecoration(
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
hintText: "Nhập số điện thoại", hintText: "Nhập số điện thoại",
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(color: Colors.grey),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none, borderSide: BorderSide.none,
),
prefixIcon: const Icon(Icons.phone, color: Color(0xFF9DA4AE))
), ),
prefixIcon: const Icon(Icons.phone, color: Color(0xFF9DA4AE)) onChanged: _viewModel.updatePhoneNumber,
), ),
onChanged: _viewModel.updatePhoneNumber, const SizedBox(height: 16),
),
const SizedBox(height: 16),
/// 📌 Nút Tiếp Tục /// 📌 Nút Tiếp Tục
Obx( Obx(
() => SizedBox( () => SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: onPressed:
_viewModel.isButtonEnabled _viewModel.isButtonEnabled
? () { ? () {
_viewModel.checkPhoneNumber(); _viewModel.checkPhoneNumber();
} }
: null, : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
backgroundColor: _viewModel.isButtonEnabled ? Colors.white : Colors.white54, backgroundColor: _viewModel.isButtonEnabled ? Colors.white : Colors.white54,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
child: Text( child: Text(
"Tiếp tục", "Tiếp tục",
style: TextStyle( style: TextStyle(
color: _viewModel.isButtonEnabled ? BaseColor.second600 : BaseColor.second400, color: _viewModel.isButtonEnabled ? BaseColor.second600 : BaseColor.second400,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
),
), ),
), ),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), /// 📌 Checkbox + Điều khoản sử dụng + Chính sách bảo mật
Row(
/// 📌 Checkbox + Điều khoản sử dụng + Chính sách bảo mật crossAxisAlignment: CrossAxisAlignment.center,
Row( children: [
crossAxisAlignment: CrossAxisAlignment.center, Obx(
children: [ () => Checkbox(
Obx( value: _viewModel.isChecked.value,
() => Checkbox( onChanged: _viewModel.toggleCheckbox,
value: _viewModel.isChecked.value, activeColor: Colors.white,
onChanged: _viewModel.toggleCheckbox, checkColor: Colors.red,
activeColor: Colors.white, side: const BorderSide(color: Colors.white, width: 2),
checkColor: Colors.red, ),
side: const BorderSide(color: Colors.white, width: 2),
), ),
), Expanded(
Expanded( child: RichText(
child: RichText( text: TextSpan(
text: TextSpan( style: const TextStyle(color: Colors.white, fontSize: 14),
style: const TextStyle(color: Colors.white, fontSize: 14), children: [
children: [ const TextSpan(text: "Bằng việc tiếp tục, bạn đã đọc và đồng ý với "),
const TextSpan(text: "Bằng việc tiếp tục, bạn đã đọc và đồng ý với "), WidgetSpan(
WidgetSpan( child: GestureDetector(
child: GestureDetector( onTap: () => debugPrint("Điều khoản sử dụng"),
onTap: () => debugPrint("Điều khoản sử dụng"), child: const Text(
child: const Text( "Điều khoản sử dụng",
"Điều khoản sử dụng", style: TextStyle(
style: TextStyle( decoration: TextDecoration.underline,
decoration: TextDecoration.underline, decorationColor: Colors.white,
decorationColor: Colors.white, decorationThickness: 2,
decorationThickness: 2, color: Colors.white,
color: Colors.white, ),
), ),
), ),
), ),
), const TextSpan(text: " và "),
const TextSpan(text: " và "), WidgetSpan(
WidgetSpan( child: GestureDetector(
child: GestureDetector( onTap: () => debugPrint("Chính sách bảo mật"),
onTap: () => debugPrint("Chính sách bảo mật"), child: const Text(
child: const Text( "Chính sách bảo mật",
"Chính sách bảo mật", style: TextStyle(
style: TextStyle( decoration: TextDecoration.underline,
decoration: TextDecoration.underline, decorationColor: Colors.white,
decorationColor: Colors.white, decorationThickness: 2,
decorationThickness: 2, color: Colors.white,
color: Colors.white, ),
), ),
), ),
), ),
), const TextSpan(text: " của MyPoint"),
const TextSpan(text: " của MyPoint"), ],
], ),
), ),
), ),
), ],
], ),
), SizedBox(height: 0),
], ],
),
), ),
), ),
], ],
......
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart'; import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/networking/restful_api_request.dart'; import 'package:mypoint_flutter_app/networking/restful_api_request.dart';
import 'package:mypoint_flutter_app/onboading/model/check_phone_response_model.dart'; import '../../base/base_response_model.dart';
import '../base/base_response_model.dart'; import '../../base/restful_api_viewmodel.dart';
import '../base/restful_api_viewmodel.dart'; import 'model/check_phone_response_model.dart';
import 'model/onboarding_info_model.dart'; import 'model/onboarding_info_model.dart';
class OnboardingViewModel extends RestfulApiViewModel { class OnboardingViewModel extends RestfulApiViewModel {
......
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pin_code_fields/pin_code_fields.dart';
import '../../resouce/base_color.dart';
import 'otp_view_model.dart';
class OtpScreen extends StatefulWidget {
final IOtpRepository repository;
const OtpScreen({super.key, required this.repository});
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
@override
Widget build(BuildContext context) {
final otpVM = Get.put(OtpViewModel(widget.repository));
return Scaffold(
appBar: AppBar(
centerTitle: true,
leading: IconButton(icon: const Icon(Icons.arrow_back_ios), onPressed: () => Navigator.pop(context)),
),
body: SafeArea(
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text("Nhập mã xác thực OTP", style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildWelcomeText(otpVM),
const SizedBox(height: 32),
_buildPinCodeFields(otpVM),
const SizedBox(height: 16),
_buildErrorText(otpVM),
const SizedBox(height: 16),
_buildResendOtp(otpVM),
],
),
),
),
),
);
}
/// PinCodeTextField cho 6 ô
Widget _buildPinCodeFields(OtpViewModel vm) {
double screenWidth = MediaQuery.of(context).size.width;
// return Obx(() {
return PinCodeTextField(
appContext: Get.context!,
length: 6,
obscureText: false,
cursorColor: Colors.black,
keyboardType: TextInputType.number,
autoFocus: true,
animationType: AnimationType.none,
pinTheme: PinTheme(
shape: PinCodeFieldShape.box,
borderRadius: BorderRadius.circular(6),
fieldHeight: screenWidth / 6 - 12,
fieldWidth: screenWidth / 6 - 12,
activeColor: Colors.blue,
inactiveColor: Colors.grey.shade300,
selectedColor: Colors.blueAccent,
),
onChanged: (value) {
vm.otpCode.value = value;
vm.errorMessage.value = ""; // clear lỗi khi gõ
},
onCompleted: (value) {
vm.otpCode.value = value;
},
);
// });
}
Widget _buildErrorText(OtpViewModel vm) {
// Chỉ bọc Obx ở đây vì ta đọc vm.errorMessage
return Obx(() {
final error = vm.errorMessage.value;
if (error.isEmpty) {
return const SizedBox.shrink();
}
return Text(error, style: const TextStyle(color: Colors.red));
});
}
/// "Gửi lại OTP (02:30)"
Widget _buildResendOtp(OtpViewModel vm) {
// Bọc Obx vì ta đọc vm.currentCountdown
return Obx(() {
final cd = vm.currentCountdown.value;
final canResend = cd == 0;
final textTime = vm.countdownText;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: canResend ? vm.onResendOtp : null,
child: Text(
"Gửi lại OTP ${!canResend ? "($textTime)" : ""}",
style: TextStyle(color: canResend ? Colors.blue : Colors.grey),
),
),
],
);
});
}
Widget _buildWelcomeText(OtpViewModel vm) {
return RichText(
text: TextSpan(
style: TextStyle(fontSize: 14, color: BaseColor.second500),
children: [
const TextSpan(text: "Mã OTP đã được gửi về số điện thoại "),
TextSpan(
text: "0999999999", //"${vm.phoneNumber}",
style: const TextStyle(fontWeight: FontWeight.w500, color: BaseColor.primary500),
),
],
),
);
}
}
import 'dart:async';
import 'package:get/get.dart';
import 'package:get/get_state_manager/src/simple/get_controllers.dart';
// i_otp_repository.dart
abstract class IOtpRepository {
Future<void> sendOtp();
Future<bool> verifyOtp(String otpCode);
Future<void> resendOtp();
}
class OtpViewModel extends GetxController {
final IOtpRepository repository;
// Mã OTP người dùng nhập
var otpCode = "".obs;
// Lỗi (nếu OTP sai)
var errorMessage = "".obs;
// Đếm ngược thời gian resend
final int _maxCountdown = 150; // 2 phút 30 giây
var currentCountdown = 0.obs;
Timer? _timer;
OtpViewModel(this.repository);
@override
void onInit() {
super.onInit();
// Gửi OTP ngay khi vào màn hình (tuỳ logic)
sendOtp();
startCountdown();
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
/// Gửi OTP (lần đầu)
Future<void> sendOtp() async {
try {
await repository.sendOtp();
// Reset countdown
startCountdown();
} catch (e) {
errorMessage.value = "Gửi OTP thất bại. Vui lòng thử lại.";
}
}
// Đếm ngược 2:30
void startCountdown() {
currentCountdown.value = _maxCountdown;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (currentCountdown.value <= 0) {
timer.cancel();
} else {
currentCountdown.value--;
}
});
}
String get countdownText {
final m = currentCountdown.value ~/ 60;
final s = currentCountdown.value % 60;
final sStr = s < 10 ? "0$s" : "$s";
return "$m:$sStr";
}
// User nhập OTP
void onOtpChanged(String value) {
otpCode.value = value;
errorMessage.value = ""; // clear lỗi cũ
}
// Submit OTP
Future<void> onSubmitOtp() async {
if (otpCode.value.length < 6) {
errorMessage.value = "Vui lòng nhập đủ 6 ký tự";
return;
}
try {
final success = await repository.verifyOtp(otpCode.value);
if (success) {
errorMessage.value = "";
// TODO: Navigate or do something
// Example: Get.offAllNamed("/home");
print("OTP chính xác! Điều hướng tiếp...");
} else {
errorMessage.value = "Mã OTP không chính xác";
}
} catch (e) {
errorMessage.value = "Xác thực OTP thất bại. Thử lại.";
}
}
// Bấm "Gửi lại OTP"
Future<void> onResendOtp() async {
if (currentCountdown.value > 0) {
// Chưa hết thời gian => return
return;
}
try {
await repository.resendOtp();
startCountdown();
} catch (e) {
errorMessage.value = "Gửi lại OTP thất bại. Thử lại.";
}
}
}
\ No newline at end of file
// sign_up_otp_repository.dart
import 'package:flutter/material.dart';
import '../otp/otp_view_model.dart';
class SignUpOtpRepository implements IOtpRepository {
final String phoneNumber;
SignUpOtpRepository(this.phoneNumber);
@override
Future<void> sendOtp() async {
debugPrint("[SignUpOtpRepository] Gọi API gửi OTP cho luồng đăng ký");
// TODO: call API real
await Future.delayed(const Duration(seconds: 1));
}
@override
Future<bool> verifyOtp(String otpCode) async {
debugPrint("[SignUpOtpRepository] Gọi API verify OTP cho luồng đăng ký");
// TODO: call API real, giả lập OTP "123456" mới đúng
await Future.delayed(const Duration(seconds: 1));
return otpCode == "123456";
}
@override
Future<void> resendOtp() async {
debugPrint("[SignUpOtpRepository] Gọi API resend OTP đăng ký");
// TODO: call API real
await Future.delayed(const Duration(seconds: 1));
}
}
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
...@@ -7,10 +6,11 @@ import 'package:get/get_core/src/get_main.dart'; ...@@ -7,10 +6,11 @@ import 'package:get/get_core/src/get_main.dart';
import 'package:mypoint_flutter_app/configs/api_paths.dart'; import 'package:mypoint_flutter_app/configs/api_paths.dart';
import 'package:mypoint_flutter_app/dio_http_service/api_helper.dart'; import 'package:mypoint_flutter_app/dio_http_service/api_helper.dart';
import 'package:mypoint_flutter_app/networking/api_service.dart'; import 'package:mypoint_flutter_app/networking/api_service.dart';
import 'package:mypoint_flutter_app/splash_screen/splash_screen_view_model.dart'; import 'package:mypoint_flutter_app/screen/splash/splash_screen_view_model.dart';
import '../../model/check_update_response_model.dart';
import '../onboarding/onboarding_screen.dart';
import '../model/check_update_response_model.dart';
import '../onboading/onboarding_screen.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
......
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/restful_api_viewmodel.dart'; import 'package:mypoint_flutter_app/base/restful_api_viewmodel.dart';
import 'package:mypoint_flutter_app/networking/restful_api_request.dart'; import 'package:mypoint_flutter_app/networking/restful_api_request.dart';
import '../base/base_response_model.dart'; import '../../base/base_response_model.dart';
import '../model/check_update_response_model.dart'; import '../../model/update_response_object.dart';
import '../model/update_response_object.dart';
class SplashScreenViewModel extends RestfulApiViewModel { class SplashScreenViewModel extends RestfulApiViewModel {
var infoAppUpdate = BaseResponseModel<UpdateResponseObject>().obs; var infoAppUpdate = BaseResponseModel<UpdateResponseObject>().obs;
......
...@@ -45,6 +45,8 @@ dependencies: ...@@ -45,6 +45,8 @@ dependencies:
device_info_plus: ^9.0.3 device_info_plus: ^9.0.3
uuid: ^4.3.3 uuid: ^4.3.3
flutter_svg: flutter_svg:
local_auth:
pin_code_fields:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment