Commit a0bcdab2 authored by DatHV's avatar DatHV
Browse files

refactor. update logic,

parent 9f4cb968
......@@ -2,7 +2,7 @@
## Tổng quan
Tài liệu này mô tả cách tích hợp mini app với `x-app-sdk` để lấy token và đóng app từ Super App. Implementation này đơn giản và chỉ sử dụng 2 API chính: `getToken()` `closeApp()`.
Tài liệu này mô tả cách tích hợp mini app với `x-app-sdk` và cách Flutter web wrapper (`XAppSDKService`) expose đầy đủ API như `getToken()`, `closeApp()`, `configUIApp()` cùng các tiện ích khác từ Super App.
## Cài đặt
......@@ -45,14 +45,70 @@ webCloseApp({
### Web Helper Functions
**Init & Diagnostics**
- `webInitializeXAppSDK()`: Khởi tạo x-app-sdk service
- `webGetToken()`: Lấy token từ Super App
- `webCloseApp(data)`: Đóng app và trả về Super App với data
- `webIsSDKInitialized()`: Kiểm tra SDK đã khởi tạo chưa
- `webGetToken()`: Lấy token từ Super App
- `webGetCachedToken()`: Lấy token đã cache
- `webGetLastError()`: Lấy error message cuối cùng
- `webClearTokenCache()`: Xóa token cache
- `webResetSDK()`: Reset SDK service
- `webCloseApp(data) -> bool`: Đóng app, trả về `true` nếu host xử lý thành công; `false` khi chạy browser mode (dùng fallback điều hướng tại Flutter)
**Config**
- `webConfigUIApp(config)`: Thiết lập UI trong Super App
```dart
import 'package:flutter/foundation.dart';
import 'package:mypoint_flutter_app/web/web_helper.dart';
Future<void> configureHeader() async {
if (!webIsSDKInitialized()) {
await webInitializeXAppSDK();
}
final response = await webConfigUIApp({
'headerTitle': 'Tên ứng dụng',
'headerColor': '#ffffff',
'headerTextColor': '#000000',
});
if (response != null) {
debugPrint('Cấu hình thành công: $response');
} else {
debugPrint('Cấu hình thất bại: ${webGetLastError()}');
}
}
```
**Device**
- `webCallPhone(phone)` / `webCall(phone)`: Gọi điện
- `webSendSms(phone)` / `webSms(phone)`: Mở app SMS
- `webVibrate()`: Rung thiết bị
**Location**
- `webCurrentLocation()`: Lấy vị trí hiện tại
- `webRequestLocationPermission()`: Xin quyền vị trí
**Media**
- `webOpenPickerImage(type)`: Mở image picker
- `webOpenPickerFile([options])`: Mở file picker
**Notification & Payment**
- `webListenNotificationEvent(onEvent)`: Lắng nghe notification
- `webPaymentRequest(payload)`: Gửi yêu cầu thanh toán
- `webListenPaymentEvent(onEvent)`: Lắng nghe sự kiện thanh toán
**Permission**
- `webPermissionsRequest(type)` / `webPremissionsRequest(type)`: Xin quyền theo SDK
**Store**
- `webSaveStore(data)`: Lưu dữ liệu
- `webGetStore()`: Lấy dữ liệu
- `webClearStore()`: Xóa dữ liệu
**User**
- `webGetInfo(key)`: Lấy thông tin user
### XAppSDKService
......@@ -66,12 +122,21 @@ await service.initialize();
String? token = await service.getToken();
// Đóng app
await service.closeApp({'message': 'Done'});
final closed = await service.closeApp({'message': 'Done'});
if (!closed) {
// Không có Super App host => fallback
Navigator.of(context).pushReplacementNamed(onboardingRoute);
}
// Kiểm tra trạng thái
bool isReady = service.isInitialized;
String? cachedToken = service.cachedToken;
String? error = service.lastError;
// Ví dụ lắng nghe notification
final removeNotification = await service.listenNotificationEvent((event) {
debugPrint('Notification event: $event');
});
```
## Luồng hoạt động
......
......@@ -4,4 +4,3 @@
"t3Token":"runner-env-flavor-dev",
"enableLogging":true
}
......@@ -64,6 +64,7 @@ class AppLoading {
double size = 56,
double strokeWidth = 4,
}) {
print('AppLoading.show called');
// Đưa thao tác vào hàng đợi, không làm ngay
_ops.add(() {
if (isShowing) {
......@@ -108,6 +109,7 @@ class AppLoading {
}
void hide() {
print('AppLoading.hide called');
_ops.add(() {
_timer?.cancel();
_timer = null;
......
......@@ -35,20 +35,26 @@ class AppNavigator {
buttons: [
AlertButton(
text: "Đã hiểu",
onPressed: () {
onPressed: () async {
_authDialogShown = false;
// if (kIsWeb) {
// webCloseApp({
// 'message': message.isNotEmpty ? message : description,
// 'timestamp': DateTime.now().millisecondsSinceEpoch,
// });
// return;
// }
final fallbackMessage = message.isNotEmpty ? message : description;
if (kIsWeb) {
await DataPreference.instance.clearData();
final closed = await webCloseApp({
'message': fallbackMessage,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
if (!closed) {
Get.offAllNamed(onboardingScreen);
}
return;
}
final phone = DataPreference.instance.phoneNumberUsedForLoginScreen;
if (phone.isNotEmpty) {
await DataPreference.instance.clearLoginToken();
Get.offAllNamed(loginScreen, arguments: {'phone': phone});
} else {
DataPreference.instance.clearData();
await DataPreference.instance.clearData();
Get.offAllNamed(onboardingScreen);
}
},
......@@ -114,7 +120,14 @@ class AppNavigator {
VoidCallback? onConfirmed,
}) {
print("Show alert error: $_errorDialogShown");
if (_errorDialogShown || _ctx == null) return;
if (_errorDialogShown) return;
final context = _ctx ?? Get.context ?? Get.overlayContext;
if (context == null) {
if (kDebugMode) {
print('⚠️ AppNavigator: Unable to show alert, no context available');
}
return;
}
_errorDialogShown = true;
Get.dialog(
CustomAlertDialog(
......
......@@ -41,6 +41,7 @@ class AppInitializer {
try {
// Initialize x-app-sdk
await webInitializeXAppSDK();
await _configureWebSdkHeader();
print('✅ Web features initialized successfully');
} catch (e) {
print('❌ Error initializing web features: $e');
......@@ -70,6 +71,21 @@ class AppInitializer {
}
}
static Future<void> _configureWebSdkHeader() async {
try {
final response = await webConfigUIApp({
'headerTitle': 'MyPoint',
'headerColor': '#E71D28',
'headerTextColor': '#ffffff',
});
if (response != null && kDebugMode) {
print('🧭 x-app-sdk header configured: $response');
}
} catch (error) {
print('❌ Failed to configure x-app-sdk header: $error');
}
}
/// Setup post-initialization callbacks
static void setupPostInitCallbacks() {
try {
......@@ -98,4 +114,4 @@ class AppInitializer {
});
} catch (_) {}
}
}
\ No newline at end of file
}
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'dart:io';
import '../env_loader.dart';
import 'interceptor/auth_interceptor.dart';
import 'interceptor/exception_interceptor.dart';
......@@ -34,14 +31,6 @@ class DioHttpService {
..interceptors.addAll(kReleaseMode ? const [] : [LoggerInterceptor()])
..interceptors.add(AuthInterceptor())
..interceptors.add(ExceptionInterceptor());
// ..interceptors.add(
// InterceptorsWrapper(
// onError: (e, h) {
// if (e.response != null) return h.resolve(e.response!);
// h.next(e);
// },
// ),
// );
void setDefaultHeaders(Map<String, dynamic> headers) {
dio.options.headers.addAll(headers);
......
......@@ -27,7 +27,7 @@ class AuthInterceptor extends Interceptor {
if (_isTokenInvalid(data)) {
response.requestOptions.extra[_kAuthHandledKey] = true;
// Kiểm tra xem path này có cần skip refresh token không
if (_shouldSkipRefreshToken(response.requestOptions.path) || response.requestOptions.method == 'GET') {
if (_shouldSkipRefreshToken(response.requestOptions.path)) {
handler.reject(
DioException(
requestOptions: response.requestOptions
......
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:universal_html/html.dart' as html;
class NetworkConnectivity {
NetworkConnectivity._();
......@@ -12,6 +14,14 @@ class NetworkConnectivity {
String host = 'one.one.one.one',
Duration timeout = const Duration(seconds: 2),
}) async {
if (kIsWeb) {
try {
final online = html.window.navigator.onLine;
return online ?? true;
} catch (_) {
return true;
}
}
try {
final res = await InternetAddress.lookup(host).timeout(timeout);
return res.isNotEmpty && res.first.rawAddress.isNotEmpty;
......
......@@ -59,6 +59,10 @@ class RestfulAPIClient {
_debug('DioException: $e');
final status = e.response?.statusCode;
final map = _asJson(e.response?.data);
final errorCode = map['error_code']?.toString() ?? map['errorCode']?.toString() ?? e.error?.toString();
if (errorCode != null && ErrorCodes.tokenInvalidCodes.contains(errorCode)) {
rethrow;
}
final msg = _extractMessage(map, status) ?? e.message ?? Constants.commonError;
return BaseResponseModel<T>(status: "fail", message: msg, data: null, code: status);
} catch (e) {
......@@ -118,4 +122,4 @@ class RestfulAPIClient {
print('=== API DEBUG === $e');
}
}
}
\ No newline at end of file
}
......@@ -169,7 +169,7 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
"device_key": deviceKey,
"lang": "vi",
};
return requestNormal(APIPaths.login, Method.POST, body, (data) => EmptyCodable.fromJson(data as Json));
return requestNormal(APIPaths.logout, Method.POST, body, (data) => EmptyCodable.fromJson(data as Json));
}
Future<BaseResponseModel<LoginTokenResponseModel>> loginWithBiometric(String phone) async {
......
......@@ -38,12 +38,10 @@ class RestfulApiViewModel extends BaseViewModel {
} else {
final msg = res.errorMessage ?? defaultError;
final hasInternet = await NetworkConnectivity().hasInternet();
if (hasInternet) {
if (showAppNavigatorDialog) {
AppNavigator.showAlertError(content: msg);
} else {
await onFailure?.call(msg, res, null);
}
if (showAppNavigatorDialog) {
AppNavigator.showAlertError(content: hasInternet ? msg : ErrorCodes.networkError);
} else {
await onFailure?.call(hasInternet ? msg : ErrorCodes.networkError, res, null);
}
}
} catch (e) {
......@@ -55,12 +53,10 @@ class RestfulApiViewModel extends BaseViewModel {
msg = ErrorMapper.map(e);
}
final hasInternet = await NetworkConnectivity().hasInternet();
if (hasInternet) {
if (showAppNavigatorDialog) {
AppNavigator.showAlertError(content: msg);
} else {
await onFailure?.call(msg, res, null);
}
if (showAppNavigatorDialog) {
AppNavigator.showAlertError(content: hasInternet ? msg : ErrorCodes.networkError);
} else {
await onFailure?.call(hasInternet ? msg : ErrorCodes.networkError, res, null);
}
} finally {
if (withLoading) hideLoading();
......@@ -82,4 +78,4 @@ class RestfulApiViewModel extends BaseViewModel {
onComplete?.call();
}
}
}
\ No newline at end of file
}
......@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../model/auth/login_token_response_model.dart';
import '../model/auth/profile_response_model.dart';
import '../screen/popup_manager/popup_manager_viewmodel.dart';
import '../web/web_helper.dart';
class DataPreference {
static final DataPreference _instance = DataPreference._internal();
......@@ -89,6 +90,7 @@ class DataPreference {
}
Future<void> clearData() async {
await webClearStore();
await clearLoginToken();
await clearUserProfile();
}
......@@ -128,4 +130,4 @@ class DataPreference {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('biometric_login_token_$phone');
}
}
\ No newline at end of file
}
......@@ -42,6 +42,7 @@ class _AffiliateTabScreenState extends BaseState<AffiliateTabScreen> with BasicS
showAffiliateBrandPopup(context, data.$1, title: data.$2);
};
runPopupCheck(DirectionalScreenName.pointBack);
viewModel.refreshData();
}
@override
......@@ -66,11 +67,10 @@ class _AffiliateTabScreenState extends BaseState<AffiliateTabScreen> with BasicS
],
),
body: Obx(() {
if (viewModel.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: viewModel.refreshData,
onRefresh: () async {
await viewModel.refreshData(isShowLoading: false);
},
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
child: Column(
......@@ -116,15 +116,15 @@ class _AffiliateTabScreenState extends BaseState<AffiliateTabScreen> with BasicS
],
),
),
AffiliateBrand(brands: viewModel.affiliateBrands),
AffiliateCategory(categories: viewModel.affiliateCategories, onTap: (category) {
AffiliateBrand(brands: viewModel.affiliateBrands.value),
AffiliateCategory(categories: viewModel.affiliateCategories.value, onTap: (category) {
if (category.code == AffiliateCategoryType.other) {
Get.toNamed(affiliateCategoryGridScreen, arguments: {"categories": viewModel.allAffiliateCategories});
return;
}
viewModel.affiliateBrandGetListBuyCategory(category);
},),
AffiliateProductTopSale(products: viewModel.affiliateProducts),
AffiliateProductTopSale(products: viewModel.affiliateProducts.value),
],
),
),
......
......@@ -16,21 +16,22 @@ class AffiliateTabViewModel extends RestfulApiViewModel {
final Rxn<CashbackOverviewModel> overview = Rxn<CashbackOverviewModel>();
void Function((List<AffiliateBrandModel>, String) data)? onShowAffiliateBrandPopup;
@override
void onInit() {
super.onInit();
refreshData();
_getAffiliateOverview();
bool get isDataAvailable {
return affiliateBrands.isNotEmpty ||
affiliateCategories.isNotEmpty ||
affiliateProducts.isNotEmpty;
}
Future<void> refreshData() async {
isLoading.value = true;
Future<void> refreshData({bool isShowLoading = true}) async {
if (isShowLoading && isDataAvailable) return;
if (isShowLoading) showLoading();
await Future.wait([
_getAffiliateOverview(),
_getAffiliateBrandGetList(),
_getAffiliateCategoryGetList(),
_getAffiliateProductTopSale(),
]);
isLoading.value = false;
if (isShowLoading) hideLoading();
}
Future<void> _getAffiliateBrandGetList() async {
......@@ -42,6 +43,7 @@ class AffiliateTabViewModel extends RestfulApiViewModel {
onFailure: (msg, _, __) async {
affiliateBrands.clear();
},
withLoading: false,
);
}
......@@ -60,10 +62,11 @@ class AffiliateTabViewModel extends RestfulApiViewModel {
limitedData.add(category);
affiliateCategories.assignAll(limitedData);
},
onFailure: (msg, _, __) async {
onFailure: (msg, _, _) async {
affiliateCategories.clear();
allAffiliateCategories.clear();
},
withLoading: false,
);
}
......@@ -76,6 +79,7 @@ class AffiliateTabViewModel extends RestfulApiViewModel {
onFailure: (msg, _, __) async {
affiliateProducts.clear();
},
withLoading: false,
);
}
......@@ -88,7 +92,7 @@ class AffiliateTabViewModel extends RestfulApiViewModel {
onFailure: (msg, _, __) async {
overview.value = null;
},
showAppNavigatorDialog: true,
withLoading: false,
);
}
......
......@@ -167,9 +167,9 @@ class _ChangePassScreenState extends BaseState<ChangePassScreen> with BasicState
localHeaderImage: "assets/images/ic_pipi_03.png",
buttons: [AlertButton(
text: "Đồng ý",
onPressed: () {
DataPreference.instance.clearLoginToken();
_safeBackToLogin();
onPressed: () async {
await DataPreference.instance.clearLoginToken();
await _safeBackToLogin();
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
......@@ -231,7 +231,7 @@ class _ChangePassScreenState extends BaseState<ChangePassScreen> with BasicState
});
}
void _safeBackToLogin() {
Future<void> _safeBackToLogin() async {
bool found = false;
Navigator.popUntil(Get.context!, (route) {
final matched = route.settings.name == loginScreen;
......@@ -244,7 +244,7 @@ class _ChangePassScreenState extends BaseState<ChangePassScreen> with BasicState
Get.offAllNamed(loginScreen, arguments: {'phone': phone});
}
} else {
DataPreference.instance.clearData();
await DataPreference.instance.clearData();
Get.offAllNamed(onboardingScreen);
}
}
......
......@@ -123,7 +123,11 @@ class _DeleteAccountDialogState extends State<DeleteAccountDialog> {
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _viewModel.agreed.value ? _onConfirmDelete : null,
onPressed: _viewModel.agreed.value
? () async {
await _onConfirmDelete();
}
: null,
style: AppButtonStyle.secondary,
child: const Text("Xác nhận xoá"),
),
......@@ -143,11 +147,11 @@ class _DeleteAccountDialogState extends State<DeleteAccountDialog> {
);
}
void _onConfirmDelete() {
Future<void> _onConfirmDelete() async {
if (DataPreference.instance.profile?.userAgreements?.hideDeleteAccount == false) {
_viewModel.confirmDelete();
await _viewModel.confirmDelete();
} else {
DataPreference.instance.clearData();
await DataPreference.instance.clearData();
Get.offAllNamed(onboardingScreen);
}
}
......
......@@ -67,46 +67,56 @@ class _GameTabScreenState extends BaseState<GameTabScreen> with BasicState, Popu
],
),
body: Obx(() {
if (_viewModel.games.isEmpty) {
return const Center(child: EmptyWidget());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_viewModel.turnsNumberText.value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
itemCount: _viewModel.games.length,
separatorBuilder: (_, __) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = _viewModel.games[index];
return GestureDetector(
onTap: () {
_viewModel.getGameDetail(item.id ?? "");
},
child: AspectRatio(
aspectRatio: 343 / 132,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: loadNetworkImage(
url: item.icon ?? '',
fit: BoxFit.fitWidth,
placeholderAsset: 'assets/images/bg_default_169.png',
),
),
),
);
final games = _viewModel.games;
return RefreshIndicator(
onRefresh: _viewModel.getGames,
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: games.isEmpty ? 1 : games.length + 1,
separatorBuilder: (_, index) {
if (games.isEmpty) return const SizedBox.shrink();
if (index == 0) {
return SizedBox(height: _viewModel.turnsNumberText.value.isEmpty ? 4 : 12);
}
return const SizedBox(height: 4);
},
itemBuilder: (context, index) {
if (games.isEmpty) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: const Center(child: EmptyWidget()),
);
}
if (index == 0) {
final turnsText = _viewModel.turnsNumberText.value;
if (turnsText.isEmpty) {
return const SizedBox.shrink();
}
return Text(
turnsText,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
);
}
final item = games[index - 1];
return GestureDetector(
onTap: () {
_viewModel.getGameDetail(item.id ?? "");
},
),
),
],
child: AspectRatio(
aspectRatio: 343 / 132,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: loadNetworkImage(
url: item.icon ?? '',
fit: BoxFit.fitWidth,
placeholderAsset: 'assets/images/bg_default_169.png',
),
),
),
);
},
),
);
}),
);
......
......@@ -3,7 +3,6 @@ import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.da
import 'package:mypoint_flutter_app/screen/game/models/game_bundle_item_model.dart';
import '../../networking/restful_api_viewmodel.dart';
import '../../configs/constants.dart';
import 'models/game_bundle_response.dart';
class GameTabViewModel extends RestfulApiViewModel {
......@@ -12,8 +11,8 @@ class GameTabViewModel extends RestfulApiViewModel {
void Function(String message)? onShowAlertError;
void Function(GameBundleItemModel data)? gotoGameDetail;
void getGames() {
callApi<GameBundleResponse>(
Future<void> getGames() {
return callApi<GameBundleResponse>(
request: () => client.getGames(),
onSuccess: (data, _) {
games.assignAll(data.games ?? []);
......@@ -36,4 +35,4 @@ class GameTabViewModel extends RestfulApiViewModel {
},
);
}
}
\ No newline at end of file
}
......@@ -121,3 +121,4 @@ class HealthBookItem extends StatelessWidget {
}
......@@ -19,15 +19,12 @@ class HeaderHomeViewModel extends RestfulApiViewModel {
greeting: 'Xin chào!',
totalVoucher: 0,
totalPointActive: 0,
background:
'https://api.mypoint.com.vn/8854/gup2start/rest/photoReader/1.0.0/F31FF2E775D7BFC940156709FB79E883/1746430303',
background: '',
);
}
Future<void> freshData() async {
if (_headerHomeData.value == null) {
await _getDynamicHeaderHome();
}
await _getDynamicHeaderHome();
await _getNotificationUnread();
}
......
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