Commit 928c3660 authored by DatHV's avatar DatHV
Browse files

cập nhật logic, refactor code

parent 6c72edcb
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart';
import 'package:mypoint_flutter_app/networking/app_navigator.dart';
import 'package:mypoint_flutter_app/main.dart' show routeObserver;
import '../networking/dio_http_service.dart';
import '../resources/base_color.dart';
import '../widgets/alert/custom_alert_dialog.dart';
import '../widgets/alert/data_alert_model.dart';
......@@ -17,20 +15,13 @@ abstract class BaseScreen extends StatefulWidget {
abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
with WidgetsBindingObserver, RouteAware {
bool _isVisible = false;
bool _isPaused = false;
ModalRoute<dynamic>? _route;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
if (kDebugMode) {
print("_show: $runtimeType");
}
// Gọi onInit sau khi initState
WidgetsBinding.instance.addPostFrameCallback((_) {
onInit();
});
WidgetsBinding.instance.addPostFrameCallback((_) => onInit());
}
@override
......@@ -38,9 +29,8 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
WidgetsBinding.instance.removeObserver(this);
if (_route != null) {
routeObserver.unsubscribe(this);
_route = null;
}
onDestroy();
onDispose();
super.dispose();
}
......@@ -49,39 +39,12 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
if (_isPaused) {
_isPaused = false;
onAppResumed();
if (_isVisible) {
// App back to foreground while this route is visible → appear again
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
}
}
break;
case AppLifecycleState.paused:
if (!_isPaused) {
_isPaused = true;
onAppPaused();
if (_isVisible) {
// App goes to background while this route is visible → disappear
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
}
}
break;
case AppLifecycleState.inactive:
onAppInactive();
break;
case AppLifecycleState.detached:
onAppDetached();
break;
case AppLifecycleState.hidden:
onAppHidden();
default:
break;
}
}
......@@ -89,131 +52,107 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Subscribe to RouteObserver when route is available
_setupRouteObserver();
}
void _setupRouteObserver() {
final modalRoute = ModalRoute.of(context);
if (modalRoute != null && modalRoute is PageRoute && modalRoute != _route) {
_route = modalRoute;
routeObserver.subscribe(this, modalRoute);
}
if (!_isVisible) {
_isVisible = true;
// First time becoming visible in the tree
onWillAppear();
// Call did-appear after the frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isVisible && !_isPaused) {
onDidAppear();
}
});
}
}
@override
void didUpdateWidget(Screen oldWidget) {
super.didUpdateWidget(oldWidget);
// Gọi khi widget được update (có thể do navigation, state changes)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isVisible && !_isPaused) {
onStart();
}
});
}
// MARK: - Flutter Lifecycle Hooks (Override these in your screens)
// MARK: - Core Lifecycle Methods
/// Called when the widget is first inserted into the tree (similar to viewDidLoad in iOS)
/// Called when the widget is first inserted into the tree.
/// Use this to initialize data, setup listeners, etc.
void onInit() {
// Override in subclasses
if (kDebugMode) print("onInit: $runtimeType");
}
/// Called when the widget is about to become visible (similar to viewWillAppear in iOS)
void onResume() {
// Override in subclasses
/// Called when the widget is removed from the tree.
/// Use this to cleanup resources, cancel timers, etc.
void onDispose() {
if (kDebugMode) print("onDispose: $runtimeType");
}
/// Called when the widget has become visible (similar to viewDidAppear in iOS)
void onStart() {
// Override in subclasses
}
// MARK: - Route Visibility Methods
/// Called when the widget is about to become invisible (similar to viewWillDisappear in iOS)
void onPause() {
// Override in subclasses
/// Called when the route is about to become visible (push or uncovered).
/// Use this to prepare data, start animations, etc.
void onRouteWillAppear() {
if (kDebugMode) print("onRouteWillAppear: $runtimeType");
}
/// Called when the widget has become invisible (similar to viewDidDisappear in iOS)
void onStop() {
// Override in subclasses
/// Called when the route has become visible.
/// Use this to start timers, refresh data, etc.
void onRouteDidAppear() {
if (kDebugMode) print("onRouteDidAppear: $runtimeType");
}
/// Called when the widget is removed from the tree (similar to viewDidUnload in iOS)
void onDestroy() {
// Override in subclasses
/// Called when the route is about to be covered or popped.
/// Use this to pause operations, save state, etc.
void onRouteWillDisappear() {
if (kDebugMode) print("onRouteWillDisappear: $runtimeType");
}
// MARK: - Route visibility hooks (Navigator push/pop)
/// Called right before the route appears (push or uncovered)
void onWillAppear() {}
/// Called right after the route appeared
void onDidAppear() {}
/// Called right before another route covers this one
void onWillDisappear() {}
/// Called when the route has been covered or popped.
/// Use this to stop timers, cleanup temporary resources, etc.
void onRouteDidDisappear() {
if (kDebugMode) print("onRouteDidDisappear: $runtimeType");
}
/// Called right after this route is covered or popped
void onDidDisappear() {}
// MARK: - App Lifecycle Methods
/// Called when app becomes active (similar to applicationDidBecomeActive in iOS)
/// Called when the app becomes active (foreground).
/// Use this to resume operations, refresh data, etc.
void onAppResumed() {
// Override in subclasses
if (kDebugMode) print("onAppResumed: $runtimeType");
}
/// Called when app becomes inactive (similar to applicationWillResignActive in iOS)
/// Called when the app becomes inactive (background).
/// Use this to pause operations, save state, etc.
void onAppPaused() {
// Override in subclasses
}
/// Called when app becomes inactive (similar to applicationWillResignActive in iOS)
void onAppInactive() {
// Override in subclasses
}
/// Called when app is detached (similar to applicationWillTerminate in iOS)
void onAppDetached() {
// Override in subclasses
}
/// Called when app is hidden (similar to applicationDidEnterBackground in iOS)
void onAppHidden() {
// Override in subclasses
if (kDebugMode) print("onAppPaused: $runtimeType");
}
// MARK: - UI Helper Methods
/// Shows a popup dialog with custom data
void showPopup({
required PopupDataModel data,
bool? barrierDismissibl,
bool? barrierDismissible,
bool showCloseButton = false,
ButtonsDirection direction = ButtonsDirection.column,
}) {
Get.dialog(
CustomAlertDialog(alertData: data.dataAlertModel, showCloseButton: showCloseButton, direction: direction),
barrierDismissible: barrierDismissibl ?? true,
CustomAlertDialog(
alertData: data.dataAlertModel,
showCloseButton: showCloseButton,
direction: direction,
),
barrierDismissible: barrierDismissible ?? true,
);
}
/// Shows an alert dialog with custom data
void showAlert({
required DataAlertModel data,
bool? barrierDismissibl,
bool? barrierDismissible,
bool showCloseButton = true,
ButtonsDirection direction = ButtonsDirection.column,
}) {
Get.dialog(
CustomAlertDialog(alertData: data, showCloseButton: showCloseButton, direction: direction),
barrierDismissible: barrierDismissibl ?? false,
CustomAlertDialog(
alertData: data,
showCloseButton: showCloseButton,
direction: direction,
),
barrierDismissible: barrierDismissible ?? false,
);
}
/// Shows an error alert with default styling
void showAlertError({
required String content,
bool? barrierDismissible,
......@@ -234,9 +173,7 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
text: "Đã Hiểu",
onPressed: () {
Get.back();
if (onConfirmed != null) {
onConfirmed();
}
onConfirmed?.call();
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
......@@ -248,51 +185,34 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
);
}
/// Hides the keyboard
void hideKeyboard() {
FocusScope.of(context).unfocus();
}
void printDebug(dynamic data) {
if (kDebugMode) {
print(data);
}
}
Widget? createBottomBar() {
return null;
}
// MARK: - RouteAware overrides mapping to hooks
// MARK: - RouteAware Implementation
@override
void didPush() {
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
_isVisible = true;
_handleRouteAppear();
}
@override
void didPopNext() {
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
}
void didPopNext() => _handleRouteAppear();
@override
void didPushNext() {
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
}
void didPushNext() => _handleRouteDisappear();
@override
void didPop() {
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
void didPop() => _handleRouteDisappear();
void _handleRouteAppear() {
onRouteWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) => onRouteDidAppear());
}
void _handleRouteDisappear() {
onRouteWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) => onRouteDidDisappear());
}
}
......@@ -16,7 +16,7 @@ mixin BasicState<Screen extends BaseScreen> on BaseState<Screen> {
backgroundColor: colorView,
key: _scaffoldStateKey,
appBar: appBar,
bottomNavigationBar: createBottomBar(),
// bottomNavigationBar: createBottomBar(),
body: isSafeArea == true
? Container(
color: colorSafeArea,
......@@ -39,7 +39,7 @@ mixin BasicState<Screen extends BaseScreen> on BaseState<Screen> {
child: Scaffold(
backgroundColor: colorView,
appBar: appBar,
bottomNavigationBar: createBottomBar(),
// bottomNavigationBar: createBottomBar(),
body: isSafeArea == true
? GestureDetector(
onPanUpdate: (details) {
......
......@@ -16,6 +16,7 @@ class APIPaths {//sandbox
static const String login = "/iam/v1/authentication/account-login";
static const String loginWithBiometric = "/iam/v1/authentication/bio-login";
static const String getUserInfo = "/user/api/v2.0/mypoint/me";
static const String refreshToken = "/accountAccessTokenRefresh/3.0.0";
static const String bioCredential = "/iam/v1/account/me/bio-credential";
static const String accountLoginForPasswordChange = "/accountLoginForPasswordChange/1.0.0";
static const String accountPasswordChange = "/accountPasswordChange/1.0.0";
......
......@@ -91,7 +91,7 @@ class DirectionalScreen {
return true;
case DirectionalScreenName.viewVoucherWithCountTime:
final countDownSecond = int.tryParse(clickActionParam ?? '') ?? 0;
Get.toNamed(voucherDetailScreen, arguments: {"countDownSecond": countDownSecond});
Get.toNamed(vouchersScreen, arguments: {"countDownSecond": countDownSecond});
return true;
case DirectionalScreenName.popViewController:
if (Get.isOverlaysOpen) {
......
......@@ -5,7 +5,9 @@ import 'package:intl/intl.dart' as intl;
extension PhoneValidator on String {
bool isPhoneValid() {
return RegExp(r'^0\d{9}$').hasMatch(this);
final phone = replaceAll(RegExp(r'\s+'), '');
final regex = RegExp(r'^(0|\+84)(3[2-9]|5[6|8|9]|7[0|6-9]|8[1-5]|9[0-4|6-9])[0-9]{7}$');
return regex.hasMatch(phone);
}
}
......
......@@ -8,6 +8,28 @@ class LoginTokenResponseModel {
bool? forceResetPassword;
LoginTokenResponseModel({this.accessToken, this.refreshToken, this.forceResetPassword});
LoginTokenResponseModel.fromRefreshToken(TokenRefreshResponseModel refresh)
: accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken,
forceResetPassword = false;
factory LoginTokenResponseModel.fromJson(Map<String, dynamic> json) => _$LoginTokenResponseModelFromJson(json);
Map<String, dynamic> toJson() => _$LoginTokenResponseModelToJson(this);
}
class TokenRefreshResponseModel {
String? accessToken;
String? refreshToken;
TokenRefreshResponseModel({this.accessToken, this.refreshToken});
factory TokenRefreshResponseModel.fromJson(Map<String, dynamic> json) => TokenRefreshResponseModel(
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
);
Map<String, dynamic> toJson() => {
'access_token': accessToken,
'refresh_token': refreshToken,
};
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../configs/constants.dart';
import '../app_navigator.dart';
import '../dio_http_service.dart';
import 'package:mypoint_flutter_app/preference/data_preference.dart';
import '../../services/token_refresh_service.dart';
class AuthInterceptor extends Interceptor {
bool _isHandlingAuth = false;
......@@ -35,8 +37,8 @@ class AuthInterceptor extends Interceptor {
final statusCode = err.response?.statusCode;
if (alreadyHandled) return;
if (statusCode == 401 || _isTokenInvalid(data)) {
await _handleAuthError(data);
return handler.reject(err);
await _handleAuthError(data, originalRequest: err.requestOptions, handler: handler, originalError: err);
return;
}
handler.next(err);
}
......@@ -49,10 +51,45 @@ class AuthInterceptor extends Interceptor {
return false;
}
Future<void> _handleAuthError(dynamic data) async {
Future<void> _handleAuthError(dynamic data, {RequestOptions? originalRequest, ErrorInterceptorHandler? handler, DioException? originalError}) async {
if (_isHandlingAuth) return;
_isHandlingAuth = true;
try {
// Thử refresh token trước khi logout
final refreshService = TokenRefreshService();
if (!refreshService.isRefreshing) {
await refreshService.refreshToken((success) async {
if (success && originalRequest != null && handler != null) {
try {
final RequestOptions retryOptions = originalRequest.copyWith();
retryOptions.extra.remove(_kAuthHandledKey);
final dio = DioHttpService().dio;
final Response retried = await dio.fetch(retryOptions);
handler.resolve(retried);
return;
} catch (e) {
handler.reject(errFrom(e, originalRequest));
}
} else if (!success) {
_performLogout(data);
if (handler != null && originalError != null) handler.reject(originalError);
}
});
} else {
_performLogout(data);
if (handler != null && originalError != null) handler.reject(originalError);
}
} finally {
_isHandlingAuth = false;
}
}
DioException errFrom(Object e, RequestOptions req) {
if (e is DioException) return e;
return DioException(requestOptions: req, error: e);
}
Future<void> _performLogout(dynamic data) async {
await DataPreference.instance.clearData();
String? message;
if (data is Map<String, dynamic>) {
......@@ -61,8 +98,5 @@ class AuthInterceptor extends Interceptor {
data['message']?.toString();
}
await AppNavigator.showAuthAlertAndGoLogin(message ?? ErrorCodes.tokenInvalidMessage);
} finally {
_isHandlingAuth = false;
}
}
}
......@@ -179,9 +179,19 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
return requestNormal(APIPaths.getUserInfo, Method.GET, {}, (data) => ProfileResponseModel.fromJson(data as Json));
}
Future<BaseResponseModel<TokenRefreshResponseModel>> refreshToken() async {
String? token = DataPreference.instance.token ?? "";
String? refreshToken = DataPreference.instance.refreshToken ?? "";
final body = {
"access_token_old": token,
"refresh_token": refreshToken,
'lang': 'vi',
};
return requestNormal(APIPaths.refreshToken, Method.POST, body, (data) => TokenRefreshResponseModel.fromJson(data as Json));
}
Future<BaseResponseModel<CreateOTPResponseModel>> otpCreateNew(String ownerId) async {
// var deviceKey = await DeviceInfo.getDeviceId();
final body = {"owner_id": ownerId, "ttl": Constants.otpTtl, "resend_after_second": Constants.otpTtl};
final body = {"owner_id": ownerId, "ttl": Constants.otpTtl, "resend_after_second": Constants.otpTtl, 'lang': 'vi'};
return requestNormal(
APIPaths.otpCreateNew,
Method.POST,
......
......@@ -58,6 +58,7 @@ class DataPreference {
}
String? get rankName => _profile?.workingSite?.primaryMembership?.membershipLevel?.levelName ?? "";
String? get token => _loginToken?.accessToken;
String? get refreshToken => _loginToken?.refreshToken;
String? get phone => _profile?.workerSite?.phoneNumber;
bool get logged => (token ?? "").isNotEmpty;
ProfileResponseModel? get profile => _profile;
......
......@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/screen/data_network_service/product_network_data_model.dart';
import 'package:mypoint_flutter_app/widgets/custom_empty_widget.dart';
import 'package:mypoint_flutter_app/widgets/custom_navigation_bar.dart';
......@@ -79,7 +80,7 @@ class _DataNetworkServiceScreenState extends BaseState<DataNetworkServiceScreen>
Widget _buildButton() {
return Obx(() {
final isValidInput = _viewModel.validatePhoneNumber() && (_viewModel.selectedProduct.value != null);
final isValidInput = _viewModel.phoneNumber.value.isPhoneValid() && (_viewModel.selectedProduct.value != null);
return ElevatedButton(
onPressed: isValidInput ? _redeemProductMobileCard : null,
style: ElevatedButton.styleFrom(
......
......@@ -12,7 +12,7 @@ import '../voucher/models/product_model.dart';
import '../voucher/models/product_type.dart';
class DataNetworkServiceViewModel extends RestfulApiViewModel {
var histories = RxList<String>();
final RxList<String> histories = <String>[].obs;
final RxList<ProductBrandModel> topUpBrands = <ProductBrandModel>[].obs;
final RxList<TopUpNetworkDataModel> topUpNetworkData = <TopUpNetworkDataModel>[].obs;
final Map<String, List<TopUpNetworkDataModel>> _allValue = {};
......@@ -30,12 +30,6 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
return UserPointManager().point >= payPoint;
}
bool validatePhoneNumber() {
final phone = phoneNumber.value.replaceAll(RegExp(r'\s+'), '');
final regex = RegExp(r'^(0|\+84)(3[2-9]|5[6|8|9]|7[0|6-9]|8[1-5]|9[0-4|6-9])[0-9]{7}$');
return regex.hasMatch(phone);
}
@override
void onInit() {
super.onInit();
......@@ -43,13 +37,13 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
phoneNumber.value = myPhone;
ContactStorageService().getUsedContacts().then((value) {
if (value.isNotEmpty) {
histories.value = value;
histories.assignAll(value);
} else {
histories.value = [myPhone];
histories.assignAll([myPhone]);
}
});
if (!histories.contains(myPhone)) {
histories.value.insert(0, myPhone);
histories.insert(0, myPhone);
ContactStorageService().saveUsedContact(myPhone);
}
}
......@@ -61,7 +55,7 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
_getNetworkBrands() {
showLoading();
client.productTopUpBrands().then((response) {
topUpBrands.value = response.data ?? [];
topUpBrands.assignAll(response.data ?? []);
hideLoading();
checkMobileNetwork();
}).catchError((error) {
......@@ -83,7 +77,7 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
hideLoading();
getTelcoDetail();
}).catchError((error) {
final first = topUpBrands.value.firstOrNull;
final first = topUpBrands.firstOrNull;
if (first != null) {
selectedBrand.value = first;
}
......@@ -119,7 +113,7 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
// Dùng cache nếu có
if (_allValue.containsKey(code)) {
final cached = _allValue[code]!;
topUpNetworkData.value = cached;
topUpNetworkData.assignAll(cached);
makeSelected(cached);
return;
}
......@@ -131,7 +125,7 @@ class DataNetworkServiceViewModel extends RestfulApiViewModel {
.where((e) => e.products?.isNotEmpty == true)
.toList();
_allValue[code ?? ""] = data;
topUpNetworkData.value = data;
topUpNetworkData.assignAll(data);
makeSelected(data);
hideLoading();
} catch (error) {
......
......@@ -26,8 +26,8 @@ class HomeTabViewModel extends RestfulApiViewModel {
final RxList<AffiliateBrandModel> affiliates = <AffiliateBrandModel>[].obs;
final RxList<MyProductModel> myProducts = <MyProductModel>[].obs;
final RxList<MainSectionConfigModel> sectionLayouts = <MainSectionConfigModel>[].obs;
var flashSaleData = Rxn<FlashSaleModel>();
var hoverData = Rxn<HoverDataModel>();
final Rxn<FlashSaleModel> flashSaleData = Rxn<FlashSaleModel>();
final Rxn<HoverDataModel> hoverData = Rxn<HoverDataModel>();
@override
void onInit() {
......@@ -44,16 +44,16 @@ class HomeTabViewModel extends RestfulApiViewModel {
showLoading();
try {
final response = await client.getSectionLayoutHome();
sectionLayouts.value = response.data ?? [];
sectionLayouts.assignAll(response.data ?? []);
hideLoading();
} catch (error) {
sectionLayouts.value = await _loadSectionLayoutHomeFromCache();
sectionLayouts.assignAll(await _loadSectionLayoutHomeFromCache());
hideLoading();
} finally {
if (sectionLayouts.value.isEmpty) {
sectionLayouts.value = await _loadSectionLayoutHomeFromCache();
if (sectionLayouts.isEmpty) {
sectionLayouts.assignAll(await _loadSectionLayoutHomeFromCache());
}
for (final section in sectionLayouts.value) {
for (final section in sectionLayouts) {
await _processSection(section);
}
}
......@@ -63,7 +63,6 @@ class HomeTabViewModel extends RestfulApiViewModel {
try {
final result = await client.getDataPiPiHome();
hoverData.value = result.data;
hoverData.refresh();
} catch (error) {
print("Error fetching loadDataPiPiHome: $error");
}
......@@ -83,35 +82,35 @@ class HomeTabViewModel extends RestfulApiViewModel {
path,
(json) => MainServiceModel.fromJson(json as Map<String, dynamic>),
);
services.value = res.data ?? [];
services.assignAll(res.data ?? []);
break;
case HeaderSectionType.banner:
final res = await client.fetchList<BannerModel>(
path,
(json) => BannerModel.fromJson(json as Map<String, dynamic>),
);
banners.value = res.data ?? [];
banners.assignAll(res.data ?? []);
break;
case HeaderSectionType.campaign:
final res = await client.fetchList<AchievementModel>(
path,
(json) => AchievementModel.fromJson(json as Map<String, dynamic>),
);
achievements.value = res.data ?? [];
achievements.assignAll(res.data ?? []);
break;
case HeaderSectionType.product:
final res = await client.fetchList<ProductModel>(
path,
(json) => ProductModel.fromJson(json as Map<String, dynamic>),
);
products.value = res.data ?? [];
products.assignAll(res.data ?? []);
break;
case HeaderSectionType.news:
final res = await client.fetchList<PageItemModel>(
path,
(json) => PageItemModel.fromJson(json as Map<String, dynamic>),
);
news.value = res.data ?? [];
news.assignAll(res.data ?? []);
break;
case HeaderSectionType.flashSale:
final res = await client.fetchObject<FlashSaleModel>(
......@@ -125,21 +124,21 @@ class HomeTabViewModel extends RestfulApiViewModel {
path,
(json) => BrandModel.fromJson(json as Map<String, dynamic>),
);
brands.value = res.data ?? [];
brands.assignAll(res.data ?? []);
break;
case HeaderSectionType.pointPartner:
final res = await client.fetchList<AffiliateBrandModel>(
path,
(json) => AffiliateBrandModel.fromJson(json as Map<String, dynamic>),
);
affiliates.value = (res.data ?? []).take(6).toList();
affiliates.assignAll((res.data ?? []).take(6).toList());
break;
case HeaderSectionType.myProduct:
final res = await client.fetchList<MyProductModel>(
path,
(json) => MyProductModel.fromJson(json as Map<String, dynamic>),
);
myProducts.value = res.data ?? [];
myProducts.assignAll(res.data ?? []);
break;
default:
print("Unknown section type: ${section.headerSectionType}");
......
......@@ -32,7 +32,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
final args = Get.arguments;
if (args is Map) {
phoneNumber = args['phone'];
fullName = args['fullName'] ?? '';
fullName = args['fullName'] ?? 'Quý khách';
}
loginVM.onShowChangePass = (message) {
Get.dialog(
......@@ -45,7 +45,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
AlertButton(
text: "Cài đặt ngay",
onPressed: () {
loginVM.onForgotPassPressed(phoneNumber);
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
......@@ -57,7 +57,9 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
};
loginVM.onShowDeviceError = (message) {
showAlertError(content: message, onConfirmed: () {
loginVM.onChangePhonePressed();
});
};
loginVM.onShowInvalidAccount = (message) {
......@@ -281,23 +283,10 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
return Obx(() {
bool enabled = false;
Color color = BaseColor.second400;
switch (vm.loginState.value) {
case LoginState.typing:
if (vm.password.value.isNotEmpty) {
if (vm.loginState.value == LoginState.typing && vm.password.value.length >= 6) {
color = BaseColor.primary500;
enabled = true;
} else {
enabled = false;
color = BaseColor.second400;
}
break;
case LoginState.error:
case LoginState.idle:
enabled = false;
color = BaseColor.second400;
break;
}
return Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
......
import 'dart:convert';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/configs/constants.dart';
import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart';
import 'package:mypoint_flutter_app/screen/otp/forgot_pass_otp_repository.dart';
import 'package:mypoint_flutter_app/screen/otp/otp_screen.dart';
import 'package:mypoint_flutter_app/shared/router_gage.dart';
import '../../base/base_response_model.dart';
import '../../networking/restful_api_viewmodel.dart';
import '../../model/auth/login_token_response_model.dart';
import '../../permission/biometric_manager.dart';
import '../../preference/data_preference.dart';
import '../../firebase/push_token_service.dart';
import '../main_tab_screen/main_tab_screen.dart';
import '../../services/login_service.dart';
// login_state_enum.dart
enum LoginState { idle, typing, error }
class LoginViewModel extends RestfulApiViewModel {
......@@ -27,6 +22,7 @@ class LoginViewModel extends RestfulApiViewModel {
void Function(String message)? onShowDeviceError;
void Function(String message)? onShowChangePass;
void Function(String message)? onShowInvalidAccount;
final LoginService _loginService = LoginService();
@override
void onInit() {
......@@ -52,27 +48,48 @@ class LoginViewModel extends RestfulApiViewModel {
isPasswordVisible.value = !isPasswordVisible.value;
}
/// REFACTORED: Clean login method using LoginService
Future<void> onLoginPressed(String phone) async {
if (password.value.isEmpty) return;
showLoading();
final response = await client.login(phone, password.value);
try {
final result = await _loginService.login(phone, password.value);
hideLoading();
_handleLoginResponse(response, phone);
_handleLoginResult(result, phone);
} catch (e) {
hideLoading();
print('Login error: ${e.toString()}');
onShowAlertError?.call(Constants.commonError);
}
}
Future<void> _getUserProfile() async {
showLoading();
final response = await client.getUserProfile();
final userProfile = response.data;
if (response.isSuccess && userProfile != null) {
await DataPreference.instance.saveUserProfile(userProfile);
hideLoading();
/// REFACTORED: Handle login result with proper error handling
void _handleLoginResult(LoginResponse result, String phone) {
switch (result.result) {
case LoginResult.success:
Get.offAllNamed(mainScreen);
} else {
hideLoading();
await DataPreference.instance.clearLoginToken();
final mgs = response.errorMessage ?? Constants.commonError;
onShowAlertError?.call(mgs);
break;
case LoginResult.deviceUndefined:
onShowDeviceError?.call(result.message ?? Constants.commonError);
break;
case LoginResult.requiredChangePass:
onShowChangePass?.call(result.message ?? Constants.commonError);
break;
case LoginResult.invalidAccount:
onShowInvalidAccount?.call(result.message ?? Constants.commonError);
break;
case LoginResult.bioTokenInvalid:
_loginService.clearBiometricToken(phone);
onShowAlertError?.call(result.message ?? Constants.commonError);
break;
case LoginResult.invalidCredentials:
loginState.value = LoginState.error;
break;
case LoginResult.networkError:
case LoginResult.unknownError:
default:
onShowAlertError?.call(result.message ?? Constants.commonError);
break;
}
}
......@@ -87,42 +104,26 @@ class LoginViewModel extends RestfulApiViewModel {
Future<void> onForgotPassPressed(String phone) async {
showLoading();
try {
final response = await client.otpCreateNew(phone);
hideLoading();
if (!response.isSuccess) return;
if (!response.isSuccess) {
onShowAlertError?.call(response.errorMessage ?? Constants.commonError);
return;
}
Get.to(
OtpScreen(
repository: ForgotPassOTPRepository(phone, response.data?.resendAfterSecond ?? Constants.otpTtl),
),
);
}
Future<void> _handleLoginResponse(BaseResponseModel<LoginTokenResponseModel> response, String phone) async {
if (response.isSuccess && response.data != null) {
await DataPreference.instance.saveLoginToken(response.data!);
// Upload FCM token after login
await PushTokenService.uploadIfLogged();
await _getUserProfile();
return;
}
final errorMsg = response.errorMessage ?? Constants.commonError;
final errorCode = response.errorCode;
if (errorCode == ErrorCodes.deviceUndefined) {
onShowDeviceError?.call(errorMsg);
} else if (errorCode == ErrorCodes.requiredChangePass) {
onShowChangePass?.call(errorMsg);
} else if (errorCode == ErrorCodes.invalidAccount) {
onShowInvalidAccount?.call(errorMsg);
} else {
if (errorCode == ErrorCodes.bioTokenInvalid) {
DataPreference.instance.clearBioToken(phone);
}
onShowAlertError?.call(errorMsg);
} catch (e) {
hideLoading();
print('OTP error: ${e.toString()}');
onShowAlertError?.call(Constants.commonError);
}
}
/// Xác thực đăng nhập bằng sinh trắc
/// REFACTORED: Biometric login using LoginService
Future<void> onBiometricLoginPressed(String phone) async {
final isSupported = await BiometricManager().isDeviceSupported();
if (!isSupported) {
......@@ -136,8 +137,14 @@ class LoginViewModel extends RestfulApiViewModel {
return;
}
showLoading();
final response = await client.loginWithBiometric(phone);
try {
final result = await _loginService.biometricLogin(phone);
hideLoading();
_handleLoginResponse(response, phone);
_handleLoginResult(result, phone);
} catch (e) {
hideLoading();
print('Biometric login error: ${e.toString()}');
onShowAlertError?.call(Constants.commonError);
}
}
}
......@@ -7,6 +7,7 @@ import '../../networking/restful_api_viewmodel.dart';
import '../../configs/constants.dart';
import '../../preference/data_preference.dart';
import '../../shared/router_gage.dart';
import '../../utils/validation_utils.dart';
import '../location_address/location_address_viewmodel.dart';
class PersonalEditViewModel extends RestfulApiViewModel {
......@@ -155,7 +156,6 @@ class PersonalEditViewModel extends RestfulApiViewModel {
}
bool isValidEmail(String email) {
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
return emailRegex.hasMatch(email);
return ValidationUtils.isValidEmail(email);
}
}
......@@ -70,17 +70,17 @@ class SplashScreenViewModel extends RestfulApiViewModel {
}
void _directionWhenTokenInvalid() {
// TODO: handle later
Get.toNamed(onboardingScreen);
return;
if (kIsWeb) {
print('❌ No token found on web, cannot proceed');
webCloseApp({
'message': 'No token found, cannot proceed',
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
} else {
Get.toNamed(onboardingScreen);
}
// if (kIsWeb) {
// print('❌ No token found on web, cannot proceed');
// webCloseApp({
// 'message': 'No token found, cannot proceed',
// 'timestamp': DateTime.now().millisecondsSinceEpoch,
// });
// } else {
// Get.toNamed(onboardingScreen);
// }
}
void _freshDataAndToMainScreen(ProfileResponseModel userProfile) async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
......
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/screen/topup/topup_viewmodel.dart';
import 'package:mypoint_flutter_app/widgets/custom_navigation_bar.dart';
import 'package:mypoint_flutter_app/widgets/image_loader.dart';
......@@ -331,7 +332,7 @@ class _PhoneTopUpScreenState extends BaseState<PhoneTopUpScreen> with BasicState
),
const Spacer(),
ElevatedButton(
onPressed: _viewModel.validatePhoneNumber() ? () {
onPressed: _viewModel.phoneNumber.value.isPhoneValid() ? () {
Get.toNamed(
transactionDetailScreen,
arguments: {"product": product, "quantity": 1, "targetPhoneNumber": _viewModel.phoneNumber.value},
......
......@@ -10,7 +10,7 @@ import '../voucher/models/product_model.dart';
import '../voucher/models/product_type.dart';
class TopUpViewModel extends RestfulApiViewModel {
var histories = RxList<String>();
final RxList<String> histories = <String>[].obs;
final RxList<ProductBrandModel> topUpBrands = <ProductBrandModel>[].obs;
final RxList<ProductModel> products = <ProductModel>[].obs;
var selectedBrand = Rxn<ProductBrandModel>();
......@@ -25,23 +25,17 @@ class TopUpViewModel extends RestfulApiViewModel {
phoneNumber.value = myPhone;
ContactStorageService().getUsedContacts().then((value) {
if (value.isNotEmpty) {
histories.value = value;
histories.assignAll(value);
} else {
histories.value = [myPhone];
histories.assignAll([myPhone]);
}
});
if (!histories.contains(myPhone)) {
histories.value.insert(0, myPhone);
histories.insert(0, myPhone);
ContactStorageService().saveUsedContact(myPhone);
}
}
bool validatePhoneNumber() {
final phone = phoneNumber.value.replaceAll(RegExp(r'\s+'), '');
final regex = RegExp(r'^(0|\+84)(3[2-9]|5[6|8|9]|7[0|6-9]|8[1-5]|9[0-4|6-9])[0-9]{7}$');
return regex.hasMatch(phone);
}
firstLoadTopUpData() async {
_getTopUpBrands();
}
......@@ -50,7 +44,7 @@ class TopUpViewModel extends RestfulApiViewModel {
await callApi<List<ProductBrandModel>>(
request: () => client.getTopUpBrands(ProductType.topupMobile),
onSuccess: (data, _) {
topUpBrands.value = data;
topUpBrands.assignAll(data);
checkMobileNetwork();
},
showAppNavigatorDialog: true,
......@@ -66,7 +60,7 @@ class TopUpViewModel extends RestfulApiViewModel {
? topUpBrands.firstWhere(
(brand) => brand.code == brandCode,
orElse: () => topUpBrands.first,
) : topUpBrands.value.firstOrNull;
) : topUpBrands.firstOrNull;
selectedBrand.value = brand;
getTelcoDetail();
},
......@@ -102,7 +96,7 @@ class TopUpViewModel extends RestfulApiViewModel {
// Dùng cache nếu có
if (_allValue.containsKey(code)) {
final cached = _allValue[code]!;
products.value = cached;
products.assignAll(cached);
makeSelected(cached);
return;
}
......@@ -116,7 +110,7 @@ class TopUpViewModel extends RestfulApiViewModel {
request: () => client.getProducts(body),
onSuccess: (data, _) {
_allValue[code] = data;
products.value = data;
products.assignAll(data);
makeSelected(data);
},
showAppNavigatorDialog: true,
......
......@@ -25,14 +25,23 @@ class MyProductListViewModel extends RestfulApiViewModel {
"size": 20,
"status": selectedTabIndex.value,
};
if (isRefresh) {
showLoading();
}
client.getCustomerProducts(body).then((response) {
final result = response.data ?? [];
if (isRefresh) {
hideLoading();
myProducts.clear();
}
myProducts.addAll(result);
}).catchError((error) {
hideLoading();
if (isRefresh) {
myProducts.clear();
}
print('Error fetching products: $error');
});
}
......
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../base/base_screen.dart';
......@@ -37,7 +36,7 @@ class _VoucherListScreenState extends BaseState<VoucherListScreen> with BasicSta
isHotProduct = args['isHotProduct'] ?? false;
isFavorite = args['favorite'] ?? false;
_viewModel = Get.put(VoucherListViewModel(isHotProduct: isHotProduct, isFavorite: isFavorite));
_remainingSeconds = 10; //args['countDownSecond'] ?? 0;
_remainingSeconds = args['countDownSecond'] ?? 0;
_viewModel.submitCampaignViewVoucherResponse = (response) {
final popup = response.data?.popup;
if (popup != null) {
......@@ -55,28 +54,25 @@ class _VoucherListScreenState extends BaseState<VoucherListScreen> with BasicSta
});
}
@override
void dispose() {
_countdownTimer?.cancel();
super.dispose();
void onRouteWillDisappear() {
super.onRouteWillDisappear();
// Pause timer khi route bị che phủ (push sang màn hình khác)
_pauseCountdown();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Khi màn hình trở lại visible (route current) → resume timer nếu còn thời gian
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
if (isCurrent) {
void onRouteDidAppear() {
super.onRouteDidAppear();
// Resume timer khi route trở lại visible
_resumeCountdownIfNeeded();
}
}
@override
void deactivate() {
// Luôn pause khi rời màn hình để tránh timer chạy nền
void onDispose() {
_pauseCountdown();
super.deactivate();
print('VoucherListScreen deactivate');
super.onDispose();
}
void _startCountdownIfNeeded() {
......
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