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
b93b2948
Commit
b93b2948
authored
Oct 29, 2025
by
DatHV
Browse files
update logic handle dynamic branch link
parent
e9cf8244
Changes
14
Hide whitespace changes
Inline
Side-by-side
android/app/build.gradle.kts
View file @
b93b2948
...
...
@@ -44,6 +44,18 @@ android {
targetSdk
=
flutter
.
targetSdkVersion
versionCode
=
flutter
.
versionCode
versionName
=
flutter
.
versionName
manifestPlaceholders
.
putAll
(
mapOf
(
"branch_key_live"
to
"key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw"
,
"branch_key_test"
to
"key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV"
,
"branch_uri_scheme"
to
"mypointapp"
,
"branch_app_domain"
to
"mypoint.app.link"
,
"branch_app_domain_test"
to
"mypoint.test-app.link"
,
"branch_alt_domain"
to
"mypoint-alternate.app.link"
,
"branch_alt_domain_test"
to
"mypoint-alternate.test-app.link"
,
"branch_test_mode"
to
"false"
)
)
}
signingConfigs
{
...
...
@@ -97,6 +109,7 @@ android {
buildConfigField
(
"boolean"
,
"ENABLE_LOGGING"
,
"${env["
enableLogging
"]}"
)
applicationIdSuffix
=
".dev"
versionNameSuffix
=
"-dev"
manifestPlaceholders
[
"branch_test_mode"
]
=
"true"
}
create
(
"stg"
)
{
dimension
=
"environment"
...
...
android/app/proguard-rules.pro
0 → 100644
View file @
b93b2948
# Branch SDK
-
keep
class
io
.
branch
.
**
{
*
;
}
-
dontwarn
io
.
branch
.
**
#
Flutter
Branch
SDK
uses
reflection
on
Branch
plugin
classes
-
keep
class
flutter
.
plugins
.
flutter_branch_sdk
.
**
{
*
;
}
#
Retain
Kotlin
metadata
-
keepclassmembers
class
kotlin
.
Metadata
{
*
;
}
android/app/src/main/AndroidManifest.xml
View file @
b93b2948
...
...
@@ -40,7 +40,7 @@
<category
android:name=
"android.intent.category.BROWSABLE"
/>
<data
android:scheme=
"mypointapp"
/>
</intent-filter>
<intent-filter>
<intent-filter
android:autoVerify=
"true"
>
<action
android:name=
"android.intent.action.VIEW"
/>
<category
android:name=
"android.intent.category.DEFAULT"
/>
<category
android:name=
"android.intent.category.BROWSABLE"
/>
...
...
@@ -59,6 +59,30 @@
android:name=
"com.google.firebase.messaging.default_notification_channel_id"
android:value=
"default_channel"
/>
<meta-data
android:name=
"io.branch.sdk.BranchKey"
android:value=
"${branch_key_live}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchKey.test"
android:value=
"${branch_key_test}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchAppDomain"
android:value=
"${branch_app_domain}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchAppDomain.test"
android:value=
"${branch_app_domain_test}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchAlternateAppDomain"
android:value=
"${branch_alt_domain}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchAlternateAppDomain.test"
android:value=
"${branch_alt_domain_test}"
/>
<meta-data
android:name=
"io.branch.sdk.BranchUriScheme"
android:value=
"${branch_uri_scheme}"
/>
<meta-data
android:name=
"io.branch.sdk.TestMode"
android:value=
"${branch_test_mode}"
/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
...
...
ios/Runner/Info.plist
View file @
b93b2948
...
...
@@ -76,5 +76,19 @@
</array>
</dict>
</array>
<key>
branch_key
</key>
<dict>
<key>
live
</key>
<string>
key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw
</string>
<key>
test
</key>
<string>
key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV
</string>
</dict>
<key>
branch_universal_link_domains
</key>
<array>
<string>
mypoint.app.link
</string>
<string>
mypoint-alternate.app.link
</string>
<string>
mypoint.test-app.link
</string>
<string>
mypoint-alternate.test-app.link
</string>
</array>
</dict>
</plist>
ios/Runner/Runner.entitlements
View file @
b93b2948
...
...
@@ -12,6 +12,8 @@
<string>
applinks:mypointapp.page.link
</string>
<string>
applinks:mypoint-alternate.app.link
</string>
<string>
applinks:mypoint.app.link
</string>
<string>
applinks:mypoint-alternate.test-app.link
</string>
<string>
applinks:mypoint.test-app.link
</string>
</array>
<key>
com.apple.security.application-groups
</key>
<array>
...
...
lib/core/app_initializer.dart
View file @
b93b2948
...
...
@@ -11,6 +11,7 @@ import 'package:mypoint_flutter_app/firebase/push_setup.dart';
import
'package:mypoint_flutter_app/base/app_loading.dart'
;
import
'package:mypoint_flutter_app/env_loader.dart'
;
import
'package:mypoint_flutter_app/web/web_helper.dart'
;
import
'package:mypoint_flutter_app/core/deep_link_service.dart'
;
/// Main app initialization and setup
class
AppInitializer
{
...
...
@@ -31,6 +32,8 @@ class AppInitializer {
await
_fetchUserPointIfLoggedIn
();
// Initialize web-specific features (including x-app-sdk)
await
_initializeWebFeatures
();
// Initialize deep link handlers (Branch, URI schemes)
await
DeepLinkService
().
initialize
();
print
(
'✅ App initialization completed'
);
}
...
...
@@ -105,7 +108,9 @@ class AppInitializer {
static
Future
<
void
>
_handleInitialNotificationLaunch
()
async
{
try
{
final
initial
=
await
FirebaseMessaging
.
instance
.
getInitialMessage
();
print
(
'Checking initial message for app launch from terminated state...
$initial
'
);
print
(
'Checking initial message for app launch from terminated state...
$initial
'
,
);
if
(
initial
==
null
)
return
;
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
Future
.
delayed
(
const
Duration
(
seconds:
1
),
()
{
...
...
lib/core/deep_link_service.dart
View file @
b93b2948
import
'dart:async'
;
import
'package:flutter/foundation.dart'
;
import
'package:flutter_branch_sdk/flutter_branch_sdk.dart'
;
import
'package:mypoint_flutter_app/extensions/string_extension.dart'
;
import
'package:uni_links/uni_links.dart'
;
import
'package:mypoint_flutter_app/directional/directional_screen.dart'
;
import
'package:mypoint_flutter_app/extensions/crypto.dart'
as
mycrypto
;
import
'../directional/directional_action_type.dart'
;
class
DeepLinkService
{
DeepLinkService
.
_internal
();
static
final
DeepLinkService
_instance
=
DeepLinkService
.
_internal
();
factory
DeepLinkService
()
=>
_instance
;
StreamSubscription
?
_linkSub
;
StreamSubscription
<
Map
>?
_branchSub
;
bool
_initialized
=
false
;
Future
<
void
>
initialize
()
async
{
...
...
@@ -17,6 +22,7 @@ class DeepLinkService {
_initialized
=
true
;
if
(
kDebugMode
)
print
(
'🔗 Initializing DeepLinkService...'
);
await
_initBranchSdk
();
await
_handleInitialLink
();
_listenLinkStream
();
}
...
...
@@ -24,9 +30,32 @@ class DeepLinkService {
Future
<
void
>
dispose
()
async
{
await
_linkSub
?.
cancel
();
_linkSub
=
null
;
await
_branchSub
?.
cancel
();
_branchSub
=
null
;
_initialized
=
false
;
}
Future
<
void
>
_initBranchSdk
()
async
{
try
{
await
FlutterBranchSdk
.
init
(
enableLogging:
kDebugMode
);
if
(
kDebugMode
)
{
print
(
'🌿 Branch SDK init '
);
}
_branchSub
=
FlutterBranchSdk
.
listSession
().
listen
(
_handleBranchSession
,
onError:
(
error
)
{
if
(
kDebugMode
)
{
print
(
'❌ Branch session stream error:
$error
'
);
}
},
);
}
catch
(
e
)
{
if
(
kDebugMode
)
{
print
(
'❌ Failed to initialize Branch SDK:
$e
'
);
}
}
}
Future
<
void
>
_handleInitialLink
()
async
{
try
{
final
initial
=
await
getInitialLink
();
...
...
@@ -57,22 +86,56 @@ class DeepLinkService {
final
cipherHex
=
uri
.
queryParameters
[
'key'
];
if
(
cipherHex
!=
null
&&
cipherHex
.
isNotEmpty
)
{
// Try multiple known secrets (match iOS CommonAPI.schemeCryptKey variants)
const
candidates
=
<
String
>[
'mypointdeeplinkk'
,
'PVt3FWQibsB7xaLx'
,
];
const
candidates
=
<
String
>[
'mypointdeeplinkk'
,
'PVt3FWQibsB7xaLx'
];
for
(
final
secret
in
candidates
)
{
final
phone
=
mycrypto
.
Crypto
(
cipherHex:
cipherHex
,
secretKey:
secret
).
decryption
();
if
(
phone
!=
null
&&
phone
.
isNotEmpty
)
{
final
phone
=
mycrypto
.
Crypto
(
cipherHex:
cipherHex
,
secretKey:
secret
).
decryption
()
.
orEmpty
;
if
(
phone
.
isNotEmpty
)
{
if
(
kDebugMode
)
print
(
'🔐 Decrypted phone from key:
$phone
'
);
break
;
// Use if you need to attach to userInfo later
final
direction
=
DirectionalScreen
.
buildByName
(
name:
DirectionalScreenName
.
linkMBPAccount
,
clickActionParam:
phone
);
direction
?.
extraData
=
{
'password'
:
param
,
};
direction
?.
begin
();
return
;
// Use if you need to attach to userInfo later
}
}
}
final
screen
=
DirectionalScreen
.
build
(
clickActionType:
type
,
clickActionParam:
param
);
screen
?.
begin
();
}
}
void
_handleBranchSession
(
Map
<
dynamic
,
dynamic
>
data
)
{
if
(
kDebugMode
)
{
print
(
'🌿 Branch session data:
$data
'
);
}
final
dynamic
clickedLink
=
data
[
"+clicked_branch_link"
];
if
(
clickedLink
!=
true
&&
clickedLink
!=
"true"
)
{
return
;
}
final
type
=
_stringOrNull
(
data
[
Defines
.
actionType
])
??
_stringOrNull
(
data
[
'action_type'
]);
final
param
=
_stringOrNull
(
data
[
Defines
.
actionParams
])
??
_stringOrNull
(
data
[
'action_param'
]);
if
(
type
!=
null
)
{
final
screen
=
DirectionalScreen
.
build
(
clickActionType:
type
,
clickActionParam:
param
);
if
(
screen
!=
null
)
{
screen
.
begin
();
return
;
}
}
final
fallbackLink
=
_stringOrNull
(
data
[
'~referring_link'
])
??
_stringOrNull
(
data
[
'+url'
])
??
_stringOrNull
(
data
[
'deeplink'
]);
if
(
fallbackLink
!=
null
)
{
_routeFromUriString
(
fallbackLink
);
}
}
String
?
_stringOrNull
(
dynamic
value
)
{
if
(
value
is
String
&&
value
.
isNotEmpty
)
return
value
;
return
null
;
}
}
lib/directional/directional_action_type.dart
View file @
b93b2948
...
...
@@ -98,6 +98,7 @@ enum DirectionalScreenName {
unknown
,
transactionHistories
,
qrCode
,
linkMBPAccount
,
}
extension
DirectionalScreenRouterExtension
on
DirectionalScreenName
{
...
...
@@ -320,6 +321,8 @@ extension DirectionalScreenNameExtension on DirectionalScreenName {
return
"APP_SCREEN_TRANSACTION_HISTORIES"
;
case
DirectionalScreenName
.
qrCode
:
return
"APP_SCREEN_QR_CODE"
;
case
DirectionalScreenName
.
linkMBPAccount
:
return
"APP_SCREEN_LINK_MBP_ACCOUNT"
;
}
}
...
...
lib/directional/directional_screen.dart
View file @
b93b2948
...
...
@@ -11,10 +11,13 @@ import 'package:uuid/uuid.dart';
import
'../configs/constants.dart'
;
import
'../base/app_navigator.dart'
;
import
'../networking/restful_api_viewmodel.dart'
;
import
'../resources/base_color.dart'
;
import
'../screen/pageDetail/model/detail_page_rule_type.dart'
;
import
'../screen/pipi/pipi_detail_screen.dart'
;
import
'../screen/webview/web_view_screen.dart'
;
import
'../services/logout_service.dart'
;
import
'../shared/router_gage.dart'
;
import
'../widgets/alert/data_alert_model.dart'
;
import
'directional_action_type.dart'
;
class
Defines
{
...
...
@@ -26,8 +29,9 @@ class DirectionalScreen {
final
String
?
clickActionType
;
final
String
?
clickActionParam
;
final
PopupDataModel
?
popup
;
Map
<
String
,
dynamic
>?
extraData
=
{};
const
DirectionalScreen
.
_
({
this
.
clickActionType
,
this
.
clickActionParam
,
this
.
popup
});
DirectionalScreen
.
_
({
this
.
clickActionType
,
this
.
clickActionParam
,
this
.
popup
});
factory
DirectionalScreen
.
fromJson
(
Map
<
String
,
dynamic
>
json
)
=>
DirectionalScreen
.
_
(
clickActionType:
json
[
'click_action_type'
]
as
String
?,
...
...
@@ -307,11 +311,52 @@ class DirectionalScreen {
);
}();
return
true
;
case
DirectionalScreenName
.
linkMBPAccount
:
if
((
clickActionParam
??
''
).
isEmpty
)
return
false
;
_handleLinkMBPAccount
();
return
true
;
default
:
print
(
"Không nhận diện được action type:
$clickActionType
"
);
return
false
;
}
}
void
_handleLinkMBPAccount
()
{
final
phone
=
clickActionParam
.
orEmpty
;
if
(
phone
.
isEmpty
)
return
;
final
password
=
extraData
?[
'password'
]
as
String
?
??
''
;
if
(!
DataPreference
.
instance
.
logged
)
{
_gotoLoginScreen
(
phone
,
password
);
return
;
}
final
currentPhone
=
DataPreference
.
instance
.
phone
.
orEmpty
;
if
(
phone
==
currentPhone
||
currentPhone
.
isEmpty
)
return
;
final
dataAlert
=
DataAlertModel
(
title:
"Xác nhận"
,
description:
"Bạn muốn đăng xuất để login tài khoản(
$phone
) vừa liên kết không?"
,
localHeaderImage:
"assets/images/ic_pipi_03.png"
,
buttons:
[
AlertButton
(
text:
"Đồng ý"
,
onPressed:
()
async
{
Get
.
back
();
_gotoLoginScreen
(
phone
,
password
);
print
(
"Đồng ý đăng xuất để liên kết tài khoản
$phone
"
);
},
bgColor:
BaseColor
.
primary500
,
textColor:
Colors
.
white
,
),
AlertButton
(
text:
"Huỷ"
,
onPressed:
()
=>
Get
.
back
(),
bgColor:
Colors
.
white
,
textColor:
BaseColor
.
second500
),
],
);
AppNavigator
.
showAlert
(
data:
dataAlert
,
showCloseButton:
false
);
}
Future
<
void
>
_gotoLoginScreen
(
String
phone
,
String
password
)
async
{
await
LogoutService
.
logout
();
await
DataPreference
.
instance
.
clearData
();
Get
.
offAllNamed
(
loginScreen
,
arguments:
{
"phone"
:
phone
,
'password'
:
password
});
}
}
Future
<
bool
>
forceOpen
({
required
Uri
url
,
LaunchMode
mode
=
LaunchMode
.
platformDefault
})
async
{
...
...
lib/screen/login/login_screen.dart
View file @
b93b2948
...
...
@@ -30,9 +30,11 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
void
initState
()
{
super
.
initState
();
final
args
=
Get
.
arguments
;
String
autoPass
=
''
;
if
(
args
is
Map
)
{
phoneNumber
=
args
[
'phone'
];
fullName
=
args
[
'fullName'
]
??
'Quý khách'
;
autoPass
=
args
[
'password'
]
??
''
;
}
loginVM
.
onShowChangePass
=
(
message
)
{
Get
.
dialog
(
...
...
@@ -101,6 +103,14 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
_focusNode
.
requestFocus
();
});
if
(
autoPass
.
isNotEmpty
)
{
_phoneController
.
text
=
autoPass
;
loginVM
.
password
.
value
=
autoPass
;
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
loginVM
.
onLoginPressed
(
phoneNumber
);
});
}
}
@override
...
...
@@ -135,7 +145,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
children:
[
Text
(
"Đăng nhập"
,
style:
TextStyle
(
color:
BaseColor
.
second600
,
fontSize:
24
,
fontWeight:
FontWeight
.
bold
),
style:
TextStyle
(
color:
BaseColor
.
second600
,
fontSize:
30
,
fontWeight:
FontWeight
.
bold
),
),
const
SizedBox
(
height:
16
),
_buildWelcomeText
(
loginVM
),
...
...
lib/screen/webview/payment_web_view_screen.dart
View file @
b93b2948
...
...
@@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart';
import
'../../base/base_screen.dart'
;
import
'../../base/basic_state.dart'
;
import
'../../directional/directional_screen.dart'
;
import
'../../resources/base_color.dart'
;
import
'../../shared/router_gage.dart'
;
import
'../../widgets/alert/data_alert_model.dart'
;
...
...
@@ -46,6 +47,7 @@ enum PaymentProcess {
}
}
/// Data required to kick off a payment session.
class
PaymentWebViewInput
{
final
String
url
;
final
String
orderId
;
...
...
@@ -69,9 +71,11 @@ class PaymentWebViewScreen extends BaseScreen {
State
<
PaymentWebViewScreen
>
createState
()
=>
_PaymentWebViewScreenState
();
}
/// Handles payment flows in an embedded WebView while listening for
/// provider callbacks and native schemes.
class
_PaymentWebViewScreenState
extends
BaseState
<
PaymentWebViewScreen
>
with
BasicState
{
late
final
PaymentWebViewInput
input
;
late
final
WebViewController
_
c
ontroller
;
WebViewController
?
_
webViewC
ontroller
;
bool
_isLoading
=
true
;
final
List
<
String
>
paymentSuccessUrls
=
[
...
...
@@ -88,36 +92,49 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
@override
void
initState
()
{
super
.
initState
();
if
(!
_hydrateArguments
())
return
;
if
(
_handleWebPlatformLaunch
())
return
;
_initializeMobileWebView
();
}
bool
_hydrateArguments
()
{
final
args
=
Get
.
arguments
;
if
(
args
is
!
PaymentWebViewInput
)
{
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
Get
.
back
();
});
return
;
if
(
args
is
PaymentWebViewInput
)
{
input
=
args
;
return
true
;
}
input
=
args
;
// Web platform: mở URL trong tab mới và đóng màn hình ngay
if
(
kIsWeb
)
{
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
async
{
await
_openUrlInBrowser
();
if
(
mounted
)
{
Get
.
back
();
}
});
return
;
}
// Mobile platform: khởi tạo WebView
_controller
=
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
if
(
Get
.
key
.
currentState
?.
canPop
()
??
false
)
{
Get
.
back
();
}
else
if
(
mounted
)
{
Navigator
.
of
(
context
).
maybePop
();
}
});
return
false
;
}
bool
_handleWebPlatformLaunch
()
{
if
(!
kIsWeb
)
return
false
;
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
async
{
await
_openUrlInBrowser
();
if
(
mounted
&&
Navigator
.
of
(
context
).
canPop
())
{
Navigator
.
of
(
context
).
pop
();
}
});
return
true
;
}
void
_initializeMobileWebView
()
{
_webViewController
=
WebViewController
()
..
setJavaScriptMode
(
JavaScriptMode
.
unrestricted
)
..
addJavaScriptChannel
(
'MyPoint'
,
onMessageReceived:
(
JavaScriptMessage
message
)
{
final
data
=
message
.
message
;
debugPrint
(
'📩 JS Message:
$data
'
);
// Expect JSON string with {"event":"payment_result","status":"success|failure"}
if
(
kDebugMode
)
{
debugPrint
(
'📩 JS Message:
$data
'
);
}
if
(
data
.
contains
(
'payment_result'
))
{
if
(
data
.
contains
(
'success'
))
{
_onPaymentResult
(
PaymentProcess
.
success
);
...
...
@@ -129,20 +146,16 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
)
..
setNavigationDelegate
(
NavigationDelegate
(
onPageStarted:
(
_
)
{
setState
(()
{
_isLoading
=
true
;
});
},
onPageFinished:
(
_
)
{
setState
(()
{
_isLoading
=
false
;
});
},
onPageStarted:
(
_
)
=>
_setLoading
(
true
),
onPageFinished:
(
_
)
=>
_setLoading
(
false
),
onNavigationRequest:
_handleNavigation
,
onWebResourceError:
(
error
)
{
debugPrint
(
'❌ WebView error:
${error.description}
'
);
_onPaymentResult
(
PaymentProcess
.
failure
);
},
),
)
..
loadRequest
(
Uri
.
parse
(
input
.
url
)
);
)
;
_loadInitialPage
(
);
}
@override
...
...
@@ -153,9 +166,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
appBar:
CustomNavigationBar
(
title:
"Thanh toán"
,
leftButtons:
[
CustomBackButton
(
onPressed:
()
=>
Get
.
back
(),
),
CustomBackButton
(
onPressed:
()
=>
Get
.
back
()),
],
),
body:
const
Center
(
...
...
@@ -170,8 +181,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
),
);
}
// Mobile platform: hiển thị WebView
return
Scaffold
(
appBar:
CustomNavigationBar
(
title:
"Thanh toán"
,
...
...
@@ -190,7 +200,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
body:
Stack
(
children:
[
WebViewWidget
(
controller:
_
c
ontroller
,
controller:
_
webViewC
ontroller
!
,
gestureRecognizers:
const
<
Factory
<
OneSequenceGestureRecognizer
>>{
Factory
<
VerticalDragGestureRecognizer
>(
VerticalDragGestureRecognizer
.
new
),
},
...
...
@@ -201,9 +211,41 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
);
}
void
_loadInitialPage
()
{
final
formatted
=
formatUrl
(
input
.
url
);
final
uri
=
Uri
.
tryParse
(
formatted
);
if
(
uri
==
null
)
{
debugPrint
(
'❌ Invalid payment URL:
${input.url}
'
);
_onPaymentResult
(
PaymentProcess
.
failure
);
return
;
}
_webViewController
?.
loadRequest
(
uri
);
}
void
_setLoading
(
bool
active
)
{
if
(
_isLoading
==
active
)
return
;
if
(!
mounted
)
{
_isLoading
=
active
;
return
;
}
setState
(()
{
_isLoading
=
active
;
});
}
String
formatUrl
(
String
rawUrl
)
{
if
(
rawUrl
.
isEmpty
)
return
rawUrl
;
if
(
rawUrl
.
startsWith
(
'http://'
)
||
rawUrl
.
startsWith
(
'https://'
))
{
return
rawUrl
;
}
return
'https://
$rawUrl
'
;
}
NavigationDecision
_handleNavigation
(
NavigationRequest
request
)
{
final
url
=
request
.
url
;
debugPrint
(
"➡️ Navigating:
$url
"
);
if
(
kDebugMode
)
{
debugPrint
(
"➡️ Navigating:
$url
"
);
}
if
(
paymentSuccessUrls
.
any
((
success
)
=>
url
.
startsWith
(
success
)))
{
_onPaymentResult
(
PaymentProcess
.
success
);
return
NavigationDecision
.
prevent
;
...
...
@@ -219,6 +261,9 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
);
return
NavigationDecision
.
prevent
;
}
if
(
kDebugMode
)
{
debugPrint
(
"🔗 Handling URL scheme:
${uri?.scheme}
"
);
}
// Xử lý chung mypointapp:// và các scheme ngoài http/https
if
(
uri
!=
null
)
{
// mypointapp://open?click_action_type=PAYMENT_SUCCESS|PAYMENT_FAIL
...
...
@@ -232,9 +277,15 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
_onPaymentResult
(
PaymentProcess
.
failure
);
return
NavigationDecision
.
prevent
;
}
// Các action khác: cố mở ngoài ứng dụng
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
);
return
NavigationDecision
.
prevent
;
final
direction
=
DirectionalScreen
.
build
(
clickActionType:
action
,
clickActionParam:
uri
.
queryParameters
[
'click_action_param'
]
??
''
,
);
final
directionSuccess
=
direction
?.
begin
();
if
(
directionSuccess
!=
true
)
{
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
);
return
NavigationDecision
.
prevent
;
}
}
// Bất kỳ scheme không phải http/https: cố gắng mở ngoài
if
(
uri
.
scheme
.
isNotEmpty
&&
uri
.
scheme
!=
'http'
&&
uri
.
scheme
!=
'https'
)
{
...
...
@@ -267,7 +318,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
/// Mở URL trong browser (web platform)
Future
<
void
>
_openUrlInBrowser
()
async
{
try
{
final
uri
=
Uri
.
parse
(
input
.
url
);
final
uri
=
Uri
.
parse
(
formatUrl
(
input
.
url
)
)
;
await
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
,
...
...
@@ -277,7 +328,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
// Fallback: mở trong tab hiện tại
try
{
await
launchUrl
(
Uri
.
parse
(
input
.
url
),
Uri
.
parse
(
formatUrl
(
input
.
url
)
)
,
mode:
LaunchMode
.
platformDefault
,
);
}
catch
(
e2
)
{
...
...
@@ -301,7 +352,10 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
AlertButton
(
text:
"Dừng thanh toán"
,
onPressed:
()
{
Get
.
offNamed
(
transactionHistoryDetailScreen
,
arguments:
{
"orderId"
:
input
.
orderId
??
""
,
"canBack"
:
false
});
Get
.
offNamed
(
transactionHistoryDetailScreen
,
arguments:
{
"orderId"
:
input
.
orderId
,
"canBack"
:
false
},
);
},
bgColor:
Colors
.
white
,
textColor:
BaseColor
.
second500
,
...
...
lib/screen/webview/web_view_screen.dart
View file @
b93b2948
import
'dart:async'
;
import
'dart:convert'
;
import
'package:flutter/foundation.dart'
;
import
'package:flutter/gestures.dart'
;
import
'package:flutter/material.dart'
;
import
'package:get/get.dart'
;
import
'package:mypoint_flutter_app/widgets/back_button.dart'
;
import
'package:mypoint_flutter_app/widgets/custom_toast_message.dart'
;
import
'package:url_launcher/url_launcher.dart'
;
import
'package:webview_flutter/webview_flutter.dart'
;
import
'package:image_gallery_saver/image_gallery_saver.dart'
;
import
'package:permission_handler/permission_handler.dart'
;
import
'../../base/app_loading.dart'
;
import
'../../base/base_screen.dart'
;
import
'../../base/basic_state.dart'
;
...
...
@@ -13,6 +18,7 @@ import '../../widgets/custom_navigation_bar.dart';
import
'../../preference/data_preference.dart'
;
import
'../../preference/package_info.dart'
;
/// Payload for launching [BaseWebViewScreen].
class
BaseWebViewInput
{
final
String
?
title
;
final
String
url
;
...
...
@@ -32,10 +38,12 @@ class BaseWebViewScreen extends BaseScreen {
State
<
BaseWebViewScreen
>
createState
()
=>
_BaseWebViewScreenState
();
}
/// Hosts a platform-aware WebView that mirrors native behaviour on iOS/Android
/// while delegating to the browser on Flutter web builds.
class
_BaseWebViewScreenState
extends
BaseState
<
BaseWebViewScreen
>
with
BasicState
{
late
final
BaseWebViewInput
input
;
WebViewController
?
_
c
ontroller
;
// Null
able cho web platform
WebViewController
?
_
webViewC
ontroller
;
// Null
khi chạy Flutter web
String
?
_dynamicTitle
;
Map
<
String
,
String
>?
_authHeaders
;
bool
_isReissuingNavigation
=
false
;
...
...
@@ -43,32 +51,42 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
@override
void
initState
()
{
super
.
initState
();
if
(!
_hydrateArguments
())
return
;
if
(
_handleWebPlatformLaunch
())
return
;
_initializeMobileController
();
}
bool
_hydrateArguments
()
{
final
args
=
Get
.
arguments
;
if
(
args
is
BaseWebViewInput
&&
args
.
url
.
isNotEmpty
)
{
input
=
args
;
}
else
{
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
Get
.
back
();
});
return
;
}
// Web platform: mở URL trong tab mới và đóng màn hình ngay
if
(
kIsWeb
)
{
AppLoading
().
hide
();
Future
.
microtask
(()
async
{
await
_openUrlInBrowser
();
if
(
mounted
)
{
if
(
Navigator
.
of
(
context
).
canPop
())
{
Navigator
.
of
(
context
).
pop
();
}
}
});
return
;
return
true
;
}
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
if
(
Get
.
key
.
currentState
?.
canPop
()
??
false
)
{
Get
.
back
();
}
else
if
(
mounted
)
{
Navigator
.
of
(
context
).
maybePop
();
}
});
return
false
;
}
// Mobile platform: khởi tạo WebView
bool
_handleWebPlatformLaunch
()
{
if
(!
kIsWeb
)
return
false
;
AppLoading
().
hide
();
Future
.
microtask
(()
async
{
await
_openUrlInBrowser
();
if
(
mounted
&&
Navigator
.
of
(
context
).
canPop
())
{
Navigator
.
of
(
context
).
pop
();
}
});
return
true
;
}
void
_initializeMobileController
()
{
AppLoading
().
show
();
_
c
ontroller
=
_
webViewC
ontroller
=
WebViewController
()
..
setJavaScriptMode
(
JavaScriptMode
.
unrestricted
)
..
setBackgroundColor
(
Colors
.
transparent
)
...
...
@@ -76,7 +94,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
NavigationDelegate
(
onPageFinished:
(
_
)
async
{
AppLoading
().
hide
();
final
title
=
await
_
c
ontroller
!.
getTitle
();
final
title
=
await
_
webViewC
ontroller
!.
getTitle
();
setState
(()
{
_dynamicTitle
=
title
;
});
...
...
@@ -132,7 +150,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
children:
[
SafeArea
(
child:
WebViewWidget
(
controller:
_
c
ontroller
!,
controller:
_
webViewC
ontroller
!,
gestureRecognizers:
const
<
Factory
<
OneSequenceGestureRecognizer
>>{
Factory
<
VerticalDragGestureRecognizer
>(
VerticalDragGestureRecognizer
.
new
,
...
...
@@ -165,8 +183,8 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
}
// Mobile: kiểm tra WebView có thể go back không
if
(
_
c
ontroller
!=
null
&&
await
_
c
ontroller
!.
canGoBack
())
{
_
c
ontroller
!.
goBack
();
if
(
_
webViewC
ontroller
!=
null
&&
await
_
webViewC
ontroller
!.
canGoBack
())
{
_
webViewC
ontroller
!.
goBack
();
}
else
{
if
(
context
.
mounted
)
Navigator
.
of
(
context
).
pop
();
}
...
...
@@ -195,6 +213,10 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
if
(
url
.
isEmpty
||
url
==
'about:blank'
)
{
return
NavigationDecision
.
prevent
;
}
if
(
_isDataImageUrl
(
url
))
{
_processDataImageUrl
(
url
);
return
NavigationDecision
.
prevent
;
}
if
(
url
.
startsWith
(
'itms-apps://'
))
{
openStringUrlExternally
(
url
);
return
NavigationDecision
.
prevent
;
...
...
@@ -204,13 +226,18 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
launchUrl
(
uri
);
return
NavigationDecision
.
prevent
;
}
if
(
kDebugMode
)
{
print
(
'🔗 Handling navigation to URL:
$url
'
);
}
if
(
_isReissuingNavigation
)
{
_isReissuingNavigation
=
false
;
return
NavigationDecision
.
navigate
;
}
if
(
_shouldAttachHeaders
(
url
))
{
if
(
kDebugMode
)
{
print
(
'🔄 Reissuing navigation with headers to URL:
$url
'
);
}
try
{
final
target
=
Uri
.
parse
(
url
);
_loadWithHeaders
(
target
);
...
...
@@ -221,10 +248,36 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
}
}
}
final
uri
=
Uri
.
tryParse
(
url
);
if
(
uri
!=
null
)
{
if
(
uri
.
scheme
==
'mypointapp'
)
{
final
action
=
uri
.
queryParameters
[
'click_action_type'
]
??
''
;
final
direction
=
DirectionalScreen
.
build
(
clickActionType:
action
,
clickActionParam:
''
,
);
final
directionSuccess
=
direction
?.
begin
();
if
(
directionSuccess
!=
true
)
{
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
);
return
NavigationDecision
.
prevent
;
}
}
// Bất kỳ scheme không phải http/https: cố gắng mở ngoài
if
(
uri
.
scheme
.
isNotEmpty
&&
uri
.
scheme
!=
'http'
&&
uri
.
scheme
!=
'https'
)
{
launchUrl
(
uri
,
mode:
LaunchMode
.
externalApplication
);
return
NavigationDecision
.
prevent
;
}
}
if
(
kDebugMode
)
{
print
(
'✅ Allowing navigation to URL:
$url
'
);
}
return
NavigationDecision
.
navigate
;
}
/// Performs the first load by cleaning stale cookies, attaching auth headers,
/// and retrying without headers when the secure load fails (e.g., CORS).
Future
<
void
>
_prepareInitialLoad
()
async
{
await
_clearCookies
();
final
formattedUrl
=
formatUrl
(
input
.
url
);
...
...
@@ -252,10 +305,11 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
'WebView load with headers failed:
$e
. Retrying without headers.'
,
);
}
await
_
c
ontroller
?.
loadRequest
(
uri
);
await
_
webViewC
ontroller
?.
loadRequest
(
uri
);
}
}
/// Synchronises token cookies with the WebView so native and web share a session.
Future
<
void
>
_syncAuthCookie
(
Uri
uri
)
async
{
if
(!
DataPreference
.
instance
.
logged
)
return
;
final
token
=
DataPreference
.
instance
.
token
?.
trim
();
...
...
@@ -283,21 +337,22 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
'Cookie'
:
'access_token=
$token
'
};
}
catch
(
_
)
{
return
{
'access_token'
:
token
,
'Cookie'
:
'access_token=
$token
'
};
return
{
'access_token'
:
token
,
'Cookie'
:
'access_token=
$token
'
};
}
}
Future
<
void
>
_loadWithHeaders
(
Uri
uri
)
async
{
if
(
_authHeaders
==
null
||
_authHeaders
!.
isEmpty
)
{
await
_
c
ontroller
?.
loadRequest
(
uri
);
await
_
webViewC
ontroller
?.
loadRequest
(
uri
);
return
;
}
_isReissuingNavigation
=
true
;
print
(
'➡️ Loading with headers:
${uri.toString()}
, headers:
$_authHeaders
'
);
await
_controller
?.
loadRequest
(
uri
,
headers:
_authHeaders
!);
if
(
kDebugMode
)
{
print
(
'➡️ Loading with headers:
${uri.toString()}
, headers:
$_authHeaders
'
,
);
}
await
_webViewController
?.
loadRequest
(
uri
,
headers:
_authHeaders
!);
}
bool
_shouldAttachHeaders
(
String
url
)
{
...
...
@@ -306,4 +361,77 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
final
lower
=
url
.
toLowerCase
();
return
lower
.
startsWith
(
'http://'
)
||
lower
.
startsWith
(
'https://'
);
}
bool
_isDataImageUrl
(
String
url
)
=>
url
.
startsWith
(
'data:image'
);
void
_processDataImageUrl
(
String
url
)
{
Future
.
microtask
(()
=>
_saveBase64Image
(
url
));
}
Future
<
void
>
_saveBase64Image
(
String
url
)
async
{
if
(!
mounted
||
kIsWeb
)
return
;
final
payload
=
_extractBase64Payload
(
url
);
if
(
payload
==
null
)
{
_showSnack
(
'Không thể đọc dữ liệu ảnh.'
);
return
;
}
final
hasPermission
=
await
_ensureMediaPermission
();
if
(!
hasPermission
)
{
_showSnack
(
'Ứng dụng chưa có quyền lưu ảnh. Vui lòng cấp quyền trong cài đặt.'
,
);
return
;
}
try
{
final
Uint8List
bytes
=
base64Decode
(
payload
);
final
result
=
await
ImageGallerySaver
.
saveImage
(
bytes
,
quality:
100
,
name:
'mypoint_
${DateTime.now().millisecondsSinceEpoch}
'
,
);
final
success
=
(
result
[
'isSuccess'
]
??
result
[
'success'
]
??
result
[
'status'
])
==
true
;
_showSnack
(
success
?
'Ảnh đã được lưu vào thư viện.'
:
'Không thể lưu ảnh.'
,
);
}
catch
(
e
)
{
if
(
kDebugMode
)
{
print
(
'Failed to save base64 image:
$e
'
);
}
_showSnack
(
'Không thể lưu ảnh.'
);
}
}
String
?
_extractBase64Payload
(
String
url
)
{
final
marker
=
'base64,'
;
final
index
=
url
.
indexOf
(
marker
);
if
(
index
==
-
1
)
return
null
;
return
url
.
substring
(
index
+
marker
.
length
).
trim
();
}
Future
<
bool
>
_ensureMediaPermission
()
async
{
if
(
kIsWeb
)
return
false
;
PermissionStatus
status
;
if
(
defaultTargetPlatform
==
TargetPlatform
.
iOS
)
{
status
=
await
Permission
.
photosAddOnly
.
request
();
if
(
status
.
isGranted
)
return
true
;
status
=
await
Permission
.
photos
.
request
();
if
(
status
.
isGranted
)
return
true
;
}
else
{
status
=
await
Permission
.
photos
.
request
();
if
(
status
.
isGranted
)
return
true
;
status
=
await
Permission
.
storage
.
request
();
if
(
status
.
isGranted
||
status
.
isLimited
)
return
true
;
}
if
(
status
.
isPermanentlyDenied
&&
kDebugMode
)
{
print
(
'Media permission permanently denied.'
);
}
return
false
;
}
void
_showSnack
(
String
message
)
{
if
(!
mounted
)
return
;
showToastMessage
(
message
);
}
}
pubspec.yaml
View file @
b93b2948
...
...
@@ -58,8 +58,10 @@ dependencies:
permission_handler
:
^12.0.1
share_plus
:
^12.0.0
file_saver
:
^0.3.1
flutter_branch_sdk
:
^8.0.1
month_picker_dialog
:
marquee
:
^2.2.3
image_gallery_saver
:
^2.0.3
fl_chart
:
^1.1.0
mobile_scanner
:
^7.0.1
encrypt
:
^5.0.1
...
...
web/index.html
View file @
b93b2948
...
...
@@ -85,6 +85,16 @@
try
{
mo
.
observe
(
document
.
documentElement
,
{
subtree
:
true
,
childList
:
true
,
attributes
:
true
,
attributeFilter
:
[
'
src
'
]
});
}
catch
(
e
)
{}
})();
</script>
<script>
(
function
(
b
,
r
,
a
,
n
,
c
,
h
,
_
,
s
,
d
,
k
){
if
(
!
b
[
n
]
||!
b
[
n
].
_q
){
for
(;
s
<
_
.
length
;)
c
(
h
,
_
[
s
++
]);
d
=
r
.
createElement
(
a
);
d
.
async
=
1
;
d
.
src
=
"
https://cdn.branch.io/branch-latest.min.js
"
;
k
=
r
.
getElementsByTagName
(
a
)[
0
];
k
.
parentNode
.
insertBefore
(
d
,
k
);
b
[
n
]
=
h
}})(
window
,
document
,
"
script
"
,
"
branch
"
,
function
(
b
,
r
){
b
[
r
]
=
function
(){
b
.
_q
.
push
([
r
,
arguments
])}},{
_q
:[],
_v
:
1
},
"
addListener banner closeBanner closeJourney data deepview deepviewCta first init link logout removeListener setBranchViewData setIdentity track trackCommerceEvent logEvent disableTracking getBrowserFingerprintId crossPlatformIds lastAttributedTouchData setAPIResponseCallback qrCode setRequestMetaData setAPIUrl getAPIUrl setDMAParamsForEEA
"
.
split
(
"
"
),
0
);
(
function
(){
var
host
=
(
window
.
location
&&
window
.
location
.
hostname
||
''
).
toLowerCase
();
var
isLocal
=
host
===
'
localhost
'
||
host
===
'
127.0.0.1
'
;
var
isTestDomain
=
host
.
indexOf
(
'
test-app.link
'
)
!==
-
1
;
var
branchKey
=
(
isLocal
||
isTestDomain
)
?
'
key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV
'
:
'
key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw
'
;
try
{
branch
.
init
(
branchKey
);
}
catch
(
e
)
{
console
.
error
(
'
Branch init failed
'
,
e
);
}
})();
</script>
<script>
(
function
()
{
var
textDecoder
=
typeof
TextDecoder
===
'
function
'
?
new
TextDecoder
(
'
utf-8
'
)
:
null
;
...
...
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