Commit 6c72edcb authored by DatHV's avatar DatHV
Browse files

update logic direction

parent 97763d9b
......@@ -34,6 +34,21 @@
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mypointapp" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="mypoint.app.link" />
<data android:scheme="https" android:host="mypoint-alternate.app.link" />
<data android:scheme="https" android:host="mypoint-alternate.test-app.link" />
<data android:scheme="https" android:host="mypoint.test-app.link" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
......
#!/bin/bash
# Script để export web app cho môi trường development
# Script để export web app cho môi trường development và chạy với CORS
echo "🔧 Exporting Development Web App..."
......@@ -13,3 +13,96 @@ lsof -i :8080 | awk 'NR>1 {print $2}' | xargs kill -9 2>/dev/null || true
# Export web app
./export_web.sh
# Chạy server với CORS như run_dev
echo "🚀 Starting exported web app with CORS..."
EXPORT_DIRS=$(ls -d web_export_* 2>/dev/null | grep -v "\.zip$" | sort -r | head -1)
if [ -z "$EXPORT_DIRS" ]; then
echo "❌ No web export directory found"
exit 1
fi
echo "📁 Using export directory: $EXPORT_DIRS"
cd "$EXPORT_DIRS"
# Verify we're in the right directory
if [ ! -f "index.html" ]; then
echo "❌ index.html not found in $EXPORT_DIRS"
exit 1
fi
echo "✅ Found index.html in export directory"
# Start web server with CORS headers (same as run_web_complete.sh)
python3 -c "
import http.server
import socketserver
import socket
import os
class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, Origin')
super().end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.end_headers()
def log_message(self, format, *args):
print(f'🌐 {format % args}')
def find_free_port(start_port=8080, max_attempts=10):
for port in range(start_port, start_port + max_attempts):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', port))
return port
except OSError:
continue
return None
PORT = find_free_port(8080, 20)
if not PORT:
print('❌ No free port found')
exit(1)
print(f'🚀 Server running at http://localhost:{PORT}')
print(f'📁 Serving from: {os.getcwd()}')
print('🔧 CORS headers enabled for API calls')
print('')
print('Press Ctrl+C to stop the server')
with socketserver.TCPServer(('', PORT), CORSHTTPRequestHandler) as httpd:
httpd.serve_forever()
" &
SERVER_PID=$!
# Wait for server to start
sleep 3
# Open browser with CORS disabled (same as run_web_complete.sh)
echo "🌐 Opening browser with CORS disabled..."
if command -v open &> /dev/null; then
# macOS
open -n -a "Google Chrome" --args --disable-web-security --user-data-dir=/tmp/chrome_dev --disable-features=VizDisplayCompositor http://localhost:8080
elif command -v google-chrome &> /dev/null; then
# Linux
google-chrome --disable-web-security --user-data-dir=/tmp/chrome_dev --disable-features=VizDisplayCompositor http://localhost:8080 &
else
echo "⚠️ Chrome not found. Please open manually: http://localhost:8080"
fi
echo ""
echo "✅ Setup complete!"
echo "🌐 Web app: http://localhost:8080"
echo "🔧 CORS disabled in browser for development"
echo "📁 Export directory: $EXPORT_DIRS"
echo ""
echo "Press Ctrl+C to stop the server"
# Wait for user to stop
wait $SERVER_PID
......@@ -67,5 +67,14 @@
</array>
<key>FirebaseAppDelegateProxyEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>mypointapp</string>
</array>
</dict>
</array>
</dict>
</plist>
......@@ -3,7 +3,6 @@ import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../configs/constants.dart';
import '../configs/constants.dart';
class AppLoading {
// Singleton ẩn
......
......@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart';
import 'package:mypoint_flutter_app/networking/app_navigator.dart';
import 'package:mypoint_flutter_app/main.dart' show routeObserver;
import '../networking/dio_http_service.dart';
import '../resources/base_color.dart';
import '../widgets/alert/custom_alert_dialog.dart';
......@@ -13,9 +14,11 @@ abstract class BaseScreen extends StatefulWidget {
const BaseScreen({super.key});
}
abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with WidgetsBindingObserver {
abstract class BaseState<Screen extends BaseScreen> extends State<Screen>
with WidgetsBindingObserver, RouteAware {
bool _isVisible = false;
bool _isPaused = false;
ModalRoute<dynamic>? _route;
@override
void initState() {
......@@ -33,6 +36,10 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (_route != null) {
routeObserver.unsubscribe(this);
_route = null;
}
onDestroy();
super.dispose();
}
......@@ -46,7 +53,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
_isPaused = false;
onAppResumed();
if (_isVisible) {
onStart();
// App back to foreground while this route is visible → appear again
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
}
}
break;
......@@ -55,7 +66,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
_isPaused = true;
onAppPaused();
if (_isVisible) {
onStop();
// App goes to background while this route is visible → disappear
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
}
}
break;
......@@ -74,13 +89,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Subscribe to RouteObserver when route is available
final modalRoute = ModalRoute.of(context);
if (modalRoute != null && modalRoute is PageRoute && modalRoute != _route) {
_route = modalRoute;
routeObserver.subscribe(this, modalRoute);
}
if (!_isVisible) {
_isVisible = true;
onResume();
// Gọi onStart sau frame tiếp theo
// First time becoming visible in the tree
onWillAppear();
// Call did-appear after the frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isVisible && !_isPaused) {
onStart();
onDidAppear();
}
});
}
......@@ -129,6 +151,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
// Override in subclasses
}
// MARK: - Route visibility hooks (Navigator push/pop)
/// Called right before the route appears (push or uncovered)
void onWillAppear() {}
/// Called right after the route appeared
void onDidAppear() {}
/// Called right before another route covers this one
void onWillDisappear() {}
/// Called right after this route is covered or popped
void onDidDisappear() {}
/// Called when app becomes active (similar to applicationDidBecomeActive in iOS)
void onAppResumed() {
// Override in subclasses
......@@ -225,4 +261,38 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
Widget? createBottomBar() {
return null;
}
// MARK: - RouteAware overrides mapping to hooks
@override
void didPush() {
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
}
@override
void didPopNext() {
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
}
@override
void didPushNext() {
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
}
@override
void didPop() {
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
}
}
......@@ -116,4 +116,5 @@ class APIPaths {//sandbox
static const String pushNotificationDeviceUpdateToken = "/pushNotificationDeviceUpdateToken/1.0.0";
static const String myProductMarkAsUsed = "/myProductMarkAsUsed/1.0.0";
static const String myProductMarkAsNotUsedYet = "/myProductMarkAsNotUsedYet/1.0.0";
static const String submitCampaignViewVoucherComplete = "/campaign/api/v3.0/view-voucher/complete";
}
\ No newline at end of file
......@@ -6,6 +6,7 @@ class Constants {
static var phoneNumberCount = 10;
static var timeoutSeconds = 30;
static const loadingTimeoutSeconds = 30;
static const appStoreId = '1495923300';
}
class ErrorCodes {
......
......@@ -11,34 +11,29 @@ 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_app_initializer.dart';
import 'package:mypoint_flutter_app/core/deep_link_service.dart';
/// Main app initialization and setup
class AppInitializer {
/// Initialize all core app features
static Future<void> initialize() async {
print('🚀 Initializing app...');
// Load environment configuration
await loadEnv();
// Initialize data preferences
await DataPreference.instance.init();
// Initialize HTTP service
DioHttpService();
// Initialize GetX controllers
Get.put(HeaderThemeController(), permanent: true);
// Initialize Firebase (mobile only)
await _initializeFirebase();
// Fetch user point if logged in
await _fetchUserPointIfLoggedIn();
// Initialize web-specific features
await WebAppInitializer.initialize();
// Initialize deep links
await DeepLinkService().initialize();
print('✅ App initialization completed');
}
......@@ -64,15 +59,17 @@ class AppInitializer {
/// Setup post-initialization callbacks
static void setupPostInitCallbacks() {
WidgetsBinding.instance.addPostFrameCallback((_) {
AppLoading().attach();
});
// Handle launch from notification when app was killed
_handleInitialNotificationLaunch();
// Handle launch from local notification tap when app was killed
handleLocalNotificationLaunchIfAny();
try {
WidgetsBinding.instance.addPostFrameCallback((_) {
AppLoading().attach();
});
// Handle launch from notification when app was killed
_handleInitialNotificationLaunch();
// Handle launch from local notification tap when app was killed
handleLocalNotificationLaunchIfAny();
} catch (e) {
if (kDebugMode) print('Error in setupPostInitCallbacks: $e');
}
}
/// Handle initial notification launch
......@@ -88,4 +85,4 @@ class AppInitializer {
});
} catch (_) {}
}
}
}
\ No newline at end of file
import 'dart:async';
import 'package:flutter/foundation.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;
class DeepLinkService {
DeepLinkService._internal();
static final DeepLinkService _instance = DeepLinkService._internal();
factory DeepLinkService() => _instance;
StreamSubscription? _linkSub;
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
if (kDebugMode) print('🔗 Initializing DeepLinkService...');
await _handleInitialLink();
_listenLinkStream();
}
Future<void> dispose() async {
await _linkSub?.cancel();
_linkSub = null;
_initialized = false;
}
Future<void> _handleInitialLink() async {
try {
final initial = await getInitialLink();
if (initial == null) return;
_routeFromUriString(initial);
} catch (_) {}
}
void _listenLinkStream() {
try {
_linkSub = linkStream.listen((uri) {
if (uri == null) return;
_routeFromUriString(uri.toString());
}, onError: (_) {});
} catch (_) {}
}
// Firebase Dynamic Links removed due to version constraints.
void _routeFromUriString(String uriStr) {
if (kDebugMode) print('🔗 Deep link received: $uriStr');
final uri = Uri.tryParse(uriStr);
if (uri == null) return;
final type = uri.queryParameters[Defines.actionType] ?? uri.queryParameters['action_type'];
final param = uri.queryParameters[Defines.actionParams] ?? uri.queryParameters['action_param'];
// Optional: decrypt phone from `key` if present (compat with iOS scheme handler)
final cipherHex = uri.queryParameters['key'];
if (cipherHex != null && cipherHex.isNotEmpty) {
// Try multiple known secrets (match iOS CommonAPI.schemeCryptKey variants)
const candidates = <String>[
'mypointdeeplinkk',
'PVt3FWQibsB7xaLx',
];
for (final secret in candidates) {
final phone = mycrypto.Crypto(cipherHex: cipherHex, secretKey: secret).decryption();
if (phone != null && phone.isNotEmpty) {
if (kDebugMode) print('🔐 Decrypted phone from key: $phone');
break; // Use if you need to attach to userInfo later
}
}
}
final screen = DirectionalScreen.build(clickActionType: type, clickActionParam: param);
screen?.begin();
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart';
import 'package:mypoint_flutter_app/preference/data_preference.dart';
import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
import '../base/app_loading.dart';
import '../configs/constants.dart';
import '../networking/app_navigator.dart';
import '../networking/restful_api_viewmodel.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 '../shared/router_gage.dart';
import 'directional_action_type.dart';
......@@ -31,10 +35,7 @@ class DirectionalScreen {
popup: json['popup'] != null ? PopupDataModel.fromJson(json['popup'] as Map<String, dynamic>) : null,
);
Map<String, dynamic> toJson() => {
'click_action_type': clickActionType,
'click_action_param': clickActionParam,
};
Map<String, dynamic> toJson() => {'click_action_type': clickActionType, 'click_action_param': clickActionParam};
static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) {
if ((clickActionType ?? "").isEmpty) return null;
......@@ -51,7 +52,6 @@ class DirectionalScreen {
return DirectionalScreen._(clickActionType: name.rawValue, clickActionParam: clickActionParam);
}
@immutable
bool begin() {
final type = DirectionalScreenNameExtension.fromRawValue(clickActionType ?? "");
if (type == null) {
......@@ -59,6 +59,108 @@ class DirectionalScreen {
return false;
}
switch (type) {
case DirectionalScreenName.brand:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(affiliateBrandDetailScreen, arguments: {"brandId": clickActionParam});
return true;
case DirectionalScreenName.preferentialHotList:
Get.toNamed(vouchersScreen, arguments: {"isHotProduct": true});
return true;
case DirectionalScreenName.memberShip:
Get.toNamed(membershipScreen);
return true;
case DirectionalScreenName.customerReviewApp:
final storeUrl = 'https://itunes.apple.com/app/id${Constants.appStoreId}?action=write-review';
openStringUrlExternally(storeUrl);
return true;
// case DirectionalScreenName.historyInvitedFriend:
// case DirectionalScreenName.screenAddInvitationCode:
// // TODO: Lịch sử mời bạn – cần màn tương ứng
// return false;
case DirectionalScreenName.rateStorePopup:
_requestAppReview();
return false;
// return false;
// case DirectionalScreenName.shoppingOnline:
// case DirectionalScreenName.partnerRedirect:
// return false;
// case DirectionalScreenName.brandOffline:
// return false;
case DirectionalScreenName.pipiScreen:
Get.bottomSheet(const PipiDetailScreen(), isScrollControlled: true, backgroundColor: Colors.transparent);
return true;
case DirectionalScreenName.viewVoucherWithCountTime:
final countDownSecond = int.tryParse(clickActionParam ?? '') ?? 0;
Get.toNamed(voucherDetailScreen, arguments: {"countDownSecond": countDownSecond});
return true;
case DirectionalScreenName.popViewController:
if (Get.isOverlaysOpen) {
Get.back();
return true;
}
if (Get.key.currentState?.canPop() == true) {
Get.back();
return true;
}
return false;
case DirectionalScreenName.finishScreen:
Get.until((route) => route.isFirst);
return true;
case DirectionalScreenName.luckyMoney:
if (clickActionParam.orEmpty.isEmpty) return false;
BaseWebViewInput input = BaseWebViewInput(url: clickActionParam.orEmpty.urlDecoded);
Get.toNamed(baseWebViewScreen, arguments: input);
return true;
case DirectionalScreenName.privacyPolicy:
Get.toNamed(campaignDetailScreen, arguments: {"type": DetailPageRuleType.privacyPolicy});
return true;
case DirectionalScreenName.termsOfUse:
Get.toNamed(campaignDetailScreen, arguments: {"type": DetailPageRuleType.termsOfUse});
return true;
case DirectionalScreenName.termPolicyDecree13:
Get.toNamed(campaignDetailScreen, arguments: {"type": DetailPageRuleType.decree});
return true;
case DirectionalScreenName.termPolicyDeleteAccount:
Get.toNamed(campaignDetailScreen, arguments: {"type": DetailPageRuleType.policyDeleteAccount});
return true;
case DirectionalScreenName.familyMedonDetailCard:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(healthBookCardDetail, arguments: {"id": clickActionParam});
return false;
case DirectionalScreenName.webviewFullScreen:
if ((clickActionParam ?? '').isEmpty) return false;
BaseWebViewInput input = BaseWebViewInput(url: clickActionParam ?? "", isFullScreen: true);
Get.toNamed(baseWebViewScreen, arguments: input);
return true;
case DirectionalScreenName.gameCardDetail:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(gameCardScreen, arguments: {"gameId": clickActionParam ?? ''});
return true;
case DirectionalScreenName.inviteFriend ||
DirectionalScreenName.customerInviteFriend ||
DirectionalScreenName.newInviteFriend ||
DirectionalScreenName.inviteFriendApply:
Get.toNamed(inviteFriendCampaignScreen);
return true;
case DirectionalScreenName.personal:
Get.toNamed(personalEditScreen);
return true;
case DirectionalScreenName.viewSMS:
final parts = clickActionParam.orEmpty.split('_');
if (parts.length != 2) return false;
final phone = parts[0].trim();
final content = parts[1].trim();
final contentDecoded = Uri.decodeComponent(content);
final body = Uri.encodeComponent(contentDecoded);
// iOS: &body=..., Android: ?body=...
final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
final urlStr = isIOS
? 'sms:$phone&body=$body'
: 'sms:$phone?body=$body';
final uri = Uri.parse(urlStr);
print('Mở SMS: $uri phone=$phone, content=$content');
_openUrlExternally(uri);
return false;
case DirectionalScreenName.setting:
Get.toNamed(settingScreen);
return true;
......@@ -68,7 +170,7 @@ class DirectionalScreen {
case DirectionalScreenName.customerSupport:
Get.toNamed(supportScreen);
return true;
case DirectionalScreenName.viewDeepLink || DirectionalScreenName.link:
case DirectionalScreenName.link:
BaseWebViewInput input = BaseWebViewInput(url: clickActionParam ?? "");
Get.toNamed(baseWebViewScreen, arguments: input);
return true;
......@@ -135,16 +237,14 @@ class DirectionalScreen {
if (uri == null) return true;
final requestId = const Uuid().v4(); // Cần package `uuid`
final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, 'aff_sub3': requestId});
LaunchMode mode = type == DirectionalScreenName.viewDeepLink ? LaunchMode.externalApplication : LaunchMode.inAppWebView;
LaunchMode mode =
type == DirectionalScreenName.viewDeepLink ? LaunchMode.externalApplication : LaunchMode.inAppWebView;
// forceOpen(url: updatedUri, mode: mode);
safeOpenUrl(updatedUri, preferred: mode);
_safeOpenUrl(updatedUri, preferred: mode);
return true;
case DirectionalScreenName.refundHistory:
Get.toNamed(historyPointCashBackScreen);
return true;
case DirectionalScreenName.inviteFriend:
Get.toNamed(inviteFriendCampaignScreen);
return true;
case DirectionalScreenName.dailyCheckin || DirectionalScreenName.dailyCheckinScreen:
Get.toNamed(dailyCheckInScreen);
return true;
......@@ -174,7 +274,7 @@ class DirectionalScreen {
return true;
case DirectionalScreenName.surveyCampaign:
if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(surveyQuestionScreen, arguments: {"quizId": clickActionParam ?? ''});
Get.toNamed(surveyQuestionScreen, arguments: {"quizId": clickActionParam ?? ''});
return true;
case DirectionalScreenName.myMobileCard:
Get.toNamed(myMobileCardListScreen);
......@@ -199,7 +299,7 @@ class DirectionalScreen {
if (popup != null) {
AppNavigator.showPopup(data: popup);
} else {
screen.begin();
screen.begin();
}
},
withLoading: true,
......@@ -227,64 +327,59 @@ Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformD
return false;
}
Future<void> openAppStore(String url) async {
Future<void> openStringUrlExternally(String url) async {
final uri = Uri.parse(url);
_openUrlExternally(uri);
}
Future<void> _openUrlExternally(Uri uri) async {
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
debugPrint("⚠️ Không thể mở URL: $url");
debugPrint("⚠️ Không thể mở URL: $uri");
}
}
Future<bool> safeOpenUrl(Uri url, {LaunchMode preferred = LaunchMode.platformDefault,}) async {
Future<bool> _safeOpenUrl(Uri url, {LaunchMode preferred = LaunchMode.platformDefault}) async {
try {
// 1) Thử theo mode ưa thích
if (await canLaunchUrl(url)) {
final ok = await launchUrl(
url,
mode: preferred,
webViewConfiguration: const WebViewConfiguration(
enableJavaScript: true,
headers: <String, String>{},
),
);
if (ok) return true;
}
// 2) Fallback: mở bằng app ngoài (trình duyệt hệ thống)
if (await canLaunchUrl(url)) {
final ok = await launchUrl(
url,
mode: LaunchMode.externalApplication,
webViewConfiguration: const WebViewConfiguration(
enableJavaScript: true,
headers: <String, String>{},
),
);
if (ok) return true;
}
// 3) Fallback: mở trong webview của app (Custom Tabs / SFSafariViewController)
if (await canLaunchUrl(url)) {
final ok = await launchUrl(
url,
mode: LaunchMode.inAppBrowserView, // hoặc inAppWebView (tuỳ version url_launcher)
webViewConfiguration: const WebViewConfiguration(
enableJavaScript: true,
headers: <String, String>{},
),
);
if (ok) return true;
}
// 4) Fallback cuối
if (await canLaunchUrl(url)) {
final ok = await launchUrl(url, mode: LaunchMode.platformDefault);
if (ok) return true;
// Nếu không mở được bằng bất kỳ hình thức nào thì dừng sớm
if (!await canLaunchUrl(url)) return false;
// Sắp xếp các chế độ theo ưu tiên và loại bỏ trùng lặp
final List<LaunchMode> modes = <LaunchMode>[
preferred,
LaunchMode.externalApplication,
LaunchMode.inAppBrowserView,
LaunchMode.platformDefault,
];
final Set<LaunchMode> seen = <LaunchMode>{};
for (final mode in modes) {
if (!seen.add(mode)) continue;
try {
final ok = await launchUrl(
url,
mode: mode,
webViewConfiguration: const WebViewConfiguration(
enableJavaScript: true,
headers: <String, String>{},
),
);
if (ok) return true;
} catch (_) {
// thử chế độ tiếp theo
}
}
} catch (e) {
// ghi log lỗi nếu có
// debugPrint('safeOpenUrl error: $e');
}
} catch (_) {}
return false;
}
Future<void> _requestAppReview() async {
final inAppReview = InAppReview.instance;
if (await inAppReview.isAvailable()) {
await inAppReview.requestReview();
return;
}
// Fallback mở trang app trên store
await inAppReview.openStoreListing(appStoreId: Constants.appStoreId, microsoftStoreId: null);
}
......@@ -15,6 +15,8 @@ extension NullableString on String? {
return (s == null || s.isEmpty) ? fallback : s;
}
String get orEmpty => this ?? '';
bool get hasText => (this?.trim().isNotEmpty ?? false);
bool get isNullOrBlank => (this == null || this!.trim().isEmpty);
......@@ -25,13 +27,19 @@ extension StringUrlExtension on String {
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;
if (s.isEmpty) return null;
final normalized = s.startsWith(RegExp(r'https?://', caseSensitive: false))
? s
: 'https://$s';
final cleaned = normalized.replaceAll('\n', '').replaceAll('\r', '');
try {
return Uri.parse(cleaned);
} catch (e) {
debugPrint('Invalid URL: $cleaned ($e)');
return null;
}
}
}
......
......@@ -1054,4 +1054,10 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
return VerifyRegisterCampaignModel.fromJson(data as Json);
});
}
Future<BaseResponseModel<SubmitViewVoucherCompletedResponse>> submitCampaignViewVoucherComplete() async {
return requestNormal(APIPaths.submitCampaignViewVoucherComplete, Method.POST, {}, (data) {
return SubmitViewVoucherCompletedResponse.fromJson(data as Json);
});
}
}
\ No newline at end of file
......@@ -41,9 +41,12 @@ class _GameCardScreenState extends BaseState<GameCardScreen> with BasicState, Ro
if (gameId.isNotEmpty) {
_viewModel.getGameDetail(id: gameId);
}
_viewModel.onShowAlertError = (message) {
_viewModel.onShowAlertError = (message, onClose) {
if (message.isEmpty) return;
showAlertError(content: message);
showAlertError(content: message, showCloseButton: !onClose, onConfirmed: () {
if (!onClose) return;
Get.back();
});
};
_viewModel.submitGameCardSuccess = (popup) {
WidgetsBinding.instance.addPostFrameCallback((_) {
......
......@@ -7,7 +7,7 @@ import '../models/game_bundle_item_model.dart';
class GameCardViewModel extends RestfulApiViewModel {
var data = Rxn<GameBundleItemModel>();
void Function(String message)? onShowAlertError;
void Function(String message, bool onClose)? onShowAlertError;
void Function(PopupDataModel popup)? submitGameCardSuccess;
void Function()? getGameDetailSuccess;
......@@ -19,7 +19,7 @@ class GameCardViewModel extends RestfulApiViewModel {
if (response.isSuccess && popupData != null) {
submitGameCardSuccess?.call(popupData);
} else {
onShowAlertError?.call(response.errorMessage ?? Constants.commonError);
onShowAlertError?.call(response.errorMessage ?? Constants.commonError, false);
}
}
......@@ -31,7 +31,7 @@ class GameCardViewModel extends RestfulApiViewModel {
data.value = response.data;
getGameDetailSuccess?.call();
} else {
onShowAlertError?.call(response.errorMessage ?? Constants.commonError);
onShowAlertError?.call(response.errorMessage ?? Constants.commonError, true);
}
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:game_miniapp/game_miniapp.dart';
import 'package:get/get.dart';
import 'package:mypoint_flutter_app/screen/home/custom_widget/header_home_widget.dart';
import 'package:mypoint_flutter_app/screen/home/custom_widget/product_grid_widget.dart';
......@@ -7,6 +6,7 @@ import 'package:mypoint_flutter_app/screen/pipi/pipi_detail_screen.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_model.dart';
import 'package:mypoint_flutter_app/shared/router_gage.dart';
import '../../directional/directional_action_type.dart';
import '../../directional/directional_screen.dart';
import '../popup_manager/popup_runner_helper.dart';
import 'custom_widget/achievement_carousel_widget.dart';
import 'custom_widget/affiliate_brand_grid_widget.dart';
......@@ -138,7 +138,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
}
break;
case HeaderSectionType.flashSale:
final products = _viewModel.flashSaleData?.value?.products ?? [];
final products = _viewModel.flashSaleData.value?.products ?? [];
if (products.isNotEmpty) {
sections.add(
FlashSaleCarouselWidget(
......@@ -245,8 +245,4 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
await _viewModel.loadDataPiPiHome();
await _headerHomeVM.freshData();
}
void _showMiniGame(BuildContext context) async {
Navigator.push(context, MaterialPageRoute(builder: (_) => const GameMiniAppScreen()));
}
}
......@@ -22,7 +22,6 @@ class PopupManagerViewModel extends RestfulApiViewModel {
Future<void> _getPopupManagerDataInternal() async {
try {
const Duration(seconds: 3); // Giả lập thời gian tải dữ liệu
final response = await client.getPopupManagerCommonScreen();
_popupData = response.data ?? [];
// _popupData = [
......
......@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/screen/voucher/detail/store_list_section.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_type.dart';
import 'package:mypoint_flutter_app/screen/voucher/voucher_code_card_screen.dart';
......@@ -291,8 +292,9 @@ class _VoucherDetailScreenState extends BaseState<VoucherDetailScreen> with Basi
brand.website ?? '',
onTap: () {
final website = brand.website?.trim() ?? "";
final url = website.startsWith('http') ? website : 'https://${brand.website}';
_launchUri(Uri(scheme: url));
final uri = website.toUri();
if (uri == null) return;
_launchUri(uri);
},
),
],
......@@ -300,8 +302,9 @@ class _VoucherDetailScreenState extends BaseState<VoucherDetailScreen> with Basi
);
}
_launchUri(Uri uri) async {
Future<void> _launchUri(Uri uri) async {
if (await canLaunchUrl(uri)) {
print('Launching $uri');
await launchUrl(uri);
} else {
throw 'Could not launch $uri';
......
......@@ -10,6 +10,7 @@ import 'package:mypoint_flutter_app/screen/voucher/models/product_media_item.dar
import 'package:mypoint_flutter_app/screen/voucher/models/product_price_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_properties_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_type.dart';
import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart';
import '../../flash_sale/preview_flash_sale_model.dart';
import 'media_type.dart';
import 'my_product_status_type.dart';
......@@ -162,3 +163,20 @@ class ProductPreviewCampaignModel {
factory ProductPreviewCampaignModel.fromJson(Map<String, dynamic> json) => _$ProductPreviewCampaignModelFromJson(json);
Map<String, dynamic> toJson() => _$ProductPreviewCampaignModelToJson(this);
}
class SubmitViewVoucherCompletedResponse {
PopupDataModel? popup;
SubmitViewVoucherCompletedResponse({this.popup});
factory SubmitViewVoucherCompletedResponse.fromJson(Map<String, dynamic> json) {
return SubmitViewVoucherCompletedResponse(
popup: json['popup'] != null ? PopupDataModel.fromJson(json['popup']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'popup': popup?.toJson(),
};
}
}
\ No newline at end of file
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../base/base_screen.dart';
import '../../../base/basic_state.dart';
import '../../../configs/constants.dart';
import '../../../shared/router_gage.dart';
import '../../../widgets/custom_empty_widget.dart';
import '../../../widgets/custom_navigation_bar.dart';
import '../../../widgets/custom_search_navigation_bar.dart';
import '../../transaction/history/transaction_history_detail_screen.dart';
import '../sub_widget/voucher_item_list.dart';
import 'voucher_list_viewmodel.dart';
class VoucherListScreen extends StatefulWidget {
class VoucherListScreen extends BaseScreen {
const VoucherListScreen({super.key});
@override
_VoucherListScreenState createState() => _VoucherListScreenState();
}
class _VoucherListScreenState extends State<VoucherListScreen> {
class _VoucherListScreenState extends BaseState<VoucherListScreen> with BasicState {
late final Map<String, dynamic> args;
late final bool enableSearch;
late final bool isHotProduct;
late final bool isFavorite;
late final VoucherListViewModel _viewModel;
int _remainingSeconds = 0;
Timer? _countdownTimer;
bool _countdownStartedEver = false; // chỉ để đảm bảo start lần đầu sau khi load xong
@override
void initState() {
......@@ -30,74 +37,173 @@ class _VoucherListScreenState extends State<VoucherListScreen> {
isHotProduct = args['isHotProduct'] ?? false;
isFavorite = args['favorite'] ?? false;
_viewModel = Get.put(VoucherListViewModel(isHotProduct: isHotProduct, isFavorite: isFavorite));
_remainingSeconds = 10; //args['countDownSecond'] ?? 0;
_viewModel.submitCampaignViewVoucherResponse = (response) {
final popup = response.data?.popup;
if (popup != null) {
showPopup(data: popup);
} else {
showAlertError(content: response.errorMessage ?? Constants.commonError);
}
};
// Bắt đầu countdown sau khi lần đầu load xong và có data, không khởi tạo từ build()
ever<bool>(_viewModel.firstLoadDone, (done) {
if (done && !_countdownStartedEver && _remainingSeconds > 0) {
_startCountdownIfNeeded();
}
});
}
@override
void dispose() {
_countdownTimer?.cancel();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Khi màn hình trở lại visible (route current) → resume timer nếu còn thời gian
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
if (isCurrent) {
_resumeCountdownIfNeeded();
}
}
@override
void deactivate() {
// Luôn pause khi rời màn hình để tránh timer chạy nền
_pauseCountdown();
super.deactivate();
print('VoucherListScreen deactivate');
}
void _startCountdownIfNeeded() {
if (_countdownStartedEver) return; // ensure only first start after load
if (_remainingSeconds <= 0) return;
if (_countdownTimer != null) return; // already running
_countdownStartedEver = true;
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
setState(() {
_remainingSeconds -= 1;
if (_remainingSeconds <= 0) {
_viewModel.submitCampaignViewVoucherComplete();
_countdownTimer?.cancel();
_countdownTimer = null;
}
});
});
}
void _resumeCountdownIfNeeded() {
if (_remainingSeconds <= 0) return;
if (_countdownTimer != null) return;
// không đụng tới _countdownStartedEver để vẫn giữ logic chỉ start lần đầu thông qua ViewModel
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
setState(() {
_remainingSeconds -= 1;
if (_remainingSeconds <= 0) {
_viewModel.submitCampaignViewVoucherComplete();
_countdownTimer?.cancel();
_countdownTimer = null;
}
});
});
}
void _pauseCountdown() {
_countdownTimer?.cancel();
_countdownTimer = null;
}
@override
Widget build(BuildContext context) {
Widget createBody() {
final String title = isFavorite ? 'Yêu thích' : (isHotProduct ? 'Săn ưu đãi' : 'Tất cả ưu đãi');
return Scaffold(
appBar:
enableSearch
? CustomSearchNavigationBar(onSearchChanged: _viewModel.onSearchChanged,)
? CustomSearchNavigationBar(onSearchChanged: _viewModel.onSearchChanged)
: CustomNavigationBar(title: title),
body: Column(
body: Stack(
children: [
if (enableSearch)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Obx(() {
final resultCount = _viewModel.totalResult.value;
final displayText = _viewModel.searchQuery.isNotEmpty
? '$title ($resultCount kết quả)'
: title;
return Align(
alignment: Alignment.centerLeft,
child: Text(
displayText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
Column(
children: [
if (enableSearch)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Obx(() {
final resultCount = _viewModel.totalResult.value;
final displayText = _viewModel.searchQuery.isNotEmpty ? '$title ($resultCount kết quả)' : title;
return Align(
alignment: Alignment.centerLeft,
child: Text(displayText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
);
}),
),
Expanded(
child: Obx(() {
if (_viewModel.products.isEmpty) {
return const Center(child: EmptyWidget());
}
// Countdown start được điều phối ở initState qua ever(isLoading)
return RefreshIndicator(
onRefresh: () => _viewModel.loadData(reset: true),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _viewModel.products.length + (_viewModel.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _viewModel.products.length) {
_viewModel.loadData(reset: false);
return const Center(
child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()),
);
}
final product = _viewModel.products[index];
return GestureDetector(
onTap: () async {
await Get.toNamed(voucherDetailScreen, arguments: {"productId": product.id});
_viewModel.loadData(reset: true);
},
child: VoucherListItem(product: product),
);
},
),
),
);
}),
),
Expanded(
child: Obx(
() {
if (_viewModel.products.isEmpty) {
return const Center(
child: EmptyWidget(),
);
}
return RefreshIndicator(
onRefresh: () => _viewModel.loadData(reset: true),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _viewModel.products.length + (_viewModel.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _viewModel.products.length) {
_viewModel.loadData(reset: false);
return const Center(
child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()),
);
}
final product = _viewModel.products[index];
return GestureDetector(
onTap: () async {
await Get.toNamed(voucherDetailScreen, arguments: {"productId": product.id});
_viewModel.loadData(reset: true);
},
child: VoucherListItem(product: product),
);
},
}),
),
],
),
if (_remainingSeconds > 0)
Positioned(
right: 12,
bottom: 44,
child: Stack(
children: [
Image.asset(
'assets/images/ic_count_down_time_voucher.png',
width: 90,
height: 90,
fit: BoxFit.contain,
),
Positioned(
bottom: 4,
right: 0,
left: 0,
child: Center(
child: Text(
'Còn ${_remainingSeconds}s',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12),
),
),
),
);
}
],
),
),
),
],
),
);
}
}
\ No newline at end of file
}
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