Skip to content
GitLab
Menu
Projects
Groups
Snippets
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
Hoàng Văn Đạt
mypoint_flutter_app
Commits
1257980d
Commit
1257980d
authored
Mar 03, 2025
by
DatHV
Browse files
update screen otp, login
parent
abd9f02e
Changes
20
Hide whitespace changes
Inline
Side-by-side
android/app/src/debug/AndroidManifest.xml
View file @
1257980d
...
...
@@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission
android:name=
"android.permission.INTERNET"
/>
<uses-permission
android:name=
"android.permission.USE_BIOMETRIC"
/>
</manifest>
android/app/src/profile/AndroidManifest.xml
View file @
1257980d
...
...
@@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission
android:name=
"android.permission.INTERNET"
/>
<uses-permission
android:name=
"android.permission.USE_BIOMETRIC"
/>
</manifest>
ios/Runner/Info.plist
View file @
1257980d
...
...
@@ -45,5 +45,9 @@
<true/>
<key>
UIApplicationSupportsIndirectInputEvents
</key>
<true/>
<key>
NSFaceIDUsageDescription
</key>
<string>
We need Face ID to authenticate your identity
</string>
<key>
NSLocalAuthenticationUseFaceID
</key>
<string>
true
</string>
</dict>
</plist>
lib/main.dart
View file @
1257980d
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'package:mypoint_flutter_app/splash_screen/splash_screen.dart'
;
import
'onboading/onboarding_screen.dart'
;
import
'onboading/onboarding_viewmodel.dart'
;
import
'package:mypoint_flutter_app/screen/onboarding/onboarding_view_model.dart'
;
import
'package:mypoint_flutter_app/screen/splash/splash_screen.dart'
;
void
main
(
)
{
Get
.
put
(
OnboardingViewModel
());
...
...
lib/networking/restful_api_request.dart
View file @
1257980d
...
...
@@ -5,8 +5,8 @@ import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import
'package:mypoint_flutter_app/networking/restful_api.dart'
;
import
'../configs/device_info.dart'
;
import
'../model/update_response_object.dart'
;
import
'../onboading/model/check_phone_response_model.dart'
;
import
'../onboading/model/onboarding_info_model.dart'
;
import
'../
screen/
onboa
r
ding/model/check_phone_response_model.dart'
;
import
'../
screen/
onboa
r
ding/model/onboarding_info_model.dart'
;
import
'model_maker.dart'
;
extension
RestfullAPIClientAllApi
on
RestfulAPIClient
{
...
...
lib/permission/biometric_manager.dart
0 → 100644
View file @
1257980d
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
;
}
}
lib/screen/login/login_screen.dart
0 → 100644
View file @
1257980d
// 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
),
),
),
);
});
}
}
lib/screen/login/login_view_model.dart
0 → 100644
View file @
1257980d
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ỷ."
);
}
}
}
lib/onboading/model/check_phone_response_model.dart
→
lib/
screen/
onboa
r
ding/model/check_phone_response_model.dart
View file @
1257980d
File moved
lib/onboading/model/check_phone_response_model.g.dart
→
lib/
screen/
onboa
r
ding/model/check_phone_response_model.g.dart
View file @
1257980d
File moved
lib/onboading/model/onboarding_info_model.dart
→
lib/
screen/
onboa
r
ding/model/onboarding_info_model.dart
View file @
1257980d
File moved
lib/onboading/model/onboarding_info_model.g.dart
→
lib/
screen/
onboa
r
ding/model/onboarding_info_model.g.dart
View file @
1257980d
File moved
lib/onboading/onboarding_screen.dart
→
lib/
screen/
onboa
r
ding/onboarding_screen.dart
View file @
1257980d
...
...
@@ -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:get/get.dart'
;
import
'package:mypoint_flutter_app/base/base_response_model.dart'
;
import
'../base/base_screen.dart'
;
import
'../base/basic_state.dart'
;
import
'../configs/constants.dart'
;
import
'../resouce/base_color.dart'
;
import
'../../base/base_screen.dart'
;
import
'../../base/basic_state.dart'
;
import
'../../configs/constants.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
'onboarding_viewmodel.dart'
;
import
'onboarding_view
_
model.dart'
;
class
OnboardingScreen
extends
BaseScreen
{
const
OnboardingScreen
({
super
.
key
});
...
...
@@ -27,8 +30,15 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
super
.
initState
();
_viewModel
.
fetchOnboardingContent
();
_viewModel
.
checkPhoneRes
.
listen
((
response
)
{
_handleResponseCheckPhoneNumber
(
response
.
data
);
_handleResponseError
(
response
);
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
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
}
}
void
_hideKeyboard
()
{
FocusScope
.
of
(
Get
.
context
!).
unfocus
();
}
@override
Widget
createBody
()
{
return
GestureDetector
(
onTap:
_hideKeyboard
,
final
double
keyboardHeight
=
MediaQuery
.
of
(
context
).
viewInsets
.
bottom
;
return
GestureDetector
(
onTap:
hideKeyboard
,
child:
Scaffold
(
resizeToAvoidBottomInset:
false
,
body:
Stack
(
...
...
@@ -87,150 +94,148 @@ class _OnboardingScreenState extends BaseState<OnboardingScreen> with BasicState
),
/// 📌 Nội dung chính
AnimatedPadding
(
duration:
const
Duration
(
milliseconds:
120
),
padding:
EdgeInsets
.
only
(
bottom:
MediaQuery
.
of
(
context
).
viewInsets
.
bottom
),
SafeArea
(
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
end
,
//
mainAxisAlignment: MainAxisAlignment.end,
children:
[
Container
(
Spacer
(),
// Expanded(child: Container()),
AnimatedContainer
(
duration:
const
Duration
(
milliseconds:
300
),
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
,
vertical:
32
),
// decoration: const BoxDecoration(
// color: Colors.redAccent,
// borderRadius: BorderRadius.only(
// topLeft: Radius.circular(30),
// topRight: Radius.circular(30),
// ),
// ),
child:
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
mainAxisSize:
MainAxisSize
.
min
,
children:
[
/// 📌 Tiêu đề (Hiển thị nội dung HTML từ API hoặc mặc định)
Obx
(
()
=>
Visibility
(
visible:
!
_focusNode
.
hasFocus
,
child:
HtmlWidget
(
_viewModel
.
content
.
isNotEmpty
?
_viewModel
.
content
:
"""<h4 style="
color:
white
;
">Tiêu điểm dễ - Trừ tiền mê</h4>
child:
SingleChildScrollView
(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior
.
onDrag
,
physics:
BouncingScrollPhysics
(),
child:
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
mainAxisSize:
MainAxisSize
.
min
,
children:
[
/// 📌 Tiêu đề (Hiển thị nội dung HTML từ API hoặc mặc định)
Obx
(
()
=>
Visibility
(
visible:
!
_focusNode
.
hasFocus
,
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
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
TextField
(
inputFormatters:
[
LengthLimitingTextInputFormatter
(
10
)
],
// maxLength: 10,
focusNode:
_focusNode
,
keyboardType:
TextInputType
.
phone
,
style:
const
TextStyle
(
color:
BaseColor
.
second600
),
decoration:
InputDecoration
(
filled:
true
,
fillColor:
Colors
.
white
,
hintText:
"Nhập số điện thoại"
,
hintStyle:
const
TextStyle
(
color:
Colors
.
grey
),
border:
OutlineInputBorder
(
borderRadius:
BorderRadius
.
circular
(
10
),
borderSide:
BorderSide
.
none
,
/// 📌 Ô nhập số điện thoại
TextField
(
inputFormatters:
[
LengthLimitingTextInputFormatter
(
10
)
],
// maxLength: 10,
focusNode:
_focusNode
,
keyboardType:
TextInputType
.
phone
,
style:
const
TextStyle
(
color:
BaseColor
.
second600
),
decoration:
InputDecoration
(
filled:
true
,
fillColor:
Colors
.
white
,
hintText:
"Nhập số điện thoại"
,
hintStyle:
const
TextStyle
(
color:
Colors
.
grey
),
border:
OutlineInputBorder
(
borderRadius:
BorderRadius
.
circular
(
10
),
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
Obx
(
()
=>
SizedBox
(
width:
double
.
infinity
,
child:
ElevatedButton
(
onPressed:
_viewModel
.
isButtonEnabled
?
()
{
_viewModel
.
checkPhoneNumber
();
}
:
null
,
style:
ElevatedButton
.
styleFrom
(
padding:
const
EdgeInsets
.
symmetric
(
vertical:
15
),
backgroundColor:
_viewModel
.
isButtonEnabled
?
Colors
.
white
:
Colors
.
white54
,
shape:
RoundedRectangleBorder
(
borderRadius:
BorderRadius
.
circular
(
10
)),
),
child:
Text
(
"Tiếp tục"
,
style:
TextStyle
(
color:
_viewModel
.
isButtonEnabled
?
BaseColor
.
second600
:
BaseColor
.
second400
,
fontSize:
18
,
fontWeight:
FontWeight
.
bold
,
/// 📌 Nút Tiếp Tục
Obx
(
()
=>
SizedBox
(
width:
double
.
infinity
,
child:
ElevatedButton
(
onPressed:
_viewModel
.
isButtonEnabled
?
()
{
_viewModel
.
checkPhoneNumber
();
}
:
null
,
style:
ElevatedButton
.
styleFrom
(
padding:
const
EdgeInsets
.
symmetric
(
vertical:
15
),
backgroundColor:
_viewModel
.
isButtonEnabled
?
Colors
.
white
:
Colors
.
white54
,
shape:
RoundedRectangleBorder
(
borderRadius:
BorderRadius
.
circular
(
10
)),
),
child:
Text
(
"Tiếp tục"
,
style:
TextStyle
(
color:
_viewModel
.
isButtonEnabled
?
BaseColor
.
second600
:
BaseColor
.
second400
,
fontSize:
18
,
fontWeight:
FontWeight
.
bold
,
),
),
),
),
),
),
const
SizedBox
(
height:
16
),
/// 📌 Checkbox + Điều khoản sử dụng + Chính sách bảo mật
Row
(
crossAxisAlignment:
CrossAxisAlignment
.
center
,
children:
[
Obx
(
()
=>
Checkbox
(
value:
_viewModel
.
isChecked
.
value
,
onChanged:
_viewModel
.
toggleCheckbox
,
activeColor:
Colors
.
white
,
checkColor:
Colors
.
red
,
side:
const
BorderSide
(
color:
Colors
.
white
,
width:
2
),
const
SizedBox
(
height:
16
),
/// 📌 Checkbox + Điều khoản sử dụng + Chính sách bảo mật
Row
(
crossAxisAlignment:
CrossAxisAlignment
.
center
,
children:
[
Obx
(
()
=>
Checkbox
(
value:
_viewModel
.
isChecked
.
value
,
onChanged:
_viewModel
.
toggleCheckbox
,
activeColor:
Colors
.
white
,
checkColor:
Colors
.
red
,
side:
const
BorderSide
(
color:
Colors
.
white
,
width:
2
),
),
),
),
Expanded
(
child:
RichText
(
text:
TextSpan
(
style:
const
TextStyle
(
color:
Colors
.
white
,
fontSize:
14
),
children:
[
const
TextSpan
(
text:
"Bằng việc tiếp tục, bạn đã đọc và đồng ý với "
),
WidgetSpan
(
child:
GestureDetector
(
onTap:
()
=>
debugPrint
(
"Điều khoản sử dụng"
),
child:
const
Text
(
"Điều khoản sử dụng"
,
style:
TextStyle
(
decoration
:
TextDecoration
.
underlin
e
,
decoration
Color:
Colors
.
white
,
decorationThickness:
2
,
color:
Colors
.
white
,
Expanded
(
child:
RichText
(
text:
TextSpan
(
style:
const
TextStyle
(
color:
Colors
.
white
,
fontSize:
14
),
children:
[
const
TextSpan
(
text:
"Bằng việc tiếp tục, bạn đã đọc và đồng ý với "
),
WidgetSpan
(
child:
GestureDetector
(
onTap:
()
=>
debugPrint
(
"Điều khoản sử dụng"
),
child:
const
Text
(
"Điều khoản sử dụng"
,
style:
TextStyle
(
decoration:
TextDecoration
.
underline
,
decoration
Color:
Colors
.
whit
e
,
decoration
Thickness:
2
,
color:
Colors
.
white
,
)
,
),
),
),
),
const
TextSpan
(
text:
" và "
),
WidgetSpan
(
child:
GestureDetector
(
onTap:
()
=>
debugPrint
(
"Chính sách bảo mật"
),
child:
const
Text
(
"Chính sách bảo mật"
,
style:
TextStyle
(
decoration
:
TextDecoration
.
underlin
e
,
decoration
Color:
Colors
.
white
,
decorationThickness:
2
,
color:
Colors
.
white
,
const
TextSpan
(
text:
" và "
),
WidgetSpan
(
child:
GestureDetector
(
onTap:
()
=>
debugPrint
(
"Chính sách bảo mật"
),
child:
const
Text
(
"Chính sách bảo mật"
,
style:
TextStyle
(
decoration:
TextDecoration
.
underline
,
decoration
Color:
Colors
.
whit
e
,
decoration
Thickness:
2
,
color:
Colors
.
white
,
)
,
),
),
),
),
const
TextSpan
(
text:
" của MyPoint"
)
,
]
,
const
TextSpan
(
text:
" của MyPoint"
),
]
,
)
,
),
),
),
],
),
],
],
),
SizedBox
(
height:
0
),
],
),
),
),
],
...
...
lib/onboading/onboarding_viewmodel.dart
→
lib/
screen/
onboa
r
ding/onboarding_view
_
model.dart
View file @
1257980d
import
'package:get/get.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/onboading/model/check_phon
e_response_model.dart'
;
import
'../
base
/base
_
res
ponse_
model.dart'
;
import
'
../base/restful_api_view
model.dart'
;
import
'
../../base/bas
e_response_model.dart'
;
import
'../
..
/base
/
res
tful_api_view
model.dart'
;
import
'
model/check_phone_response_
model.dart'
;
import
'model/onboarding_info_model.dart'
;
class
OnboardingViewModel
extends
RestfulApiViewModel
{
...
...
lib/screen/otp/otp_screen.dart
0 → 100644
View file @
1257980d
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
),
),
],
),
);
}
}
lib/screen/otp/otp_view_model.dart
0 → 100644
View file @
1257980d
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
lib/screen/signup/signup_otp_repository.dart
0 → 100644
View file @
1257980d
// 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
));
}
}
lib/s
plash_screen
/splash_screen.dart
→
lib/s
creen/splash
/splash_screen.dart
View file @
1257980d
import
'dart:io'
;
import
'package:flutter/material.dart'
;
import
'package:flutter/services.dart'
;
import
'package:get/get.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/dio_http_service/api_helper.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
{
const
SplashScreen
({
super
.
key
});
...
...
lib/s
plash_screen
/splash_screen_view_model.dart
→
lib/s
creen/splash
/splash_screen_view_model.dart
View file @
1257980d
import
'package:get/get.dart'
;
import
'package:mypoint_flutter_app/base/restful_api_viewmodel.dart'
;
import
'package:mypoint_flutter_app/networking/restful_api_request.dart'
;
import
'../base/base_response_model.dart'
;
import
'../model/check_update_response_model.dart'
;
import
'../model/update_response_object.dart'
;
import
'../../base/base_response_model.dart'
;
import
'../../model/update_response_object.dart'
;
class
SplashScreenViewModel
extends
RestfulApiViewModel
{
var
infoAppUpdate
=
BaseResponseModel
<
UpdateResponseObject
>().
obs
;
...
...
pubspec.yaml
View file @
1257980d
...
...
@@ -45,6 +45,8 @@ dependencies:
device_info_plus
:
^9.0.3
uuid
:
^4.3.3
flutter_svg
:
local_auth
:
pin_code_fields
:
dev_dependencies
:
flutter_test
:
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment