Skip to content
GitLab
Menu
Projects
Groups
Snippets
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
Hoàng Văn Đạt
mypoint_flutter_app
Commits
6c72edcb
Commit
6c72edcb
authored
Oct 14, 2025
by
DatHV
Browse files
update logic direction
parent
97763d9b
Changes
24
Hide whitespace changes
Inline
Side-by-side
android/app/src/main/AndroidManifest.xml
View file @
6c72edcb
...
...
@@ -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 -->
...
...
assets/images/ic_count_down_time_voucher.png
0 → 100644
View file @
6c72edcb
19.9 KB
export_dev.sh
View file @
6c72edcb
#!/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
ios/Runner/Info.plist
View file @
6c72edcb
...
...
@@ -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>
lib/base/app_loading.dart
View file @
6c72edcb
...
...
@@ -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
...
...
lib/base/base_screen.dart
View file @
6c72edcb
...
...
@@ -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
)
{
on
St
ar
t
();
on
DidAppe
ar
();
}
});
}
...
...
@@ -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
();
});
}
}
lib/configs/api_paths.dart
View file @
6c72edcb
...
...
@@ -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
lib/configs/constants.dart
View file @
6c72edcb
...
...
@@ -6,6 +6,7 @@ class Constants {
static
var
phoneNumberCount
=
10
;
static
var
timeoutSeconds
=
30
;
static
const
loadingTimeoutSeconds
=
30
;
static
const
appStoreId
=
'1495923300'
;
}
class
ErrorCodes
{
...
...
lib/core/app_initializer.dart
View file @
6c72edcb
...
...
@@ -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
lib/core/deep_link_service.dart
0 → 100644
View file @
6c72edcb
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
();
}
}
lib/directional/directional_screen.dart
View file @
6c72edcb
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
>
open
AppStore
(
String
url
)
async
{
Future
<
void
>
open
StringUrlExternally
(
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:
$ur
l
"
);
debugPrint
(
"⚠️ Không thể mở URL:
$ur
i
"
);
}
}
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
);
}
lib/extensions/string_extension.dart
View file @
6c72edcb
...
...
@@ -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
;
}
}
}
...
...
lib/networking/restful_api_client_all_request.dart
View file @
6c72edcb
...
...
@@ -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
lib/screen/game/game_cards/game_card_screen.dart
View file @
6c72edcb
...
...
@@ -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
((
_
)
{
...
...
lib/screen/game/game_cards/game_card_viewmodel.dart
View file @
6c72edcb
...
...
@@ -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
lib/screen/home/home_screen.dart
View file @
6c72edcb
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
()));
}
}
lib/screen/popup_manager/popup_manager_viewmodel.dart
View file @
6c72edcb
...
...
@@ -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 = [
...
...
lib/screen/voucher/detail/voucher_detail_screen.dart
View file @
6c72edcb
...
...
@@ -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
'
;
...
...
lib/screen/voucher/models/product_model.dart
View file @
6c72edcb
...
...
@@ -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
lib/screen/voucher/voucher_list/voucher_list_screen.dart
View file @
6c72edcb
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
Base
State
<
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
}
Prev
1
2
Next
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment