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
b7cceccb
Commit
b7cceccb
authored
Oct 03, 2025
by
DatHV
Browse files
update config web + fix bug
parent
42a99a61
Changes
36
Hide whitespace changes
Inline
Side-by-side
.metadata
0 → 100644
View file @
b7cceccb
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: web
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
add_url_params.sh
0 → 100755
View file @
b7cceccb
#!/bin/bash
# Script để tự động thêm URL parameter handling vào index.html sau khi build
echo
"🔧 Adding URL parameter handling to index.html..."
# Tìm file index.html trong build/web
INDEX_FILE
=
"build/web/index.html"
if
[
!
-f
"
$INDEX_FILE
"
]
;
then
echo
"❌ File
$INDEX_FILE
not found!"
exit
1
fi
# Tạo backup
cp
"
$INDEX_FILE
"
"
$INDEX_FILE
.backup"
# Không cần thêm JavaScript nữa vì Flutter đọc trực tiếp từ URL
echo
"✅ No JavaScript needed - Flutter reads directly from URL query parameters"
echo
"✅ Added URL parameter handling to
$INDEX_FILE
"
echo
"🎉 Done! Now you can test with URLs like:"
echo
" http://localhost:8080/?token=abc123"
echo
" http://localhost:8080/?token=abc123&userId=user456"
export_web.sh
0 → 100755
View file @
b7cceccb
#!/bin/bash
# Script để export Flutter web app thành HTML/CSS cho production
# Tạo package sẵn sàng để deploy cho bên web
echo
"🚀 Exporting Flutter web app for production..."
# Kill server cũ trên port 8080 (nếu có)
echo
"🛑 Stopping any existing server on :8080..."
lsof
-i
:8080 |
awk
'NR>1 {print $2}'
| xargs
kill
-9
2>/dev/null
||
true
# Clear cache build để tránh dính SW/cache cũ
echo
"🧹 Clearing build caches..."
flutter clean
rm
-rf
.dart_tool build
flutter pub get
# Tạo thư mục export
EXPORT_DIR
=
"web_export_
$(
date
+%Y%m%d_%H%M%S
)
"
echo
"📁 Creating export directory:
$EXPORT_DIR
"
mkdir
-p
"
$EXPORT_DIR
"
# Build web app với tối ưu hóa (và tắt PWA/SW để tránh cache)
echo
"🔨 Building Flutter web app (WASM, no PWA)..."
flutter build web
--release
--wasm
--optimization-level
=
4
--source-maps
--pwa-strategy
=
none
if
[
$?
-eq
0
]
;
then
echo
"✅ Build thành công!"
# Copy toàn bộ nội dung từ build/web
echo
"📦 Copying web files..."
cp
-r
build/web/
*
"
$EXPORT_DIR
/"
# Copy firebase-messaging-sw.js nếu chưa có
if
[
!
-f
"
$EXPORT_DIR
/firebase-messaging-sw.js"
]
;
then
cp
web/firebase-messaging-sw.js
"
$EXPORT_DIR
/"
fi
# Tạo file test để demo URL parameters
cat
>
"
$EXPORT_DIR
/test_urls.html"
<<
'
EOF
'
<!DOCTYPE html>
<html>
<head>
<title>Test URL Parameters</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.test-link { display: block; margin: 10px 0; padding: 10px; background: #f0f0f0; text-decoration: none; color: #333; }
.test-link:hover { background: #e0e0e0; }
</style>
</head>
<body>
<h1>Test URL Parameters</h1>
<p>Click các link dưới đây để test URL parameters:</p>
<a href="?token=abc123" class="test-link">Test với token: abc123</a>
<a href="?token=abc123&userId=user456" class="test-link">Test với token + userId</a>
<a href="?token=admin789&userId=admin001" class="test-link">Test với admin token</a>
<a href="/" class="test-link">Test không có parameters (onboarding)</a>
<h2>Debug Info:</h2>
<div id="debug-info"></div>
<script>
// Hiển thị thông tin debug
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const userId = urlParams.get('userId');
document.getElementById('debug-info').innerHTML = `
<p><strong>Current URL:</strong>
${
window
.location.href
}
</p>
<p><strong>Token:</strong>
${
token
||
'None'
}
</p>
<p><strong>UserId:</strong>
${
userId
||
'None'
}
</p>
<p><strong>localStorage url_token:</strong>
${
localStorage
.getItem(
'url_token'
) ||
'None'
}
</p>
<p><strong>localStorage url_user_id:</strong>
${
localStorage
.getItem(
'url_user_id'
) ||
'None'
}
</p>
`;
</script>
</body>
</html>
EOF
echo
"📝 Created test_urls.html for testing"
# Tạo file README cho bên web
cat
>
"
$EXPORT_DIR
/README_DEPLOYMENT.md"
<<
'
EOF
'
# MyPoint Flutter Web App - Deployment Guide
## Cấu trúc thư mục
- `index.html` - File chính của ứng dụng
- `main.dart.js` - JavaScript code của Flutter app
- `flutter_bootstrap.js` - Flutter bootstrap script
- `canvaskit/` - Flutter rendering engine
- `assets/` - Images, fonts, và data files
- `firebase-messaging-sw.js` - Firebase service worker
## Yêu cầu server
- Web server hỗ trợ serving static files
- HTTPS được khuyến nghị cho PWA features
- CORS headers nếu cần thiết cho API calls
## Cấu hình server
1. Serve tất cả files từ thư mục này
2. Đảm bảo MIME types đúng:
- `.js` files: `application/javascript`
- `.wasm` files: `application/wasm`
- `.json` files: `application/json`
- `.png/.jpg` files: `image/*`
## Environment Variables
App sử dụng các biến môi trường từ `assets/config/env.json`
Có thể override bằng query parameters: `?env=production`
## Firebase Configuration
Firebase config được load từ `firebase_options.dart`
Đảm bảo Firebase project được cấu hình đúng cho domain này.
## Troubleshooting
- Nếu có lỗi CORS, cấu hình server để allow origin của domain
- Nếu có lỗi Firebase, kiểm tra domain trong Firebase console
- Nếu có lỗi assets, đảm bảo đường dẫn relative đúng
EOF
# Tạo file .htaccess cho Apache
cat
>
"
$EXPORT_DIR
/.htaccess"
<<
'
EOF
'
# Apache configuration for Flutter web app
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
AddOutputFilterByType DEFLATE application/wasm
</IfModule>
# Set correct MIME types
AddType application/javascript .js
AddType application/wasm .wasm
AddType application/json .json
# Cache static assets
<IfModule mod_expires.c>
ExpiresActive on
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/wasm "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header always set X-Content-Type-Options nosniff
Header always set X-Frame-Options DENY
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# CORS headers (uncomment if needed)
# <IfModule mod_headers.c>
# Header always set Access-Control-Allow-Origin "*"
# Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
# Header always set Access-Control-Allow-Headers "Content-Type"
# </IfModule>
EOF
# Tạo file nginx.conf cho Nginx
cat
>
"
$EXPORT_DIR
/nginx.conf"
<<
'
EOF
'
# Nginx configuration for Flutter web app
server {
listen 80;
server_name your-domain.com;
root /path/to/web_export;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json application/wasm;
# Cache static assets
location ~*
\.
(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|wasm)
$
{
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle Flutter routes
location / {
try_files
$uri
$uri
/ /index.html;
}
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# CORS headers (uncomment if needed)
# add_header Access-Control-Allow-Origin "*";
# add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
# add_header Access-Control-Allow-Headers "Content-Type";
}
EOF
# Tạo file package.json cho Node.js hosting
cat
>
"
$EXPORT_DIR
/package.json"
<<
'
EOF
'
{
"name": "mypoint-flutter-web",
"version": "1.0.0",
"description": "MyPoint Flutter Web Application",
"main": "index.html",
"scripts": {
"start": "npx serve -s . -l 3000",
"build": "echo 'Already built by Flutter'",
"serve": "npx serve -s . -l 3000"
},
"dependencies": {
"serve": "^14.0.0"
},
"engines": {
"node": ">=14.0.0"
}
}
EOF
# Tạo file docker-compose.yml cho Docker deployment
cat
>
"
$EXPORT_DIR
/docker-compose.yml"
<<
'
EOF
'
version: '3.8'
services:
mypoint-web:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- .:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
EOF
# Tạo file Dockerfile
cat
>
"
$EXPORT_DIR
/Dockerfile"
<<
'
EOF
'
FROM nginx:alpine
# Copy web files
COPY . /usr/share/nginx/html/
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
EOF
# Tạo zip file để dễ dàng transfer
echo
"📦 Creating zip package..."
zip
-r
"
${
EXPORT_DIR
}
.zip"
"
$EXPORT_DIR
"
echo
""
echo
"✅ Export hoàn thành!"
echo
"📁 Thư mục export:
$EXPORT_DIR
"
echo
"📦 Zip file:
${
EXPORT_DIR
}
.zip"
echo
""
echo
"🚀 Cách deploy:"
echo
"1. Upload toàn bộ nội dung thư mục
$EXPORT_DIR
lên web server"
echo
"2. Cấu hình web server theo hướng dẫn trong README_DEPLOYMENT.md"
echo
"3. Hoặc sử dụng Docker: docker-compose up -d"
echo
"4. Hoặc sử dụng Node.js: npm install && npm start"
echo
""
echo
"🌐 Test local: cd
$EXPORT_DIR
&& python3 -m http.server 8080"
echo
"💡 Tip: Nếu muốn auto-run server, chạy: (cd
$EXPORT_DIR
&& python3 -m http.server 8080 &)"
else
echo
"❌ Build thất bại!"
exit
1
fi
ios/.env_scheme
View file @
b7cceccb
pro
stg
lib/configs/constants.dart
View file @
b7cceccb
...
...
@@ -5,7 +5,7 @@ class Constants {
static
var
directionInApp
=
"IN-APP"
;
static
var
phoneNumberCount
=
10
;
static
var
timeoutSeconds
=
30
;
static
const
loadingTimeoutSeconds
=
1
0
;
static
const
loadingTimeoutSeconds
=
3
0
;
}
class
ErrorCodes
{
...
...
lib/configs/device_info.dart
View file @
b7cceccb
import
'dart:ffi'
as
MediaQuery
;
import
'dart:ui'
as
ui
;
import
'package:device_info_plus/device_info_plus.dart'
;
import
'package:flutter/foundation.dart'
;
...
...
@@ -41,67 +40,93 @@ class DeviceDetails {
}
class
DeviceInfo
{
static
const
String
_deviceIdPreferenceKey
=
'device_id'
;
static
String
?
_cachedDeviceId
;
static
DeviceDetails
?
_cachedDetails
;
static
Future
<
String
>
getDeviceId
()
async
{
SharedPreferences
prefs
=
await
SharedPreferences
.
getInstance
();
String
?
deviceId
=
prefs
.
getString
(
'device_id'
);
if
(
deviceId
==
null
)
{
deviceId
=
const
Uuid
().
v4
();
// Tạo UUID mới
await
prefs
.
setString
(
'device_id'
,
deviceId
);
}
return
deviceId
;
_cachedDeviceId
=
"1c8a26fc-748f-4d1a-a064-af8d7874df6a"
;
// if (_cachedDeviceId != null) return _cachedDeviceId!;
// final prefs = await SharedPreferences.getInstance();
// String? deviceId = prefs.getString(_deviceIdPreferenceKey);
// if (deviceId == null || deviceId.isEmpty) {
// deviceId = const Uuid().v4();
// await prefs.setString(_deviceIdPreferenceKey, deviceId);
// }
// _cachedDeviceId = deviceId;
return
_cachedDeviceId
!;
}
static
Future
<
DeviceDetails
>
getDetails
()
async
{
if
(
_cachedDetails
!=
null
)
return
_cachedDetails
!;
final
deviceInfo
=
DeviceInfoPlugin
();
final
pkg
=
await
PackageInfo
.
fromPlatform
();
// OS + version
String
os
,
osVersion
=
''
;
if
(
kIsWeb
)
{
os
=
'Web'
;
final
web
=
await
deviceInfo
.
webBrowserInfo
;
osVersion
=
[
web
.
browserName
.
name
,
if
((
web
.
appVersion
??
''
).
isNotEmpty
)
'(
${web.appVersion}
)'
,
].
where
((
e
)
=>
e
.
toString
().
isNotEmpty
).
join
(
' '
);
}
else
if
(
Platform
.
isIOS
)
{
os
=
'iOS'
;
final
ios
=
await
deviceInfo
.
iosInfo
;
osVersion
=
ios
.
systemVersion
;
}
else
if
(
Platform
.
isAndroid
)
{
os
=
'Android'
;
final
and
=
await
deviceInfo
.
androidInfo
;
final
rel
=
and
.
version
.
release
;
final
sdk
=
and
.
version
.
sdkInt
.
toString
();
osVersion
=
sdk
.
isEmpty
?
rel
:
'
$rel
(SDK
$sdk
)'
;
}
else
{
os
=
Platform
.
operatingSystem
;
// macOS/linux/windows
osVersion
=
Platform
.
operatingSystemVersion
;
String
os
=
'Unknown'
;
String
osVersion
=
'Unknown'
;
try
{
if
(
kIsWeb
)
{
os
=
'Web'
;
final
web
=
await
deviceInfo
.
webBrowserInfo
;
final
browser
=
web
.
browserName
.
name
;
final
appVersion
=
(
web
.
appVersion
??
''
).
trim
();
osVersion
=
appVersion
.
isEmpty
?
browser
:
'
$browser
(
$appVersion
)'
;
}
else
if
(
Platform
.
isIOS
)
{
os
=
'iOS'
;
final
ios
=
await
deviceInfo
.
iosInfo
;
osVersion
=
ios
.
systemVersion
;
}
else
if
(
Platform
.
isAndroid
)
{
os
=
'Android'
;
final
and
=
await
deviceInfo
.
androidInfo
;
final
rel
=
and
.
version
.
release
;
final
sdk
=
and
.
version
.
sdkInt
?.
toString
()
??
''
;
osVersion
=
sdk
.
isEmpty
?
rel
:
'
$rel
(SDK
$sdk
)'
;
}
else
{
os
=
Platform
.
operatingSystem
;
osVersion
=
Platform
.
operatingSystemVersion
;
}
}
catch
(
_
)
{
// keep defaults
}
// Model + userDeviceName + iPad fallback
String
hardwareModel
=
'Unknown'
;
String
userDeviceName
=
''
;
String
userDeviceName
=
'
Unknown
'
;
bool
fallbackIsTablet
=
false
;
if
(
kIsWeb
)
{
hardwareModel
=
'Browser'
;
}
else
if
(
Platform
.
isIOS
)
{
final
ios
=
await
deviceInfo
.
iosInfo
;
hardwareModel
=
ios
.
utsname
.
machine
;
userDeviceName
=
ios
.
name
;
fallbackIsTablet
=
(
ios
.
model
.
toLowerCase
().
contains
(
'ipad'
))
||
(
ios
.
utsname
.
machine
.
toLowerCase
().
startsWith
(
'ipad'
));
}
else
if
(
Platform
.
isAndroid
)
{
final
and
=
await
deviceInfo
.
androidInfo
;
final
brand
=
and
.
brand
.
trim
();
final
model
=
and
.
model
.
trim
();
hardwareModel
=
[
brand
,
model
].
where
((
e
)
=>
e
.
isNotEmpty
).
join
(
' '
);
userDeviceName
=
and
.
device
;
fallbackIsTablet
=
false
;
try
{
if
(
kIsWeb
)
{
hardwareModel
=
'Browser'
;
userDeviceName
=
'Web Client'
;
}
else
if
(
Platform
.
isIOS
)
{
final
ios
=
await
deviceInfo
.
iosInfo
;
hardwareModel
=
(
ios
.
utsname
.
machine
).
trim
();
userDeviceName
=
(
ios
.
name
).
trim
();
final
modelLower
=
(
ios
.
model
).
toLowerCase
();
final
machineLower
=
(
ios
.
utsname
.
machine
).
toLowerCase
();
fallbackIsTablet
=
modelLower
.
contains
(
'ipad'
)
||
machineLower
.
startsWith
(
'ipad'
);
}
else
if
(
Platform
.
isAndroid
)
{
final
and
=
await
deviceInfo
.
androidInfo
;
final
brand
=
(
and
.
brand
).
trim
();
final
model
=
(
and
.
model
).
trim
();
hardwareModel
=
[
brand
,
model
].
where
((
e
)
=>
e
.
isNotEmpty
).
join
(
' '
);
userDeviceName
=
(
and
.
device
).
trim
().
isEmpty
?
'Android Device'
:
(
and
.
device
).
trim
();
}
else
{
hardwareModel
=
os
;
userDeviceName
=
'Desktop'
;
}
}
catch
(
_
)
{
// keep defaults
}
final
hardwareType
=
_detectHardwareTypeWithoutContext
(
os:
os
,
fallbackIsTablet:
fallbackIsTablet
);
final
hardwareType
=
_detectHardwareTypeWithoutContext
(
os:
os
,
fallbackIsTablet:
fallbackIsTablet
,
);
return
DeviceDetails
(
_cachedDetails
=
DeviceDetails
(
hardwareType:
hardwareType
,
hardwareModel:
hardwareModel
,
operatingSystem:
os
,
...
...
@@ -110,6 +135,8 @@ class DeviceInfo {
appVersion:
pkg
.
version
,
appBuildNumber:
pkg
.
buildNumber
,
);
return
_cachedDetails
!;
}
/// Không cần BuildContext: dùng kích thước của view đầu tiên từ PlatformDispatcher.
...
...
lib/configs/url_params.dart
0 → 100644
View file @
b7cceccb
import
'package:flutter/foundation.dart'
;
class
UrlParams
{
static
String
?
_token
;
static
String
?
_userId
;
static
String
?
get
token
=>
_token
;
static
String
?
get
userId
=>
_userId
;
static
void
setToken
(
String
?
token
)
=>
_token
=
token
;
static
void
setUserId
(
String
?
userId
)
=>
_userId
=
userId
;
static
Map
<
String
,
String
?>
get
allParams
=>
{
'token'
:
token
,
'user_id'
:
userId
,
};
static
bool
get
hasToken
=>
token
!=
null
&&
token
!.
isNotEmpty
;
static
bool
get
hasUserId
=>
userId
!=
null
&&
userId
!.
isNotEmpty
;
// Helper method để lấy token cho API calls
static
String
?
getTokenForApi
()
{
return
token
;
}
// Helper method để lấy userId cho API calls
static
String
?
getUserIdForApi
()
{
return
userId
;
}
@override
String
toString
()
=>
'UrlParams:
$allParams
'
;
}
lib/env_loader.dart
View file @
b7cceccb
...
...
@@ -41,7 +41,10 @@ class AppConfig {
Future
<
void
>
loadEnv
()
async
{
Map
<
String
,
dynamic
>?
cfg
;
if
(
Platform
.
isIOS
)
{
if
(
kIsWeb
)
{
// Web platform: chỉ load từ assets
cfg
=
await
_tryLoadAsset
(
'assets/config/env.json'
);
}
else
if
(
Platform
.
isIOS
)
{
cfg
=
await
_tryLoadAsset
(
'assets/config/env.json'
);
cfg
??=
await
_tryLoadFromMethodChannel
();
}
else
if
(
Platform
.
isAndroid
)
{
...
...
lib/firebase/push_setup.dart
View file @
b7cceccb
...
...
@@ -14,7 +14,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
print
(
'_firebaseMessagingBackgroundHandler
${message.toMap()}
'
);
// Android: data-only message sẽ không tự hiển thị. Tự show local notification
if
(
Platform
.
isAndroid
)
{
if
(
!
kIsWeb
&&
Platform
.
isAndroid
)
{
final
data
=
message
.
data
;
final
title
=
message
.
notification
?.
title
??
data
[
'title'
]?.
toString
();
final
body
=
message
.
notification
?.
body
??
(
data
[
'body'
]?.
toString
()
??
data
[
'content'
]?.
toString
());
...
...
@@ -102,17 +102,41 @@ Future<void> handleLocalNotificationLaunchIfAny() async {
}
Future
<
void
>
initFirebaseAndFcm
()
async
{
await
Firebase
.
initializeApp
(
options:
DefaultFirebaseOptions
.
currentPlatform
);
FirebaseMessaging
.
onBackgroundMessage
(
_firebaseMessagingBackgroundHandler
);
final
messaging
=
FirebaseMessaging
.
instance
;
FirebaseMessaging
?
messaging
;
try
{
await
Firebase
.
initializeApp
(
options:
DefaultFirebaseOptions
.
currentPlatform
);
FirebaseMessaging
.
onBackgroundMessage
(
_firebaseMessagingBackgroundHandler
);
messaging
=
FirebaseMessaging
.
instance
;
// Quyền iOS / Android 13+
if
(
Platform
.
isIOS
)
{
await
messaging
.
requestPermission
(
alert:
true
,
badge:
true
,
sound:
true
);
}
else
{
await
messaging
.
requestPermission
();
// Android 13+ POST_NOTIFICATIONS
// Quyền iOS / Android 13+ / Web: chỉ tắt trên Web, mobile giữ nguyên
if
(
kIsWeb
)
{
// Tắt auto init để tránh plugin tự động thao tác permission trên web
try
{
await
FirebaseMessaging
.
instance
.
setAutoInitEnabled
(
false
);
if
(
kDebugMode
)
{
print
(
'Web: FCM auto-init disabled'
);
}
}
catch
(
_
)
{}
if
(
kDebugMode
)
{
print
(
'Web: skip requesting notification permission at startup'
);
}
}
else
if
(
Platform
.
isIOS
)
{
await
messaging
.
requestPermission
(
alert:
true
,
badge:
true
,
sound:
true
);
}
else
{
await
messaging
.
requestPermission
();
// Android 13+ POST_NOTIFICATIONS
}
await
_initLocalNotifications
();
}
catch
(
e
)
{
if
(
kDebugMode
)
{
print
(
'Firebase initialization error:
$e
'
);
}
// Continue without Firebase on web if there's an error
if
(
kIsWeb
)
{
return
;
}
rethrow
;
}
await
_initLocalNotifications
();
// Foreground: Android không tự hiển thị -> ta show local notification
FirebaseMessaging
.
onMessage
.
listen
((
message
)
{
...
...
@@ -122,7 +146,8 @@ Future<void> initFirebaseAndFcm() async {
print
(
'Data:
${message.data}
'
);
print
(
'Notification:
${message.notification?.title}
-
${message.notification?.body}
'
);
}
// if (Platform.isAndroid) {
// Only show local notifications on mobile platforms, not web
if
(!
kIsWeb
)
{
final
n
=
message
.
notification
;
final
title
=
n
?.
title
??
(
message
.
data
[
'title'
]?.
toString
());
final
body
=
n
?.
body
??
(
message
.
data
[
'body'
]?.
toString
());
...
...
@@ -142,16 +167,62 @@ Future<void> initFirebaseAndFcm() async {
payload:
message
.
data
.
isNotEmpty
?
jsonEncode
(
message
.
data
)
:
null
,
);
}
//
}
}
});
// User click notification mở app (khi app đang chạy ở background)
FirebaseMessaging
.
onMessageOpenedApp
.
listen
((
message
)
{
NotificationRouter
.
handleRemoteMessage
(
message
);
});
// Initial message sẽ được xử lý sau khi runApp trong main.dart
// Lấy token để test gửi
final
token
=
await
messaging
.
getToken
();
// if (kDebugMode) {
print
(
'FCM token:
$token
'
);
// }
// Lấy token để test gửi (bật cho mobile, tắt cho web)
if
(
messaging
!=
null
&&
!
kIsWeb
)
{
try
{
final
token
=
await
messaging
.
getToken
();
if
(
kDebugMode
)
{
print
(
'FCM token:
$token
'
);
}
}
catch
(
e
)
{
if
(
kDebugMode
)
{
print
(
'Error getting FCM token:
$e
'
);
}
}
}
}
/// Call this on WEB from a user gesture (e.g., button tap) to request notification permission.
Future
<
AuthorizationStatus
?>
requestWebNotificationPermission
()
async
{
// ĐÃ TẮT CHO WEB: Không request permission trên web
if
(
kIsWeb
)
{
if
(
kDebugMode
)
{
print
(
'Web notifications disabled: skip requesting permission'
);
}
return
AuthorizationStatus
.
denied
;
}
return
null
;
}
/// Get FCM token if available. On Web, ensure permission is granted first.
Future
<
String
?>
getFcmTokenIfAvailable
()
async
{
try
{
final
messaging
=
FirebaseMessaging
.
instance
;
if
(
kIsWeb
)
{
final
settings
=
await
messaging
.
getNotificationSettings
();
if
(
settings
.
authorizationStatus
!=
AuthorizationStatus
.
authorized
)
{
if
(
kDebugMode
)
{
print
(
'Web notifications not authorized. Cannot retrieve FCM token.'
);
}
return
null
;
}
}
final
token
=
await
messaging
.
getToken
();
if
(
kDebugMode
)
{
print
(
'FCM token (if available):
$token
'
);
}
return
token
;
}
catch
(
e
)
{
if
(
kDebugMode
)
{
print
(
'Error retrieving FCM token:
$e
'
);
}
return
null
;
}
}
lib/firebase/push_token_service.dart
View file @
b7cceccb
import
'package:firebase_messaging/firebase_messaging.dart'
;
import
'package:flutter/foundation.dart'
;
import
'package:mypoint_flutter_app/networking/restful_api_client_all_request.dart'
;
import
'package:mypoint_flutter_app/networking/restful_api_viewmodel.dart'
;
import
'package:mypoint_flutter_app/preference/data_preference.dart'
;
...
...
@@ -9,6 +10,7 @@ class PushTokenService extends RestfulApiViewModel {
factory
PushTokenService
()
=>
_instance
;
static
Future
<
void
>
uploadIfLogged
({
String
?
fcmToken
})
async
{
if
(
kIsWeb
)
return
;
final
isLogged
=
DataPreference
.
instance
.
logged
;
if
(!
isLogged
)
return
;
final
token
=
fcmToken
??
await
FirebaseMessaging
.
instance
.
getToken
();
...
...
lib/main.dart
View file @
b7cceccb
import
'package:flutter/material.dart'
;
import
'package:flutter/foundation.dart'
;
import
'package:firebase_messaging/firebase_messaging.dart'
;
import
'package:flutter_localizations/flutter_localizations.dart'
;
import
'package:get/get.dart'
;
...
...
@@ -13,6 +14,8 @@ import 'env_loader.dart';
import
'networking/dio_http_service.dart'
;
import
'firebase/push_notification.dart'
;
import
'firebase/push_setup.dart'
;
import
'configs/url_params.dart'
;
import
'web/web_helper.dart'
;
final
RouteObserver
<
PageRoute
>
routeObserver
=
RouteObserver
<
PageRoute
>();
void
main
(
)
async
{
...
...
@@ -21,8 +24,16 @@ void main() async {
await
DataPreference
.
instance
.
init
();
DioHttpService
();
Get
.
put
(
HeaderThemeController
(),
permanent:
true
);
await
initFirebaseAndFcm
();
await
UserPointManager
().
fetchUserPoint
();
// Web không dùng FCM: bỏ init Firebase Messaging để tránh SW/permission issues
if
(!
kIsWeb
)
{
await
initFirebaseAndFcm
();
}
// Chỉ fetch điểm khi đã đăng nhập, tránh 403 khi web chưa có token
if
(
DataPreference
.
instance
.
logged
)
{
await
UserPointManager
().
fetchUserPoint
();
}
// Đọc URL parameters cho web
_handleWebUrlParams
();
runApp
(
const
MyApp
());
WidgetsBinding
.
instance
.
addPostFrameCallback
((
_
)
{
AppLoading
().
attach
();
...
...
@@ -33,6 +44,26 @@ void main() async {
handleLocalNotificationLaunchIfAny
();
}
void
_handleWebUrlParams
(
)
{
print
(
'🔍 _handleWebUrlParams called, kIsWeb:
$kIsWeb
'
);
if
(!
kIsWeb
)
return
;
final
uri
=
Uri
.
base
;
print
(
'🔍 Current URI:
${uri.toString()}
'
);
final
token
=
uri
.
queryParameters
[
'token'
];
final
userId
=
uri
.
queryParameters
[
'userId'
];
print
(
'🔍 Web URL Params: {token:
$token
, user_id:
$userId
}'
);
if
(
token
!=
null
&&
token
.
isNotEmpty
)
{
UrlParams
.
setToken
(
token
);
UrlParams
.
setUserId
(
userId
);
print
(
'✅ Token set from URL:
$token
'
);
print
(
'🔍 UrlParams after set:
${UrlParams.allParams}
'
);
// Clean URL to remove query params
webReplaceUrl
(
'/'
);
}
else
{
print
(
'❌ No token found in URL parameters'
);
}
}
class
MyApp
extends
StatelessWidget
{
const
MyApp
({
super
.
key
});
...
...
lib/networking/error_mapper.dart
View file @
b7cceccb
...
...
@@ -28,6 +28,7 @@ class ErrorMapper {
}
static
String
?
_extractErrorMessage
(
dynamic
data
)
{
if
(
data
==
null
)
return
null
;
if
(
data
is
Map
<
String
,
dynamic
>)
{
return
data
[
'message'
]?.
toString
()
??
data
[
'error_message'
]?.
toString
()
??
data
[
'errorMessage'
]?.
toString
();
}
...
...
lib/networking/restful_api_client_all_request.dart
View file @
b7cceccb
import
'dart:io'
;
import
'package:flutter/foundation.dart'
;
import
'package:mypoint_flutter_app/configs/api_paths.dart'
;
import
'package:mypoint_flutter_app/base/base_response_model.dart'
;
import
'package:mypoint_flutter_app/configs/constants.dart'
;
...
...
@@ -80,8 +81,9 @@ import '../screen/voucher/models/search_product_response_model.dart';
extension
RestfulAPIClientAllRequest
on
RestfulAPIClient
{
Future
<
BaseResponseModel
<
UpdateResponseModel
>>
checkUpdateApp
()
async
{
String
version
=
Platform
.
version
;
final
body
=
{
"operating_system"
:
"iOS"
,
"software_model"
:
"MyPoint"
,
"version"
:
version
,
"build_number"
:
"1"
};
final
operatingSystem
=
kIsWeb
?
"web"
:
Platform
.
operatingSystem
;
final
version
=
kIsWeb
?
"1.0.0"
:
Platform
.
version
;
final
body
=
{
"operating_system"
:
operatingSystem
,
"software_model"
:
"MyPoint"
,
"version"
:
version
,
"build_number"
:
"1"
};
return
requestNormal
(
APIPaths
.
checkUpdate
,
Method
.
POST
,
body
,
(
data
)
=>
UpdateResponseModel
.
fromJson
(
data
as
Json
));
}
...
...
@@ -95,7 +97,7 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
}
Future
<
BaseResponseModel
<
CheckPhoneResponseModel
>>
checkPhoneNumber
(
String
phone
)
async
{
var
deviceKey
=
await
DeviceInfo
.
getDeviceId
();
var
deviceKey
=
"1c8a26fc-748f-4d1a-a064-af8d7874df6a"
;
//
await DeviceInfo.getDeviceId();
var
key
=
"
$phone
+_=
$deviceKey
/*8854"
;
final
body
=
{
"device_key"
:
deviceKey
,
"phone_number"
:
phone
,
"key"
:
key
.
toSha256
()};
print
(
'body:
$body
'
);
...
...
lib/preference/data_preference.dart
View file @
b7cceccb
...
...
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import
'../model/auth/login_token_response_model.dart'
;
import
'../model/auth/profile_response_model.dart'
;
import
'../screen/popup_manager/popup_manager_viewmodel.dart'
;
import
'../web/web_helper_stub.dart'
;
class
DataPreference
{
static
final
DataPreference
_instance
=
DataPreference
.
_internal
();
...
...
@@ -76,6 +77,7 @@ class DataPreference {
Future
<
void
>
clearLoginToken
()
async
{
_loginToken
=
null
;
webClearStorage
();
final
prefs
=
await
SharedPreferences
.
getInstance
();
await
prefs
.
remove
(
'login_token'
);
await
PopupManagerViewModel
.
instance
.
reset
();
...
...
lib/screen/affiliate/sub_widget/build_affiliate_brand.dart
View file @
b7cceccb
...
...
@@ -3,6 +3,7 @@ import 'package:get/get.dart';
import
'package:mypoint_flutter_app/extensions/string_extension.dart'
;
import
'../../../resources/base_color.dart'
;
import
'../../../shared/router_gage.dart'
;
import
'../../../widgets/image_loader.dart'
;
import
'../model/affiliate_brand_model.dart'
;
class
AffiliateBrand
extends
StatelessWidget
{
...
...
@@ -79,16 +80,22 @@ Widget buildAffiliateBrandItem(AffiliateBrandModel brand) {
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
center
,
children:
[
SizedBox
(
// SizedBox(
// width: imageWidth,
// height: imageWidth, // ✅ 1:1 tỉ lệ
// child: ClipOval(
// child: Image.network(
// brand.logo ?? '',
// fit: BoxFit.contain,
// errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
// ),
// ),
// ),
loadNetworkImage
(
url:
brand
.
logo
??
''
,
width:
imageWidth
,
height:
imageWidth
,
// ✅ 1:1 tỉ lệ
child:
ClipOval
(
child:
Image
.
network
(
brand
.
logo
??
''
,
fit:
BoxFit
.
contain
,
errorBuilder:
(
_
,
__
,
___
)
=>
const
Icon
(
Icons
.
broken_image
),
),
),
height:
imageWidth
,
fit:
BoxFit
.
contain
,
),
const
SizedBox
(
height:
4
),
Text
(
...
...
lib/screen/home/custom_widget/header_home_widget.dart
View file @
b7cceccb
...
...
@@ -20,17 +20,26 @@ class HomeGreetingHeader extends StatelessWidget {
final
heightSize
=
heightContent
??
(
width
*
86
/
375
+
112
);
final
name
=
DataPreference
.
instance
.
displayName
;
final
level
=
DataPreference
.
instance
.
rankName
??
"Hạng Đồng"
;
final
double
statusBarHeight
=
MediaQuery
.
of
(
context
).
padding
.
top
;
final
double
heightWhiteBox
=
112
;
double
heightWhiteBox
=
112
;
return
Stack
(
children:
[
SizedBox
(
Container
(
color:
Colors
.
black
,
height:
heightSize
,
width:
double
.
infinity
,
child:
loadNetworkImage
(
url:
dataHeader
.
background
,
fit:
BoxFit
.
cover
,
placeholderAsset:
"assets/images/bg_header_navi.png"
,
child:
Align
(
alignment:
Alignment
.
topCenter
,
child:
SizedBox
(
height:
44
+
kToolbarHeight
+
20
,
width:
double
.
infinity
,
child:
loadNetworkImage
(
url:
dataHeader
.
background
,
fit:
BoxFit
.
cover
,
placeholderAsset:
"assets/images/bg_header_navi.png"
,
),
),
),
),
Positioned
(
...
...
lib/screen/login/login_screen.dart
View file @
b7cceccb
...
...
@@ -21,6 +21,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
final
TextEditingController
_phoneController
=
TextEditingController
();
final
FocusNode
_focusNode
=
FocusNode
();
final
loginVM
=
Get
.
put
(
LoginViewModel
());
bool
_autoSubmitted
=
false
;
// prevent duplicate submits when reaching 6 chars
late
final
String
phoneNumber
;
late
String
fullName
=
""
;
...
...
@@ -178,7 +179,19 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
focusNode:
_focusNode
,
keyboardType:
TextInputType
.
number
,
obscureText:
!
vm
.
isPasswordVisible
.
value
,
onChanged:
vm
.
onPasswordChanged
,
onChanged:
(
value
)
{
vm
.
onPasswordChanged
(
value
);
// Auto submit when password reaches 6 characters
if
(!
_autoSubmitted
&&
value
.
length
==
6
)
{
hideKeyboard
();
_autoSubmitted
=
true
;
vm
.
onLoginPressed
(
phoneNumber
);
}
// Reset guard when user deletes characters
if
(
value
.
length
<
6
&&
_autoSubmitted
)
{
_autoSubmitted
=
false
;
}
},
decoration:
InputDecoration
(
hintText:
"Nhập mật khẩu"
,
prefixIcon:
const
Icon
(
Icons
.
password
,
color:
BaseColor
.
second500
),
...
...
lib/screen/login/login_viewmodel.dart
View file @
b7cceccb
...
...
@@ -52,29 +52,28 @@ class LoginViewModel extends RestfulApiViewModel {
isPasswordVisible
.
value
=
!
isPasswordVisible
.
value
;
}
void
onLoginPressed
(
String
phone
)
{
Future
<
void
>
onLoginPressed
(
String
phone
)
async
{
if
(
password
.
value
.
isEmpty
)
return
;
showLoading
();
client
.
login
(
phone
,
password
.
value
).
then
((
value
)
async
{
hideLoading
();
_handleLoginResponse
(
value
,
phone
);
});
final
response
=
await
client
.
login
(
phone
,
password
.
value
);
hideLoading
();
_handleLoginResponse
(
response
,
phone
);
}
void
_getUserProfile
()
{
Future
<
void
>
_getUserProfile
()
async
{
showLoading
();
client
.
getUserProfile
().
then
((
value
)
async
{
final
response
=
await
client
.
getUserProfile
();
final
userProfile
=
response
.
data
;
if
(
response
.
isSuccess
&&
userProfile
!=
null
)
{
await
DataPreference
.
instance
.
saveUserProfile
(
userProfile
);
hideLoading
();
final
userProfile
=
value
.
data
;
if
(
value
.
isSuccess
&&
userProfile
!=
null
)
{
await
DataPreference
.
instance
.
saveUserProfile
(
userProfile
);
Get
.
offAllNamed
(
mainScreen
);
}
else
{
DataPreference
.
instance
.
clearLoginToken
();
final
mgs
=
value
.
errorMessage
??
Constants
.
commonError
;
onShowAlertError
?.
call
(
mgs
);
}
});
Get
.
offAllNamed
(
mainScreen
);
}
else
{
hideLoading
();
await
DataPreference
.
instance
.
clearLoginToken
();
final
mgs
=
response
.
errorMessage
??
Constants
.
commonError
;
onShowAlertError
?.
call
(
mgs
);
}
}
void
onChangePhonePressed
()
{
...
...
@@ -86,17 +85,16 @@ class LoginViewModel extends RestfulApiViewModel {
}
}
void
onForgotPassPressed
(
String
phone
)
{
Future
<
void
>
onForgotPassPressed
(
String
phone
)
async
{
showLoading
();
client
.
otpCreateNew
(
phone
).
then
((
value
)
{
hideLoading
();
if
(!
value
.
isSuccess
)
return
;
Get
.
to
(
OtpScreen
(
repository:
ForgotPassOTPRepository
(
phone
,
value
.
data
?.
resendAfterSecond
??
Constants
.
otpTtl
),
),
);
});
final
response
=
await
client
.
otpCreateNew
(
phone
);
hideLoading
();
if
(!
response
.
isSuccess
)
return
;
Get
.
to
(
OtpScreen
(
repository:
ForgotPassOTPRepository
(
phone
,
response
.
data
?.
resendAfterSecond
??
Constants
.
otpTtl
),
),
);
}
Future
<
void
>
_handleLoginResponse
(
BaseResponseModel
<
LoginTokenResponseModel
>
response
,
String
phone
)
async
{
...
...
@@ -104,7 +102,7 @@ class LoginViewModel extends RestfulApiViewModel {
await
DataPreference
.
instance
.
saveLoginToken
(
response
.
data
!);
// Upload FCM token after login
await
PushTokenService
.
uploadIfLogged
();
_getUserProfile
();
await
_getUserProfile
();
return
;
}
final
errorMsg
=
response
.
errorMessage
??
Constants
.
commonError
;
...
...
@@ -133,12 +131,13 @@ class LoginViewModel extends RestfulApiViewModel {
}
final
bioToken
=
await
DataPreference
.
instance
.
getBioToken
(
phone
)
??
""
;
if
(
bioToken
.
isEmpty
)
{
onShowAlertError
?.
call
(
"Tài khoản này chưa kích hoạt đăng nhập bằng sinh trắc học!
\n
Vui lòng đăng nhập > cài đặt để kích hoạt tính năng"
);
onShowAlertError
?.
call
(
"Tài khoản này chưa kích hoạt đăng nhập bằng sinh trắc học!
\n
Vui lòng đăng nhập > cài đặt để kích hoạt tính năng"
);
return
;
}
client
.
loginWithBiometric
(
phone
).
then
((
value
)
async
{
hideLoading
(
);
_handleLoginResponse
(
value
,
phone
);
}
);
showLoading
();
final
response
=
await
client
.
loginWithBiometric
(
phone
);
hideLoading
(
);
_handleLoginResponse
(
response
,
phone
);
}
}
lib/screen/mobile_card/product_mobile_card_screen.dart
View file @
b7cceccb
...
...
@@ -3,6 +3,7 @@ import 'package:get/get.dart';
import
'package:mypoint_flutter_app/extensions/num_extension.dart'
;
import
'package:mypoint_flutter_app/screen/mobile_card/product_mobile_card_viewmodel.dart'
;
import
'package:mypoint_flutter_app/screen/mobile_card/usable_mobile_card_popup.dart'
;
import
'package:mypoint_flutter_app/widgets/custom_empty_widget.dart'
;
import
'package:mypoint_flutter_app/widgets/image_loader.dart'
;
import
'../../base/base_screen.dart'
;
import
'../../base/basic_state.dart'
;
...
...
@@ -43,19 +44,18 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w
return
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
const
Padding
(
padding:
EdgeInsets
.
symmetric
(
horizontal:
16
,
vertical:
12
),
child:
Text
(
"Chọn nhà mạng"
,
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
)),
),
_buildSectionNetwork
(),
if
(
_viewModel
.
mobileCardSections
.
isNotEmpty
)
_buildSectionNetwork
(),
const
SizedBox
(
height:
24
),
const
Padding
(
padding:
EdgeInsets
.
symmetric
(
horizontal:
16
),
child:
Text
(
"Mệnh giá thẻ"
,
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
)),
),
if
(
_viewModel
.
products
.
isNotEmpty
)
const
Padding
(
padding:
EdgeInsets
.
symmetric
(
horizontal:
16
),
child:
Text
(
"Mệnh giá thẻ"
,
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
)),
),
const
SizedBox
(
height:
12
),
_buildProductItem
(),
SafeArea
(
if
(
_viewModel
.
products
.
isNotEmpty
)
SafeArea
(
child:
Padding
(
padding:
const
EdgeInsets
.
all
(
16
),
child:
ElevatedButton
(
...
...
@@ -80,108 +80,123 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w
Widget
_buildSectionNetwork
()
{
final
widthCardItem
=
MediaQuery
.
of
(
context
).
size
.
width
/
2.5
;
return
SizedBox
(
height:
widthCardItem
*
9
/
16
,
child:
ListView
.
separated
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
),
scrollDirection:
Axis
.
horizontal
,
itemCount:
_viewModel
.
mobileCardSections
.
length
,
separatorBuilder:
(
_
,
_
)
=>
const
SizedBox
(
width:
12
),
itemBuilder:
(
_
,
index
)
{
final
mobileCard
=
_viewModel
.
mobileCardSections
.
value
[
index
];
final
isSelected
=
mobileCard
.
brandCode
==
_viewModel
.
selectedBrandCode
.
value
;
return
GestureDetector
(
onTap:
()
{
setState
(()
{
if
(
_viewModel
.
selectedBrandCode
.
value
==
mobileCard
.
brandCode
)
return
;
_viewModel
.
selectedBrandCode
.
value
=
mobileCard
.
brandCode
??
""
;
_viewModel
.
selectedProduct
=
null
;
});
},
child:
Container
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
20
),
decoration:
BoxDecoration
(
borderRadius:
BorderRadius
.
circular
(
12
),
border:
Border
.
all
(
color:
isSelected
?
Colors
.
orange
:
Colors
.
grey
.
shade300
,
width:
2
),
color:
Colors
.
white
,
),
alignment:
Alignment
.
center
,
child:
loadNetworkImage
(
url:
mobileCard
.
brandLogo
,
width:
widthCardItem
,
placeholderAsset:
"assets/images/bg_default_169.png"
,
),
),
);
},
),
);
}
Widget
_buildProductItem
()
{
const
double
kItemHeight
=
80
;
final
widthItem
=
(
MediaQuery
.
of
(
context
).
size
.
width
-
12
*
3
)/
2
;
return
Expanded
(
child:
GridView
.
count
(
crossAxisCount:
2
,
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
),
crossAxisSpacing:
12
,
mainAxisSpacing:
12
,
childAspectRatio:
widthItem
/
kItemHeight
,
children:
_viewModel
.
products
.
map
((
product
)
{
final
isSelected
=
_viewModel
.
selectedProduct
?.
id
==
product
.
id
;
final
amount
=
int
.
tryParse
(
(
product
.
prices
?.
isNotEmpty
==
true
)
?
product
.
prices
?.
first
.
originalPrice
??
"0"
:
"0"
,
)
??
0
;
final
price
=
int
.
tryParse
((
product
.
prices
?.
isNotEmpty
==
true
)
?
product
.
prices
?.
first
.
payPoint
??
"0"
:
"0"
)
??
0
;
return
Column
(
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
const
Padding
(
padding:
EdgeInsets
.
symmetric
(
horizontal:
16
,
vertical:
12
),
child:
Text
(
"Chọn nhà mạng"
,
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
)),
),
SizedBox
(
height:
widthCardItem
*
9
/
16
,
child:
ListView
.
separated
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
),
scrollDirection:
Axis
.
horizontal
,
itemCount:
_viewModel
.
mobileCardSections
.
length
,
separatorBuilder:
(
_
,
_
)
=>
const
SizedBox
(
width:
12
),
itemBuilder:
(
_
,
index
)
{
final
mobileCard
=
_viewModel
.
mobileCardSections
.
value
[
index
];
final
isSelected
=
mobileCard
.
brandCode
==
_viewModel
.
selectedBrandCode
.
value
;
return
GestureDetector
(
onTap:
()
{
setState
(()
{
_viewModel
.
selectedProduct
=
product
;
if
(
_viewModel
.
selectedBrandCode
.
value
==
mobileCard
.
brandCode
)
return
;
_viewModel
.
selectedBrandCode
.
value
=
mobileCard
.
brandCode
??
""
;
_viewModel
.
selectedProduct
=
null
;
});
},
child:
Container
(
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
20
),
decoration:
BoxDecoration
(
border:
Border
.
all
(
color:
isSelected
?
Colors
.
orange
:
Colors
.
grey
.
shade300
),
borderRadius:
BorderRadius
.
circular
(
12
),
color:
isSelected
?
Colors
.
orange
.
withOpacity
(
0.1
)
:
Colors
.
white
,
border:
Border
.
all
(
color:
isSelected
?
Colors
.
orange
:
Colors
.
grey
.
shade300
,
width:
2
),
color:
Colors
.
white
,
),
padding:
const
EdgeInsets
.
all
(
10
),
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
center
,
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
Text
(
amount
.
money
(
CurrencyUnit
.
vnd
),
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
,
color:
isSelected
?
Colors
.
orange
:
Colors
.
black87
,
),
),
const
SizedBox
(
height:
4
),
Row
(
children:
[
Text
(
"Giá:
${price.money(CurrencyUnit.none)}
"
,
style:
TextStyle
(
fontSize:
14
,
color:
isSelected
?
Colors
.
orange
:
Colors
.
black54
),
),
Image
.
asset
(
"assets/images/ic_point.png"
,
width:
16
,
height:
16
),
],
),
],
alignment:
Alignment
.
center
,
child:
loadNetworkImage
(
url:
mobileCard
.
brandLogo
,
width:
widthCardItem
,
placeholderAsset:
"assets/images/bg_default_169.png"
,
),
),
);
}).
toList
(),
),
},
),
),
],
);
}
Widget
_buildProductItem
()
{
const
double
kItemHeight
=
80
;
final
widthItem
=
(
MediaQuery
.
of
(
context
).
size
.
width
-
12
*
3
)
/
2
;
return
(
_viewModel
.
products
.
isEmpty
)
?
const
Expanded
(
child:
Center
(
child:
EmptyWidget
(),
),
)
:
Expanded
(
child:
GridView
.
count
(
crossAxisCount:
2
,
padding:
const
EdgeInsets
.
symmetric
(
horizontal:
16
),
crossAxisSpacing:
12
,
mainAxisSpacing:
12
,
childAspectRatio:
widthItem
/
kItemHeight
,
children:
_viewModel
.
products
.
map
((
product
)
{
final
isSelected
=
_viewModel
.
selectedProduct
?.
id
==
product
.
id
;
final
amount
=
int
.
tryParse
(
(
product
.
prices
?.
isNotEmpty
==
true
)
?
product
.
prices
?.
first
.
originalPrice
??
"0"
:
"0"
,
)
??
0
;
final
price
=
int
.
tryParse
((
product
.
prices
?.
isNotEmpty
==
true
)
?
product
.
prices
?.
first
.
payPoint
??
"0"
:
"0"
)
??
0
;
return
GestureDetector
(
onTap:
()
{
setState
(()
{
_viewModel
.
selectedProduct
=
product
;
});
},
child:
Container
(
decoration:
BoxDecoration
(
border:
Border
.
all
(
color:
isSelected
?
Colors
.
orange
:
Colors
.
grey
.
shade300
),
borderRadius:
BorderRadius
.
circular
(
12
),
color:
isSelected
?
Colors
.
orange
.
withOpacity
(
0.1
)
:
Colors
.
white
,
),
padding:
const
EdgeInsets
.
all
(
10
),
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
center
,
crossAxisAlignment:
CrossAxisAlignment
.
start
,
children:
[
Text
(
amount
.
money
(
CurrencyUnit
.
vnd
),
style:
TextStyle
(
fontWeight:
FontWeight
.
bold
,
fontSize:
16
,
color:
isSelected
?
Colors
.
orange
:
Colors
.
black87
,
),
),
const
SizedBox
(
height:
4
),
Row
(
children:
[
Text
(
"Giá:
${price.money(CurrencyUnit.none)}
"
,
style:
TextStyle
(
fontSize:
14
,
color:
isSelected
?
Colors
.
orange
:
Colors
.
black54
),
),
Image
.
asset
(
"assets/images/ic_point.png"
,
width:
16
,
height:
16
),
],
),
],
),
),
);
}).
toList
(),
),
);
}
void
_redeemProductMobileCard
()
{
if
(
_viewModel
.
selectedProduct
==
null
)
return
;
if
(!
_viewModel
.
isValidBalance
)
{
...
...
lib/screen/onboarding/onboarding_viewmodel.dart
View file @
b7cceccb
...
...
@@ -37,10 +37,4 @@ class OnboardingViewModel extends RestfulApiViewModel {
checkPhoneRes
.
value
=
value
;
});
}
@override
void
onInit
()
{
super
.
onInit
();
fetchOnboardingContent
();
// Gọi API khi khởi tạo ViewModel
}
}
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