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

update logic direction

parent 97763d9b
...@@ -34,6 +34,21 @@ ...@@ -34,6 +34,21 @@
<action android:name="FLUTTER_NOTIFICATION_CLICK" /> <action android:name="FLUTTER_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </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> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
......
#!/bin/bash #!/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..." echo "🔧 Exporting Development Web App..."
...@@ -13,3 +13,96 @@ lsof -i :8080 | awk 'NR>1 {print $2}' | xargs kill -9 2>/dev/null || true ...@@ -13,3 +13,96 @@ lsof -i :8080 | awk 'NR>1 {print $2}' | xargs kill -9 2>/dev/null || true
# Export web app # Export web app
./export_web.sh ./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 @@ ...@@ -67,5 +67,14 @@
</array> </array>
<key>FirebaseAppDelegateProxyEnabled</key> <key>FirebaseAppDelegateProxyEnabled</key>
<true/> <true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>mypointapp</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>
...@@ -3,7 +3,6 @@ import 'dart:collection'; ...@@ -3,7 +3,6 @@ import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../configs/constants.dart'; import '../configs/constants.dart';
import '../configs/constants.dart';
class AppLoading { class AppLoading {
// Singleton ẩn // Singleton ẩn
......
...@@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; ...@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart'; import 'package:mypoint_flutter_app/base/app_loading.dart';
import 'package:mypoint_flutter_app/networking/app_navigator.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 '../networking/dio_http_service.dart';
import '../resources/base_color.dart'; import '../resources/base_color.dart';
import '../widgets/alert/custom_alert_dialog.dart'; import '../widgets/alert/custom_alert_dialog.dart';
...@@ -13,9 +14,11 @@ abstract class BaseScreen extends StatefulWidget { ...@@ -13,9 +14,11 @@ abstract class BaseScreen extends StatefulWidget {
const BaseScreen({super.key}); 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 _isVisible = false;
bool _isPaused = false; bool _isPaused = false;
ModalRoute<dynamic>? _route;
@override @override
void initState() { void initState() {
...@@ -33,6 +36,10 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -33,6 +36,10 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
if (_route != null) {
routeObserver.unsubscribe(this);
_route = null;
}
onDestroy(); onDestroy();
super.dispose(); super.dispose();
} }
...@@ -46,7 +53,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -46,7 +53,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
_isPaused = false; _isPaused = false;
onAppResumed(); onAppResumed();
if (_isVisible) { if (_isVisible) {
onStart(); // App back to foreground while this route is visible → appear again
onWillAppear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidAppear();
});
} }
} }
break; break;
...@@ -55,7 +66,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -55,7 +66,11 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
_isPaused = true; _isPaused = true;
onAppPaused(); onAppPaused();
if (_isVisible) { if (_isVisible) {
onStop(); // App goes to background while this route is visible → disappear
onWillDisappear();
WidgetsBinding.instance.addPostFrameCallback((_) {
onDidDisappear();
});
} }
} }
break; break;
...@@ -74,13 +89,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -74,13 +89,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.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) { if (!_isVisible) {
_isVisible = true; _isVisible = true;
onResume(); // First time becoming visible in the tree
// Gọi onStart sau frame tiếp theo onWillAppear();
// Call did-appear after the frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isVisible && !_isPaused) { if (_isVisible && !_isPaused) {
onStart(); onDidAppear();
} }
}); });
} }
...@@ -129,6 +151,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -129,6 +151,20 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
// Override in subclasses // 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) /// Called when app becomes active (similar to applicationDidBecomeActive in iOS)
void onAppResumed() { void onAppResumed() {
// Override in subclasses // Override in subclasses
...@@ -225,4 +261,38 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W ...@@ -225,4 +261,38 @@ abstract class BaseState<Screen extends BaseScreen> extends State<Screen> with W
Widget? createBottomBar() { Widget? createBottomBar() {
return null; 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 ...@@ -116,4 +116,5 @@ class APIPaths {//sandbox
static const String pushNotificationDeviceUpdateToken = "/pushNotificationDeviceUpdateToken/1.0.0"; static const String pushNotificationDeviceUpdateToken = "/pushNotificationDeviceUpdateToken/1.0.0";
static const String myProductMarkAsUsed = "/myProductMarkAsUsed/1.0.0"; static const String myProductMarkAsUsed = "/myProductMarkAsUsed/1.0.0";
static const String myProductMarkAsNotUsedYet = "/myProductMarkAsNotUsedYet/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 { ...@@ -6,6 +6,7 @@ class Constants {
static var phoneNumberCount = 10; static var phoneNumberCount = 10;
static var timeoutSeconds = 30; static var timeoutSeconds = 30;
static const loadingTimeoutSeconds = 30; static const loadingTimeoutSeconds = 30;
static const appStoreId = '1495923300';
} }
class ErrorCodes { class ErrorCodes {
......
...@@ -11,34 +11,29 @@ import 'package:mypoint_flutter_app/firebase/push_setup.dart'; ...@@ -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/base/app_loading.dart';
import 'package:mypoint_flutter_app/env_loader.dart'; import 'package:mypoint_flutter_app/env_loader.dart';
import 'package:mypoint_flutter_app/web/web_app_initializer.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 /// Main app initialization and setup
class AppInitializer { class AppInitializer {
/// Initialize all core app features /// Initialize all core app features
static Future<void> initialize() async { static Future<void> initialize() async {
print('🚀 Initializing app...'); print('🚀 Initializing app...');
// Load environment configuration // Load environment configuration
await loadEnv(); await loadEnv();
// Initialize data preferences // Initialize data preferences
await DataPreference.instance.init(); await DataPreference.instance.init();
// Initialize HTTP service // Initialize HTTP service
DioHttpService(); DioHttpService();
// Initialize GetX controllers // Initialize GetX controllers
Get.put(HeaderThemeController(), permanent: true); Get.put(HeaderThemeController(), permanent: true);
// Initialize Firebase (mobile only) // Initialize Firebase (mobile only)
await _initializeFirebase(); await _initializeFirebase();
// Fetch user point if logged in // Fetch user point if logged in
await _fetchUserPointIfLoggedIn(); await _fetchUserPointIfLoggedIn();
// Initialize web-specific features // Initialize web-specific features
await WebAppInitializer.initialize(); await WebAppInitializer.initialize();
// Initialize deep links
await DeepLinkService().initialize();
print('✅ App initialization completed'); print('✅ App initialization completed');
} }
...@@ -64,15 +59,17 @@ class AppInitializer { ...@@ -64,15 +59,17 @@ class AppInitializer {
/// Setup post-initialization callbacks /// Setup post-initialization callbacks
static void setupPostInitCallbacks() { static void setupPostInitCallbacks() {
WidgetsBinding.instance.addPostFrameCallback((_) { try {
AppLoading().attach(); WidgetsBinding.instance.addPostFrameCallback((_) {
}); AppLoading().attach();
});
// Handle launch from notification when app was killed // Handle launch from notification when app was killed
_handleInitialNotificationLaunch(); _handleInitialNotificationLaunch();
// Handle launch from local notification tap when app was killed
// Handle launch from local notification tap when app was killed handleLocalNotificationLaunchIfAny();
handleLocalNotificationLaunchIfAny(); } catch (e) {
if (kDebugMode) print('Error in setupPostInitCallbacks: $e');
}
} }
/// Handle initial notification launch /// Handle initial notification launch
...@@ -88,4 +85,4 @@ class AppInitializer { ...@@ -88,4 +85,4 @@ class AppInitializer {
}); });
} catch (_) {} } 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: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/extensions/string_extension.dart';
import 'package:mypoint_flutter_app/networking/restful_api_client_all_request.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/preference/data_preference.dart';
import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart'; import 'package:mypoint_flutter_app/widgets/alert/popup_data_model.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../base/app_loading.dart'; import '../configs/constants.dart';
import '../networking/app_navigator.dart'; import '../networking/app_navigator.dart';
import '../networking/restful_api_viewmodel.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 '../screen/webview/web_view_screen.dart';
import '../shared/router_gage.dart'; import '../shared/router_gage.dart';
import 'directional_action_type.dart'; import 'directional_action_type.dart';
...@@ -31,10 +35,7 @@ class DirectionalScreen { ...@@ -31,10 +35,7 @@ class DirectionalScreen {
popup: json['popup'] != null ? PopupDataModel.fromJson(json['popup'] as Map<String, dynamic>) : null, popup: json['popup'] != null ? PopupDataModel.fromJson(json['popup'] as Map<String, dynamic>) : null,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {'click_action_type': clickActionType, 'click_action_param': clickActionParam};
'click_action_type': clickActionType,
'click_action_param': clickActionParam,
};
static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) { static DirectionalScreen? build({String? clickActionType, String? clickActionParam}) {
if ((clickActionType ?? "").isEmpty) return null; if ((clickActionType ?? "").isEmpty) return null;
...@@ -51,7 +52,6 @@ class DirectionalScreen { ...@@ -51,7 +52,6 @@ class DirectionalScreen {
return DirectionalScreen._(clickActionType: name.rawValue, clickActionParam: clickActionParam); return DirectionalScreen._(clickActionType: name.rawValue, clickActionParam: clickActionParam);
} }
@immutable
bool begin() { bool begin() {
final type = DirectionalScreenNameExtension.fromRawValue(clickActionType ?? ""); final type = DirectionalScreenNameExtension.fromRawValue(clickActionType ?? "");
if (type == null) { if (type == null) {
...@@ -59,6 +59,108 @@ class DirectionalScreen { ...@@ -59,6 +59,108 @@ class DirectionalScreen {
return false; return false;
} }
switch (type) { 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: case DirectionalScreenName.setting:
Get.toNamed(settingScreen); Get.toNamed(settingScreen);
return true; return true;
...@@ -68,7 +170,7 @@ class DirectionalScreen { ...@@ -68,7 +170,7 @@ class DirectionalScreen {
case DirectionalScreenName.customerSupport: case DirectionalScreenName.customerSupport:
Get.toNamed(supportScreen); Get.toNamed(supportScreen);
return true; return true;
case DirectionalScreenName.viewDeepLink || DirectionalScreenName.link: case DirectionalScreenName.link:
BaseWebViewInput input = BaseWebViewInput(url: clickActionParam ?? ""); BaseWebViewInput input = BaseWebViewInput(url: clickActionParam ?? "");
Get.toNamed(baseWebViewScreen, arguments: input); Get.toNamed(baseWebViewScreen, arguments: input);
return true; return true;
...@@ -135,16 +237,14 @@ class DirectionalScreen { ...@@ -135,16 +237,14 @@ class DirectionalScreen {
if (uri == null) return true; if (uri == null) return true;
final requestId = const Uuid().v4(); // Cần package `uuid` final requestId = const Uuid().v4(); // Cần package `uuid`
final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, 'aff_sub3': requestId}); 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); // forceOpen(url: updatedUri, mode: mode);
safeOpenUrl(updatedUri, preferred: mode); _safeOpenUrl(updatedUri, preferred: mode);
return true; return true;
case DirectionalScreenName.refundHistory: case DirectionalScreenName.refundHistory:
Get.toNamed(historyPointCashBackScreen); Get.toNamed(historyPointCashBackScreen);
return true; return true;
case DirectionalScreenName.inviteFriend:
Get.toNamed(inviteFriendCampaignScreen);
return true;
case DirectionalScreenName.dailyCheckin || DirectionalScreenName.dailyCheckinScreen: case DirectionalScreenName.dailyCheckin || DirectionalScreenName.dailyCheckinScreen:
Get.toNamed(dailyCheckInScreen); Get.toNamed(dailyCheckInScreen);
return true; return true;
...@@ -174,7 +274,7 @@ class DirectionalScreen { ...@@ -174,7 +274,7 @@ class DirectionalScreen {
return true; return true;
case DirectionalScreenName.surveyCampaign: case DirectionalScreenName.surveyCampaign:
if ((clickActionParam ?? '').isEmpty) return false; if ((clickActionParam ?? '').isEmpty) return false;
Get.toNamed(surveyQuestionScreen, arguments: {"quizId": clickActionParam ?? ''}); Get.toNamed(surveyQuestionScreen, arguments: {"quizId": clickActionParam ?? ''});
return true; return true;
case DirectionalScreenName.myMobileCard: case DirectionalScreenName.myMobileCard:
Get.toNamed(myMobileCardListScreen); Get.toNamed(myMobileCardListScreen);
...@@ -199,7 +299,7 @@ class DirectionalScreen { ...@@ -199,7 +299,7 @@ class DirectionalScreen {
if (popup != null) { if (popup != null) {
AppNavigator.showPopup(data: popup); AppNavigator.showPopup(data: popup);
} else { } else {
screen.begin(); screen.begin();
} }
}, },
withLoading: true, withLoading: true,
...@@ -227,64 +327,59 @@ Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformD ...@@ -227,64 +327,59 @@ Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformD
return false; return false;
} }
Future<void> openAppStore(String url) async { Future<void> openStringUrlExternally(String url) async {
final uri = Uri.parse(url); final uri = Uri.parse(url);
_openUrlExternally(uri);
}
Future<void> _openUrlExternally(Uri uri) async {
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl( await launchUrl(uri, mode: LaunchMode.externalApplication);
uri,
mode: LaunchMode.externalApplication,
);
} else { } 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 { try {
// 1) Thử theo mode ưa thích // 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)) { if (!await canLaunchUrl(url)) return false;
final ok = await launchUrl(
url, // Sắp xếp các chế độ theo ưu tiên và loại bỏ trùng lặp
mode: preferred, final List<LaunchMode> modes = <LaunchMode>[
webViewConfiguration: const WebViewConfiguration( preferred,
enableJavaScript: true, LaunchMode.externalApplication,
headers: <String, String>{}, LaunchMode.inAppBrowserView,
), LaunchMode.platformDefault,
); ];
if (ok) return true; final Set<LaunchMode> seen = <LaunchMode>{};
}
// 2) Fallback: mở bằng app ngoài (trình duyệt hệ thống) for (final mode in modes) {
if (await canLaunchUrl(url)) { if (!seen.add(mode)) continue;
final ok = await launchUrl( try {
url, final ok = await launchUrl(
mode: LaunchMode.externalApplication, url,
webViewConfiguration: const WebViewConfiguration( mode: mode,
enableJavaScript: true, webViewConfiguration: const WebViewConfiguration(
headers: <String, String>{}, enableJavaScript: true,
), headers: <String, String>{},
); ),
if (ok) return true; );
} if (ok) return true;
// 3) Fallback: mở trong webview của app (Custom Tabs / SFSafariViewController) } catch (_) {
if (await canLaunchUrl(url)) { // thử chế độ tiếp theo
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;
} }
} catch (e) { } catch (_) {}
// ghi log lỗi nếu có
// debugPrint('safeOpenUrl error: $e');
}
return false; 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? { ...@@ -15,6 +15,8 @@ extension NullableString on String? {
return (s == null || s.isEmpty) ? fallback : s; return (s == null || s.isEmpty) ? fallback : s;
} }
String get orEmpty => this ?? '';
bool get hasText => (this?.trim().isNotEmpty ?? false); bool get hasText => (this?.trim().isNotEmpty ?? false);
bool get isNullOrBlank => (this == null || this!.trim().isEmpty); bool get isNullOrBlank => (this == null || this!.trim().isEmpty);
...@@ -25,13 +27,19 @@ extension StringUrlExtension on String { ...@@ -25,13 +27,19 @@ extension StringUrlExtension on String {
Uri? toUri() { Uri? toUri() {
final s = trim(); final s = trim();
if (s.isEmpty || s.contains(' ')) return null; if (s.isEmpty) return null;
final uri = Uri.tryParse(s);
if (uri == null) return null; final normalized = s.startsWith(RegExp(r'https?://', caseSensitive: false))
// Phải là URL tuyệt đối + http/https ? s
if (!uri.isAbsolute) return null; : 'https://$s';
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
return uri; 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 { ...@@ -1054,4 +1054,10 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
return VerifyRegisterCampaignModel.fromJson(data as Json); 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 ...@@ -41,9 +41,12 @@ class _GameCardScreenState extends BaseState<GameCardScreen> with BasicState, Ro
if (gameId.isNotEmpty) { if (gameId.isNotEmpty) {
_viewModel.getGameDetail(id: gameId); _viewModel.getGameDetail(id: gameId);
} }
_viewModel.onShowAlertError = (message) { _viewModel.onShowAlertError = (message, onClose) {
if (message.isEmpty) return; if (message.isEmpty) return;
showAlertError(content: message); showAlertError(content: message, showCloseButton: !onClose, onConfirmed: () {
if (!onClose) return;
Get.back();
});
}; };
_viewModel.submitGameCardSuccess = (popup) { _viewModel.submitGameCardSuccess = (popup) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
......
...@@ -7,7 +7,7 @@ import '../models/game_bundle_item_model.dart'; ...@@ -7,7 +7,7 @@ import '../models/game_bundle_item_model.dart';
class GameCardViewModel extends RestfulApiViewModel { class GameCardViewModel extends RestfulApiViewModel {
var data = Rxn<GameBundleItemModel>(); var data = Rxn<GameBundleItemModel>();
void Function(String message)? onShowAlertError; void Function(String message, bool onClose)? onShowAlertError;
void Function(PopupDataModel popup)? submitGameCardSuccess; void Function(PopupDataModel popup)? submitGameCardSuccess;
void Function()? getGameDetailSuccess; void Function()? getGameDetailSuccess;
...@@ -19,7 +19,7 @@ class GameCardViewModel extends RestfulApiViewModel { ...@@ -19,7 +19,7 @@ class GameCardViewModel extends RestfulApiViewModel {
if (response.isSuccess && popupData != null) { if (response.isSuccess && popupData != null) {
submitGameCardSuccess?.call(popupData); submitGameCardSuccess?.call(popupData);
} else { } else {
onShowAlertError?.call(response.errorMessage ?? Constants.commonError); onShowAlertError?.call(response.errorMessage ?? Constants.commonError, false);
} }
} }
...@@ -31,7 +31,7 @@ class GameCardViewModel extends RestfulApiViewModel { ...@@ -31,7 +31,7 @@ class GameCardViewModel extends RestfulApiViewModel {
data.value = response.data; data.value = response.data;
getGameDetailSuccess?.call(); getGameDetailSuccess?.call();
} else { } 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:flutter/material.dart';
import 'package:game_miniapp/game_miniapp.dart';
import 'package:get/get.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/header_home_widget.dart';
import 'package:mypoint_flutter_app/screen/home/custom_widget/product_grid_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'; ...@@ -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/screen/voucher/models/product_model.dart';
import 'package:mypoint_flutter_app/shared/router_gage.dart'; import 'package:mypoint_flutter_app/shared/router_gage.dart';
import '../../directional/directional_action_type.dart'; import '../../directional/directional_action_type.dart';
import '../../directional/directional_screen.dart';
import '../popup_manager/popup_runner_helper.dart'; import '../popup_manager/popup_runner_helper.dart';
import 'custom_widget/achievement_carousel_widget.dart'; import 'custom_widget/achievement_carousel_widget.dart';
import 'custom_widget/affiliate_brand_grid_widget.dart'; import 'custom_widget/affiliate_brand_grid_widget.dart';
...@@ -138,7 +138,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -138,7 +138,7 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
} }
break; break;
case HeaderSectionType.flashSale: case HeaderSectionType.flashSale:
final products = _viewModel.flashSaleData?.value?.products ?? []; final products = _viewModel.flashSaleData.value?.products ?? [];
if (products.isNotEmpty) { if (products.isNotEmpty) {
sections.add( sections.add(
FlashSaleCarouselWidget( FlashSaleCarouselWidget(
...@@ -245,8 +245,4 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit { ...@@ -245,8 +245,4 @@ class _HomeScreenState extends State<HomeScreen> with PopupOnInit {
await _viewModel.loadDataPiPiHome(); await _viewModel.loadDataPiPiHome();
await _headerHomeVM.freshData(); await _headerHomeVM.freshData();
} }
void _showMiniGame(BuildContext context) async {
Navigator.push(context, MaterialPageRoute(builder: (_) => const GameMiniAppScreen()));
}
} }
...@@ -22,7 +22,6 @@ class PopupManagerViewModel extends RestfulApiViewModel { ...@@ -22,7 +22,6 @@ class PopupManagerViewModel extends RestfulApiViewModel {
Future<void> _getPopupManagerDataInternal() async { Future<void> _getPopupManagerDataInternal() async {
try { try {
const Duration(seconds: 3); // Giả lập thời gian tải dữ liệu
final response = await client.getPopupManagerCommonScreen(); final response = await client.getPopupManagerCommonScreen();
_popupData = response.data ?? []; _popupData = response.data ?? [];
// _popupData = [ // _popupData = [
......
...@@ -2,6 +2,7 @@ import 'dart:math'; ...@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.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/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/detail/store_list_section.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_type.dart'; import 'package:mypoint_flutter_app/screen/voucher/models/product_type.dart';
import 'package:mypoint_flutter_app/screen/voucher/voucher_code_card_screen.dart'; import 'package:mypoint_flutter_app/screen/voucher/voucher_code_card_screen.dart';
...@@ -291,8 +292,9 @@ class _VoucherDetailScreenState extends BaseState<VoucherDetailScreen> with Basi ...@@ -291,8 +292,9 @@ class _VoucherDetailScreenState extends BaseState<VoucherDetailScreen> with Basi
brand.website ?? '', brand.website ?? '',
onTap: () { onTap: () {
final website = brand.website?.trim() ?? ""; final website = brand.website?.trim() ?? "";
final url = website.startsWith('http') ? website : 'https://${brand.website}'; final uri = website.toUri();
_launchUri(Uri(scheme: url)); if (uri == null) return;
_launchUri(uri);
}, },
), ),
], ],
...@@ -300,8 +302,9 @@ class _VoucherDetailScreenState extends BaseState<VoucherDetailScreen> with Basi ...@@ -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)) { if (await canLaunchUrl(uri)) {
print('Launching $uri');
await launchUrl(uri); await launchUrl(uri);
} else { } else {
throw 'Could not launch $uri'; throw 'Could not launch $uri';
......
...@@ -10,6 +10,7 @@ import 'package:mypoint_flutter_app/screen/voucher/models/product_media_item.dar ...@@ -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_price_model.dart';
import 'package:mypoint_flutter_app/screen/voucher/models/product_properties_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/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 '../../flash_sale/preview_flash_sale_model.dart';
import 'media_type.dart'; import 'media_type.dart';
import 'my_product_status_type.dart'; import 'my_product_status_type.dart';
...@@ -162,3 +163,20 @@ class ProductPreviewCampaignModel { ...@@ -162,3 +163,20 @@ class ProductPreviewCampaignModel {
factory ProductPreviewCampaignModel.fromJson(Map<String, dynamic> json) => _$ProductPreviewCampaignModelFromJson(json); factory ProductPreviewCampaignModel.fromJson(Map<String, dynamic> json) => _$ProductPreviewCampaignModelFromJson(json);
Map<String, dynamic> toJson() => _$ProductPreviewCampaignModelToJson(this); 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:flutter/material.dart';
import 'package:get/get.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 '../../../shared/router_gage.dart';
import '../../../widgets/custom_empty_widget.dart'; import '../../../widgets/custom_empty_widget.dart';
import '../../../widgets/custom_navigation_bar.dart'; import '../../../widgets/custom_navigation_bar.dart';
import '../../../widgets/custom_search_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 '../sub_widget/voucher_item_list.dart';
import 'voucher_list_viewmodel.dart'; import 'voucher_list_viewmodel.dart';
class VoucherListScreen extends StatefulWidget { class VoucherListScreen extends BaseScreen {
const VoucherListScreen({super.key}); const VoucherListScreen({super.key});
@override @override
_VoucherListScreenState createState() => _VoucherListScreenState(); _VoucherListScreenState createState() => _VoucherListScreenState();
} }
class _VoucherListScreenState extends State<VoucherListScreen> { class _VoucherListScreenState extends BaseState<VoucherListScreen> with BasicState {
late final Map<String, dynamic> args; late final Map<String, dynamic> args;
late final bool enableSearch; late final bool enableSearch;
late final bool isHotProduct; late final bool isHotProduct;
late final bool isFavorite; late final bool isFavorite;
late final VoucherListViewModel _viewModel; 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 @override
void initState() { void initState() {
...@@ -30,74 +37,173 @@ class _VoucherListScreenState extends State<VoucherListScreen> { ...@@ -30,74 +37,173 @@ class _VoucherListScreenState extends State<VoucherListScreen> {
isHotProduct = args['isHotProduct'] ?? false; isHotProduct = args['isHotProduct'] ?? false;
isFavorite = args['favorite'] ?? false; isFavorite = args['favorite'] ?? false;
_viewModel = Get.put(VoucherListViewModel(isHotProduct: isHotProduct, isFavorite: isFavorite)); _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 @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'); final String title = isFavorite ? 'Yêu thích' : (isHotProduct ? 'Săn ưu đãi' : 'Tất cả ưu đãi');
return Scaffold( return Scaffold(
appBar: appBar:
enableSearch enableSearch
? CustomSearchNavigationBar(onSearchChanged: _viewModel.onSearchChanged,) ? CustomSearchNavigationBar(onSearchChanged: _viewModel.onSearchChanged)
: CustomNavigationBar(title: title), : CustomNavigationBar(title: title),
body: Column( body: Stack(
children: [ children: [
if (enableSearch) Column(
Padding( children: [
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), if (enableSearch)
child: Obx(() { Padding(
final resultCount = _viewModel.totalResult.value; padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
final displayText = _viewModel.searchQuery.isNotEmpty child: Obx(() {
? '$title ($resultCount kết quả)' final resultCount = _viewModel.totalResult.value;
: title; final displayText = _viewModel.searchQuery.isNotEmpty ? '$title ($resultCount kết quả)' : title;
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(displayText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
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(), if (_remainingSeconds > 0)
itemCount: _viewModel.products.length + (_viewModel.hasMore ? 1 : 0), Positioned(
itemBuilder: (context, index) { right: 12,
if (index >= _viewModel.products.length) { bottom: 44,
_viewModel.loadData(reset: false); child: Stack(
return const Center( children: [
child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), Image.asset(
); 'assets/images/ic_count_down_time_voucher.png',
} width: 90,
final product = _viewModel.products[index]; height: 90,
return GestureDetector( fit: BoxFit.contain,
onTap: () async { ),
await Get.toNamed(voucherDetailScreen, arguments: {"productId": product.id}); Positioned(
_viewModel.loadData(reset: true); bottom: 4,
}, right: 0,
child: VoucherListItem(product: product), 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