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
f1723336
Commit
f1723336
authored
Sep 12, 2025
by
DatHV
Browse files
cập nhật ui, lịch sử điểm.. base networking
parent
38520c1e
Changes
47
Hide whitespace changes
Inline
Side-by-side
assets/images/ic_pipi_04.png
View replaced file @
38520c1e
View file @
f1723336
39.5 KB
|
W:
|
H:
44.8 KB
|
W:
|
H:
2-up
Swipe
Onion skin
ios/Runner/Info.plist
View file @
f1723336
...
...
@@ -7,7 +7,7 @@
<key>
CFBundleDevelopmentRegion
</key>
<string>
$(DEVELOPMENT_LANGUAGE)
</string>
<key>
CFBundleDisplayName
</key>
<string>
My
p
oint
Flutter App
</string>
<string>
My
P
oint
</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>
myp
oint
_flutter_app
</string>
<string>
MyP
oint
</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>
lib/base/app_loading.dart
0 → 100644
View file @
f1723336
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
();
}
}
lib/base/base_screen.dart
View file @
f1723336
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
(
_
)
{}
}
}
lib/base/base_view_model.dart
View file @
f1723336
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
();
}
}
lib/configs/api_paths.dart
View file @
f1723336
...
...
@@ -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
lib/configs/constants.dart
View file @
f1723336
...
...
@@ -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
{
...
...
lib/directional/directional_action_type.dart
View file @
f1723336
...
...
@@ -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"
;
}
}
...
...
lib/directional/directional_screen.dart
View file @
f1723336
...
...
@@ -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
;
...
...
lib/extensions/crypto.dart
0 → 100644
View file @
f1723336
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
;
}
}
lib/extensions/string_extension.dart
View file @
f1723336
...
...
@@ -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
{
...
...
lib/main.dart
View file @
f1723336
...
...
@@ -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
{
...
...
lib/networking/app_navigator.dart
View file @
f1723336
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
(
_
auth
DialogShown
||
_ctx
==
null
)
return
;
_
auth
DialogShown
=
true
;
static
Future
<
void
>
show
NoInternet
Alert
(
String
message
,
Callback
retry
,
Callback
close
)
async
{
if
(
_
network
DialogShown
||
_ctx
==
null
)
return
;
_
network
DialogShown
=
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
,
);
}
}
lib/networking/dio_http_service.dart
View file @
f1723336
...
...
@@ -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
;
...
...
lib/networking/error_mapper.dart
0 → 100644
View file @
f1723336
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
lib/networking/interceptor/auth_interceptor.dart
View file @
f1723336
...
...
@@ -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
);
}
...
...
lib/networking/interceptor/exception_interceptor.dart
View file @
f1723336
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.
\n
Vui lòng thử lại!'
;
break
;
case
DioExceptionType
.
connectionError
:
message
=
'Không thể kết nối đến máy chủ.
\n
Vui 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
,
);
}
}
lib/networking/interceptor/network_error_gate.dart
0 → 100644
View file @
f1723336
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
);
}
}
lib/networking/interceptor/request_interceptor.dart
View file @
f1723336
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
);
}
...
...
lib/networking/restful_api_client.dart
View file @
f1723336
...
...
@@ -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:
...
...
Prev
1
2
3
Next
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