Commit b93b2948 authored by DatHV's avatar DatHV
Browse files

update logic handle dynamic branch link

parent e9cf8244
......@@ -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"
......
# 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 { *; }
......@@ -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
......
......@@ -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>
......@@ -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>
......
......@@ -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), () {
......
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;
}
}
......@@ -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";
}
}
......
......@@ -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 {
......
......@@ -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),
......
......@@ -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 _controller;
WebViewController? _webViewController;
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: _controller,
controller: _webViewController!,
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,
......
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? _controller; // Nullable cho web platform
WebViewController? _webViewController; // 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();
_controller =
_webViewController =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
......@@ -76,7 +94,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
NavigationDelegate(
onPageFinished: (_) async {
AppLoading().hide();
final title = await _controller!.getTitle();
final title = await _webViewController!.getTitle();
setState(() {
_dynamicTitle = title;
});
......@@ -132,7 +150,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
children: [
SafeArea(
child: WebViewWidget(
controller: _controller!,
controller: _webViewController!,
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 (_controller != null && await _controller!.canGoBack()) {
_controller!.goBack();
if (_webViewController != null && await _webViewController!.canGoBack()) {
_webViewController!.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 _controller?.loadRequest(uri);
await _webViewController?.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 _controller?.loadRequest(uri);
await _webViewController?.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);
}
}
......@@ -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
......
......@@ -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;
......
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