Commit f1723336 authored by DatHV's avatar DatHV
Browse files

cập nhật ui, lịch sử điểm.. base networking

parent 38520c1e
assets/images/ic_pipi_04.png

39.5 KB | W: | H:

assets/images/ic_pipi_04.png

44.8 KB | W: | H:

assets/images/ic_pipi_04.png
assets/images/ic_pipi_04.png
assets/images/ic_pipi_04.png
assets/images/ic_pipi_04.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Mypoint Flutter App</string>
<string>MyPoint</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
......@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>mypoint_flutter_app</string>
<string>MyPoint</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
......@@ -55,5 +55,11 @@
<array>
<string>itms-apps</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Ứng dụng cần quyền Camera để quét mã.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Ứng dụng cần Micro khi quay video.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Ứng dụng cần quyền Lưu ảnh vào thư viện.</string>
</dict>
</plist>
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../configs/constants.dart';
class AppLoading {
// Singleton ẩn
static final AppLoading _i = AppLoading._();
factory AppLoading() => _i;
AppLoading._();
// Truyền key này cho GetMaterialApp (navigatorKey: Get.key)
// hoặc tự tạo GlobalKey<NavigatorState> riêng rồi thay vào đây.
OverlayState? _overlay; // cache sau khi app sẵn sàng
OverlayEntry? _entry;
Timer? _timer;
// Hàng đợi thao tác (insert/remove)
final Queue<void Function()> _ops = Queue();
bool _flushScheduled = false;
bool get isShowing => _entry != null;
/// Gọi 1 lần sau runApp để cache OverlayState.
void attach() {
if (_overlay != null) return;
// Lấy overlay ở post-frame để tránh visitChildElements trong build
WidgetsBinding.instance.addPostFrameCallback((_) {
_overlay = Get.key.currentState?.overlay
?? Overlay.of(Get.overlayContext ?? Get.context!, rootOverlay: true);
_scheduleFlush(); // có gì trong hàng đợi thì flush
});
}
/// Chỉ schedule flush khi framework rảnh (sau frame + microtask tiếp theo).
void _scheduleFlush() {
if (_flushScheduled) return;
_flushScheduled = true;
// Đẩy sang post-frame
WidgetsBinding.instance.addPostFrameCallback((_) {
// Và tiếp tục đẩy sang event-loop tiếp theo để chắc chắn đã qua build
Future.microtask(() {
_flushScheduled = false;
// Nếu overlay chưa sẵn, đợi frame sau nữa
if (_overlay == null) {
attach();
return;
}
while (_ops.isNotEmpty) {
final op = _ops.removeFirst();
op();
}
});
});
}
void show({
Duration timeout = const Duration(seconds: Constants.loadingTimeoutSeconds),
Color? barrierColor = const Color(0x33000000),
bool absorbPointers = true,
double size = 56,
double strokeWidth = 4,
}) {
// Đưa thao tác vào hàng đợi, không làm ngay
_ops.add(() {
if (isShowing) {
_timer?.cancel();
_timer = Timer(timeout, hide);
return;
}
final entry = OverlayEntry(
builder: (_) => Stack(
fit: StackFit.expand,
children: [
if (barrierColor != null)
const SizedBox.expand( // không dùng Positioned
child: IgnorePointer(ignoring: true, child: SizedBox()),
),
if (barrierColor != null)
ModalBarrier(color: barrierColor, dismissible: false),
IgnorePointer(
ignoring: !absorbPointers,
child: Center(
child: SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(strokeWidth: strokeWidth),
),
),
),
],
),
);
_overlay!.insert(entry);
_entry = entry;
_timer?.cancel();
_timer = Timer(timeout, hide);
});
attach();
_scheduleFlush();
}
void hide() {
_ops.add(() {
_timer?.cancel();
_timer = null;
_entry?.remove();
_entry = null;
});
attach();
_scheduleFlush();
}
}
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 '../networking/dio_http_service.dart';
import '../resources/base_color.dart';
import '../widgets/alert/custom_alert_dialog.dart';
......@@ -12,8 +13,6 @@ abstract class BaseScreen extends StatefulWidget {
}
abstract class BaseState<Screen extends BaseScreen> extends State<Screen> {
var isShowLoading = false;
@override
void initState() {
super.initState();
......@@ -46,9 +45,16 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> {
);
}
showAlertError({required String content, bool? barrierDismissible, String headerImage = "assets/images/ic_pipi_03.png", VoidCallback? onConfirmed}) {
showAlertError({
required String content,
bool? barrierDismissible,
String headerImage = "assets/images/ic_pipi_03.png",
bool showCloseButton = true,
VoidCallback? onConfirmed,
}) {
Get.dialog(
CustomAlertDialog(
showCloseButton: showCloseButton,
alertData: DataAlertModel(
localHeaderImage: headerImage,
title: "",
......@@ -85,39 +91,4 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> {
Widget? createBottomBar() {
return null;
}
showLoading({int timeout = receiveTimeout}) {
Future.delayed(Duration(seconds: timeout), () {
hideLoading();
});
Future.delayed(Duration.zero, () {
if (isShowLoading) return;
isShowLoading = true;
Get.dialog(
Center(
child: SizedBox(
width: 40,
height: 80,
child: Stack(
children: [
CircularProgressIndicator(),
],
),
),
),
barrierDismissible: false,
);
});
}
hideLoading() {
if (!isShowLoading) return;
isShowLoading = false;
try {
if (Get.isDialogOpen == true) {
Get.back();
}
} catch (_) {}
}
}
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:get/get.dart';
import '../networking/dio_http_service.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart';
import '../configs/constants.dart';
class BaseViewModel extends GetxController with WidgetsBindingObserver {
var isShowLoading = false;
......@@ -35,58 +37,11 @@ class BaseViewModel extends GetxController with WidgetsBindingObserver {
}
}
showLoading({int timeout = receiveTimeout}) {
Future.delayed(Duration(seconds: timeout), () {
hideLoading();
});
Future.delayed(Duration.zero, () {
if (isShowLoading) return;
isShowLoading = true;
Get.dialog(
Center(
child: SizedBox(
width: 40,
height: 80,
child: Stack(
children: [
CircularProgressIndicator(),
],
),
),
),
barrierDismissible: false,
);
});
showLoading({int timeout = Constants.loadingTimeoutSeconds}) {
AppLoading().show(timeout: Duration(seconds: timeout));
}
hideLoading() {
if (!isShowLoading) return;
isShowLoading = false;
try {
if (Get.isDialogOpen == true) {
Get.back();
}
} catch (_) {}
}
showMessage(BuildContext context, String message) {
fToast.init(context);
Widget toast = Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.0),
color: Colors.black.withValues(alpha: 0.8),
),
child: Text(
message,
style: TextStyle(color: Colors.white),
),
);
fToast.showToast(
child: toast,
gravity: ToastGravity.BOTTOM,
toastDuration: Duration(seconds: 2),
);
AppLoading().hide();
}
}
......@@ -109,4 +109,5 @@ class APIPaths {//sandbox
static const String bankAccountDelete = "/order/api/v1.0/payment/bank-accounts/%@/delete";
static const String transactionHistoryGetList = "/transactionHistoryGetList/1.0.0";
static const String transactionGetSummaryByDate = "/transactionGetSummaryByDate/1.0.0";
static const String getOfflineBrand = "/user/api/v2.0/offline/brand";
}
\ No newline at end of file
......@@ -4,6 +4,8 @@ class Constants {
static var otpTtl = 180;
static var directionInApp = "IN-APP";
static var phoneNumberCount = 10;
static var timeoutSeconds = 30;
static const loadingTimeoutSeconds = 10;
}
class ErrorCodes {
......
......@@ -97,6 +97,7 @@ enum DirectionalScreenName {
orderMenu,
unknown,
transactionHistories,
qrCode,
}
extension DirectionalScreenRouterExtension on DirectionalScreenName {
......@@ -317,6 +318,8 @@ extension DirectionalScreenNameExtension on DirectionalScreenName {
return "APP_SCREEN_ORDER_MENU";
case DirectionalScreenName.transactionHistories:
return "APP_SCREEN_TRANSACTION_HISTORIES";
case DirectionalScreenName.qrCode:
return "APP_SCREEN_QR_CODE";
}
}
......
......@@ -14,6 +14,16 @@ class DirectionalScreen {
const DirectionalScreen._({this.clickActionType, this.clickActionParam});
factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._(
clickActionType: json['click_action_type'] as String?,
clickActionParam: json['click_action_param'] as String?,
);
Map<String, dynamic> toJson() => {
'click_action_type': clickActionType,
'click_action_param': clickActionParam,
};
static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) {
if ((clickActionType ?? "").isEmpty) return null;
if (clickActionType == "VIEW_APP_SCREEN") {
......@@ -29,11 +39,6 @@ class DirectionalScreen {
return DirectionalScreen._(clickActionType: name.rawValue, clickActionParam: clickActionParam);
}
factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._(
clickActionType: json['click_action_type'] as String?,
clickActionParam: json['click_action_param'] as String?,
);
@immutable
bool begin() {
final type = DirectionalScreenNameExtension.fromRawValue(clickActionType ?? "");
......@@ -165,6 +170,9 @@ class DirectionalScreen {
case DirectionalScreenName.pointHistory:
Get.toNamed(historyPointScreen);
return true;
case DirectionalScreenName.qrCode:
Get.toNamed(qrCodeScreen);
return true;
default:
print("Không nhận diện được action type: $clickActionType");
return false;
......
import 'dart:typed_data';
import 'package:encrypt/encrypt.dart' as enc;
/// Giống Swift:
/// - `cipherHex`: chuỗi hex của dữ liệu đã mã hoá AES
/// - `secretKey`: chuỗi key (UTF-8), yêu cầu 16 bytes (AES-128)
class Crypto {
final String cipherHex;
final String secretKey;
const Crypto({required this.cipherHex, required this.secretKey});
/// Decrypt AES-128/ECB/PKCS7 từ hex -> String (UTF-8). Lỗi -> null.
String? decryption() {
try {
final keyBytes = _normalizeKeyUtf8(secretKey, 16); // AES-128 = 16 bytes
final dataBytes = _hexToBytes(cipherHex);
final key = enc.Key(keyBytes);
final aes = enc.AES(key, mode: enc.AESMode.ecb, padding: 'PKCS7');
final encrypter = enc.Encrypter(aes);
final decrypted = encrypter.decrypt(enc.Encrypted(dataBytes));
// ignore: avoid_print
print('Decrypted Text: $decrypted');
return decrypted;
} catch (e) {
// ignore: avoid_print
print('Decryption failed: $e');
return null;
}
}
/// Chuyển hex -> bytes
Uint8List _hexToBytes(String hex) {
final s = hex.replaceAll(RegExp(r'\s+'), '');
if (s.length % 2 != 0) {
throw const FormatException('Invalid hex length');
}
final result = Uint8List(s.length ~/ 2);
for (var i = 0; i < s.length; i += 2) {
result[i ~/ 2] = int.parse(s.substring(i, i + 2), radix: 16);
}
return result;
}
/// Key từ UTF-8, đảm bảo đúng `len` bytes: nếu thiếu thì pad 0x00, nếu dư thì cắt.
Uint8List _normalizeKeyUtf8(String key, int len) {
final raw = Uint8List.fromList(key.codeUnits); // UTF-8 code units (ASCII-safe)
if (raw.length == len) return raw;
if (raw.length > len) return Uint8List.fromList(raw.sublist(0, len));
// pad 0x00 đến đủ độ dài
final out = Uint8List(len);
out.setRange(0, raw.length, raw);
return out;
}
}
......@@ -18,6 +18,17 @@ extension NullableString on String? {
extension StringUrlExtension on String {
String get urlDecoded => Uri.decodeFull(this);
Uri? toUri() {
final s = trim();
if (s.isEmpty || s.contains(' ')) return null;
final uri = Uri.tryParse(s);
if (uri == null) return null;
// Phải là URL tuyệt đối + http/https
if (!uri.isAbsolute) return null;
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
return uri;
}
}
extension StringConvert on String {
......
......@@ -8,6 +8,7 @@ import 'package:mypoint_flutter_app/preference/point/point_manager.dart';
import 'package:mypoint_flutter_app/resources/base_color.dart';
import 'package:mypoint_flutter_app/screen/home/header_home_viewmodel.dart';
import 'package:mypoint_flutter_app/shared/router_gage.dart';
import 'base/app_loading.dart';
import 'networking/dio_http_service.dart';
void main() async {
......@@ -17,6 +18,7 @@ void main() async {
DioHttpService().setBaseUrl(APIPaths.baseUrl);
await UserPointManager().fetchUserPoint();
runApp(const MyApp());
AppLoading().attach();
}
class MyApp extends StatelessWidget {
......
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get/get_rx/src/rx_typedefs/rx_typedefs.dart';
import '../configs/constants.dart';
import '../preference/data_preference.dart';
import '../resources/base_color.dart';
......@@ -10,6 +11,9 @@ import '../widgets/alert/data_alert_model.dart';
class AppNavigator {
static final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
static bool _authDialogShown = false;
static bool _networkDialogShown = false;
static bool _errorDialogShown = false;
static BuildContext? get _ctx => key.currentContext;
static Future<void> showAuthAlertAndGoLogin(String message) async {
......@@ -40,24 +44,75 @@ class AppNavigator {
Get.dialog(CustomAlertDialog(alertData: dataAlert, showCloseButton: false), barrierDismissible: false);
}
static Future<void> showAlert(String message) async {
if (_authDialogShown || _ctx == null) return;
_authDialogShown = true;
static Future<void> showNoInternetAlert(String message, Callback retry, Callback close) async {
if (_networkDialogShown || _ctx == null) return;
_networkDialogShown = true;
final dataAlert = DataAlertModel(
title: "Thông Báo",
description: message.isNotEmpty ? message : ErrorCodes.serverErrorMessage,
localHeaderImage: "assets/images/ic_pipi_03.png",
buttons: [
AlertButton(
text: "Đã hiểu",
text: "Thử lại",
onPressed: () {
_authDialogShown = false;
_networkDialogShown = false;
Get.back();
retry();
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
),
AlertButton(
text: "Đóng",
onPressed: () {
_networkDialogShown = false;
Get.back();
close();
},
bgColor: BaseColor.second300,
textColor: Colors.white,
),
],
);
Get.dialog(CustomAlertDialog(alertData: dataAlert, showCloseButton: false), barrierDismissible: false);
Get.dialog(
CustomAlertDialog(alertData: dataAlert, showCloseButton: false, direction: ButtonsDirection.row),
barrierDismissible: false,
);
}
static showAlertError({
required String content,
bool? barrierDismissible,
String headerImage = "assets/images/ic_pipi_03.png",
bool showCloseButton = false,
VoidCallback? onConfirmed,
}) {
if (_errorDialogShown || _ctx == null) return;
_errorDialogShown = true;
Get.dialog(
CustomAlertDialog(
showCloseButton: showCloseButton,
alertData: DataAlertModel(
localHeaderImage: headerImage,
title: "",
description: content,
buttons: [
AlertButton(
text: "Đã Hiểu",
onPressed: () {
_errorDialogShown = false;
Get.back();
if (onConfirmed != null) {
onConfirmed();
}
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
),
],
),
),
barrierDismissible: barrierDismissible ?? false,
);
}
}
......@@ -15,27 +15,30 @@ class DioHttpService {
String _baseUrl = '';
Dio get dio => _dio;
late final Dio _dio = Dio(
BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: connectTimeout),
receiveTimeout: const Duration(seconds: receiveTimeout),
contentType: 'application/json',
responseType: ResponseType.json,
validateStatus: (_) => true,
receiveDataWhenStatusError: true,
),
)
..interceptors.add(AuthInterceptor())
..interceptors.add(RequestInterceptor())
..interceptors.add(ExceptionInterceptor())
..interceptors.add(InterceptorsWrapper(
onError: (e, h) {
if (e.response != null) return h.resolve(e.response!);
h.next(e);
},
))
..interceptors.addAll(kReleaseMode ? const [] : [LoggerInterceptor()]);
late final Dio _dio =
Dio(
BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: connectTimeout),
receiveTimeout: const Duration(seconds: receiveTimeout),
contentType: 'application/json',
responseType: ResponseType.json,
validateStatus: (_) => true,
receiveDataWhenStatusError: true,
),
)
..interceptors.add(RequestInterceptor())
..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 setBaseUrl(String newUrl) {
_baseUrl = newUrl;
......
import 'dart:io';
import 'package:dio/dio.dart';
class ErrorMapper {
static String map(Object e) {
if (e is DioException) {
switch (e.type) {
case DioExceptionType.connectionError:
if (e.error is SocketException) return 'Không có kết nối Internet.';
return 'Lỗi kết nối.';
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
return 'Kết nối chậm. Vui lòng thử lại.';
case DioExceptionType.badCertificate:
return 'Chứng chỉ không hợp lệ.';
case DioExceptionType.cancel:
return 'Yêu cầu đã huỷ.';
case DioExceptionType.badResponse:
return _extractErrorMessage(e.response?.data) ?? 'Đã xảy ra lỗi. Vui lòng thử lại!';
case DioExceptionType.unknown:
default:
return 'Đã có lỗi không xác định.';
}
}
if (e is SocketException) return 'Không có kết nối Internet.';
return 'Đã có lỗi xảy ra.';
}
static String? _extractErrorMessage(dynamic data) {
if (data is Map<String, dynamic>) {
return data['message']?.toString() ?? data['error_message']?.toString() ?? data['errorMessage']?.toString();
}
return null;
}
static bool isNetworkError(DioException e) {
return e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.error is SocketException;
}
}
\ No newline at end of file
......@@ -13,10 +13,12 @@ class AuthInterceptor extends Interceptor {
_handleAuthError(data);
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
requestOptions: response.requestOptions
..extra['mapped_error'] = ErrorCodes.tokenInvalidMessage,
response: response,
type: DioExceptionType.badResponse,
error: 'ERR_AUTH_TOKEN_INVALID',
message: ErrorCodes.tokenInvalidMessage,
),
);
return;
......@@ -25,11 +27,12 @@ class AuthInterceptor extends Interceptor {
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final data = err.response?.data;
final statusCode = err.response?.statusCode;
if (statusCode == 401 || _isTokenInvalid(data)) {
_handleAuthError(data);
await _handleAuthError(data);
return handler.reject(err);
}
handler.next(err);
}
......
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart';
import '../app_navigator.dart';
import '../dio_http_service.dart';
import '../error_mapper.dart';
class ExceptionInterceptor extends Interceptor {
static Completer<bool>? _currentRetryPrompt;
static bool _isPrompting = false;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_handleError(err);
handler.next(err);
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final apiError = ErrorMapper.map(err);
err.requestOptions.extra['mapped_error'] = apiError;
// return handler.next(err);
print('ExceptionInterceptor: onError: $apiError');
final extra = err.requestOptions.extra;
final silent = extra['silent'] == true;
// Nếu không phải network error hoặc không thể retry -> forward
if (!ErrorMapper.isNetworkError(err)) return handler.next(err);
if (silent) return handler.next(err);
print('ExceptionInterceptor: onError: $apiError');
// Chỉ cho phép retry với GET hoặc request được mark allow_retry
final allowRetry = _shouldAllowRetry(err.requestOptions);
if (!allowRetry) return handler.next(err);
print('ExceptionInterceptor: onError: $_isPrompting');
if (_isPrompting) return handler.next(err);
_isPrompting = true;
// ask user retry
final shouldRetry = await _askUserRetry(apiError);
if (!shouldRetry) {
_isPrompting = false;
return handler.next(err);
}
// retry
try {
final response = await _retryRequest(err.requestOptions);
return handler.resolve(response);
} catch (retryError) {
final retryException = retryError is DioException
? retryError
: DioException(requestOptions: err.requestOptions, error: retryError);
handler.reject(retryException);
} finally {
_isPrompting = false;
}
}
void _handleError(DioException error) {
String message;
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
message = 'Kết nối mạng không ổn định.\nVui lòng thử lại!';
break;
case DioExceptionType.connectionError:
message = 'Không thể kết nối đến máy chủ.\nVui lòng kiểm tra kết nối mạng!';
break;
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode == 401) {
// Auth error - handled by AuthInterceptor
return;
}
message = _extractErrorMessage(error.response?.data) ??
'Đã xảy ra lỗi. Vui lòng thử lại!';
break;
default:
message = 'Đã xảy ra lỗi không xác định. Vui lòng thử lại!';
/// Kiểm tra xem request có được phép retry không
bool _shouldAllowRetry(RequestOptions options) {
// GET request luôn được phép retry
if (options.method.toUpperCase() == 'GET') return true;
// Request được mark explicitly allow retry
if (options.extra['allow_retry'] == true) return true;
// Idempotent methods
const idempotentMethods = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'];
return idempotentMethods.contains(options.method.toUpperCase());
}
/// Hỏi user có muốn retry không (throttle để tránh multiple popups)
Future<bool> _askUserRetry(String message) async {
AppLoading().hide();
final existing = _currentRetryPrompt;
if (existing != null) {
return existing.future;
}
final completer = Completer<bool>();
_currentRetryPrompt = completer;
try {
AppNavigator.showNoInternetAlert(
message,
() => _completeRetryPrompt(completer, true), // Retry
() => _completeRetryPrompt(completer, false), // Close
);
return await completer.future;
} finally {
_currentRetryPrompt = null;
}
// Show alert dialog with the error message
AppNavigator.showAlert(message);
}
String? _extractErrorMessage(dynamic data) {
if (data is Map<String, dynamic>) {
return data['message']?.toString() ??
data['error_message']?.toString() ??
data['errorMessage']?.toString();
/// Complete retry prompt safely
void _completeRetryPrompt(Completer<bool> completer, bool result) {
if (!completer.isCompleted) {
completer.complete(result);
}
return null;
}
}
//
// typedef RetryPrompt = Future<bool> Function(DioException e);
//
// class ExceptionInterceptor extends Interceptor {
// ExceptionInterceptor({
// required Dio dio,
// RetryPrompt? prompt,
// this.shouldRetryWhen = _defaultShouldRetry,
// }) : _dio = dio,
// _prompt = prompt ?? _defaultPrompt;
//
// final Dio _dio;
// final RetryPrompt _prompt;
//
// /// Quyết định lỗi nào là lỗi mạng đáng retry.
// final bool Function(DioException e) shouldRetryWhen;
//
// static bool _defaultShouldRetry(DioException e) {
// return e.type == DioExceptionType.connectionTimeout ||
// e.type == DioExceptionType.sendTimeout ||
// e.type == DioExceptionType.receiveTimeout ||
// e.type == DioExceptionType.unknown ||
// e.type == DioExceptionType.connectionError;
// }
//
// static Future<bool> _defaultPrompt(DioException e) async {
// // Dialog đơn giản với GetX. Bạn có thể thay Alert tuỳ UI của app.
// final result = await Get.dialog<bool>(
// AlertDialog(
// title: const Text('Mất kết nối'),
// content: const Text('Kết nối mạng không ổn định. Bạn có muốn thử lại không?'),
// actions: [
// TextButton(onPressed: () => Get.back(result: false), child: const Text('Đóng')),
// ElevatedButton(onPressed: () => Get.back(result: true), child: const Text('Thử lại')),
// ],
// ),
// barrierDismissible: false,
// );
// return result == true;
// }
//
// @override
// void onError(DioException err, ErrorInterceptorHandler handler) async {
// if (!shouldRetryWhen(err)) {
// return handler.next(err);
// }
// if (err.requestOptions.extra['__retried__'] == true) {
// return handler.next(err);
// }
// final wantRetry = await _prompt(err);
// if (!wantRetry) {
// return handler.next(err);
// }
// try {
// final resp = await _retryRequest(err.requestOptions);
// return handler.resolve(resp);
// } catch (_) {
// return handler.next(err);
// }
// }
//
// Future<Response<dynamic>> _retryRequest(RequestOptions ro) {
// // Đánh dấu đã retry để tránh vòng lặp
// ro.extra['__retried__'] = true;
// final options = Options(
// method: ro.method,
// headers: ro.headers,
// responseType: ro.responseType,
// contentType: ro.contentType,
// followRedirects: ro.followRedirects,
// receiveDataWhenStatusError: ro.receiveDataWhenStatusError,
// validateStatus: ro.validateStatus,
// sendTimeout: ro.sendTimeout,
// receiveTimeout: ro.receiveTimeout,
// extra: ro.extra,
// );
//
// return _dio.request<dynamic>(
// ro.path,
// data: ro.data, // Lưu ý: nếu dùng FormData với stream tự chế, có thể không retry được
// queryParameters: ro.queryParameters,
// options: options,
// cancelToken: ro.cancelToken,
// onSendProgress: ro.onSendProgress,
// onReceiveProgress: ro.onReceiveProgress,
// );
// }
// }
/// Retry request với options mới
Future<Response<dynamic>> _retryRequest(RequestOptions originalOptions) {
final retryOptions = Options(
method: originalOptions.method,
headers: Map<String, dynamic>.from(originalOptions.headers),
responseType: originalOptions.responseType,
contentType: originalOptions.contentType,
sendTimeout: originalOptions.sendTimeout,
receiveTimeout: originalOptions.receiveTimeout,
followRedirects: originalOptions.followRedirects,
listFormat: originalOptions.listFormat,
validateStatus: originalOptions.validateStatus,
extra: {
...originalOptions.extra,
'silent': true,
'allow_retry': false,// Silent để không show popup lặp
},
);
return DioHttpService().dio.requestUri<dynamic>(
originalOptions.uri,
data: originalOptions.data,
options: retryOptions,
cancelToken: originalOptions.cancelToken,
onReceiveProgress: originalOptions.onReceiveProgress,
onSendProgress: originalOptions.onSendProgress,
);
}
}
import 'dart:async';
import 'dart:io';
class NetworkConnectivity {
NetworkConnectivity._();
static final NetworkConnectivity _ins = NetworkConnectivity._();
factory NetworkConnectivity() => _ins;
/// Kiểm tra nhanh có Internet hay không bằng DNS lookup.
/// Mặc định ping DNS Cloudflare: one.one.one.one
Future<bool> hasInternet({
String host = 'one.one.one.one',
Duration timeout = const Duration(seconds: 2),
}) async {
try {
final res = await InternetAddress.lookup(host).timeout(timeout);
return res.isNotEmpty && res.first.rawAddress.isNotEmpty;
} on SocketException catch (_) {
return false;
} on TimeoutException catch (_) {
return false;
} catch (_) {
return false;
}
}
/// Tiện ích: đảm bảo online trước khi làm việc.
/// Nếu offline -> gọi `showRetryDialog()` (trả về true nếu người dùng chọn Retry),
/// rồi kiểm tra lại 1 lần nữa.
Future<bool> ensureOnlineWithRetry({
required Future<bool> Function() showRetryDialog,
String host = 'one.one.one.one',
Duration timeout = const Duration(seconds: 2),
}) async {
if (await hasInternet(host: host, timeout: timeout)) return true;
final retry = await showRetryDialog();
if (!retry) return false;
return await hasInternet(host: host, timeout: timeout);
}
}
import 'package:dio/dio.dart';
import 'package:mypoint_flutter_app/preference/data_preference.dart';
import 'package:uuid/uuid.dart';
class RequestInterceptor extends Interceptor {
@override
......@@ -13,6 +14,7 @@ class RequestInterceptor extends Interceptor {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Accept-Language': 'vi',
'X-Request-Id': Uuid().v4(),
});
handler.next(options);
}
......
......@@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:mypoint_flutter_app/base/base_response_model.dart';
import 'package:mypoint_flutter_app/networking/interceptor/auth_interceptor.dart';
import '../configs/callbacks.dart';
import '../configs/constants.dart';
......@@ -31,12 +30,14 @@ class RestfulAPIClient {
String path,
Method method,
Json params,
T Function(dynamic json) parser,
) async {
T Function(dynamic json) parser, {
bool silent = false,
bool allowRetry = false,
}) async {
final isGet = method == Method.GET;
final query = isGet ? params : const <String, dynamic>{};
final body = isGet ? const <String, dynamic>{} : params;
final opt = _opts(method).compose(_dio.options, path, queryParameters: query, data: body);
final opt = _opts(method, silent, allowRetry).compose(_dio.options, path, queryParameters: query, data: body);
try {
final res = await _dio.fetch<dynamic>(opt);
final status = res.statusCode ?? 0;
......@@ -66,7 +67,12 @@ class RestfulAPIClient {
}
}
Options _opts(Method m) => Options(method: m.name, validateStatus: (_) => true, receiveDataWhenStatusError: true);
Options _opts(Method m, bool silent, bool allowRetry) => Options(
method: m.name,
validateStatus: (_) => true,
receiveDataWhenStatusError: true,
extra: {'silent': silent, 'allow_retry': allowRetry},
);
bool _isOk(int? code) => code != null && code >= 200 && code < 300;
/// Ép mọi kiểu body về Map:
......
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