Commit b7cceccb authored by DatHV's avatar DatHV
Browse files

update config web + fix bug

parent 42a99a61
# 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'
#!/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"
#!/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
...@@ -5,7 +5,7 @@ class Constants { ...@@ -5,7 +5,7 @@ class Constants {
static var directionInApp = "IN-APP"; static var directionInApp = "IN-APP";
static var phoneNumberCount = 10; static var phoneNumberCount = 10;
static var timeoutSeconds = 30; static var timeoutSeconds = 30;
static const loadingTimeoutSeconds = 10; static const loadingTimeoutSeconds = 30;
} }
class ErrorCodes { class ErrorCodes {
......
import 'dart:ffi' as MediaQuery;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -41,28 +40,40 @@ class DeviceDetails { ...@@ -41,28 +40,40 @@ class DeviceDetails {
} }
class DeviceInfo { class DeviceInfo {
static const String _deviceIdPreferenceKey = 'device_id';
static String? _cachedDeviceId;
static DeviceDetails? _cachedDetails;
static Future<String> getDeviceId() async { static Future<String> getDeviceId() async {
SharedPreferences prefs = await SharedPreferences.getInstance(); _cachedDeviceId = "1c8a26fc-748f-4d1a-a064-af8d7874df6a";
String? deviceId = prefs.getString('device_id'); // if (_cachedDeviceId != null) return _cachedDeviceId!;
if (deviceId == null) { // final prefs = await SharedPreferences.getInstance();
deviceId = const Uuid().v4(); // Tạo UUID mới // String? deviceId = prefs.getString(_deviceIdPreferenceKey);
await prefs.setString('device_id', deviceId); // if (deviceId == null || deviceId.isEmpty) {
} // deviceId = const Uuid().v4();
return deviceId; // await prefs.setString(_deviceIdPreferenceKey, deviceId);
// }
// _cachedDeviceId = deviceId;
return _cachedDeviceId!;
} }
static Future<DeviceDetails> getDetails() async { static Future<DeviceDetails> getDetails() async {
if (_cachedDetails != null) return _cachedDetails!;
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final pkg = await PackageInfo.fromPlatform(); final pkg = await PackageInfo.fromPlatform();
// OS + version
String os, osVersion = ''; String os = 'Unknown';
String osVersion = 'Unknown';
try {
if (kIsWeb) { if (kIsWeb) {
os = 'Web'; os = 'Web';
final web = await deviceInfo.webBrowserInfo; final web = await deviceInfo.webBrowserInfo;
osVersion = [ final browser = web.browserName.name;
web.browserName.name, final appVersion = (web.appVersion ?? '').trim();
if ((web.appVersion ?? '').isNotEmpty) '(${web.appVersion})', osVersion = appVersion.isEmpty ? browser : '$browser ($appVersion)';
].where((e) => e.toString().isNotEmpty).join(' ');
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
os = 'iOS'; os = 'iOS';
final ios = await deviceInfo.iosInfo; final ios = await deviceInfo.iosInfo;
...@@ -71,37 +82,51 @@ class DeviceInfo { ...@@ -71,37 +82,51 @@ class DeviceInfo {
os = 'Android'; os = 'Android';
final and = await deviceInfo.androidInfo; final and = await deviceInfo.androidInfo;
final rel = and.version.release; final rel = and.version.release;
final sdk = and.version.sdkInt.toString(); final sdk = and.version.sdkInt?.toString() ?? '';
osVersion = sdk.isEmpty ? rel : '$rel (SDK $sdk)'; osVersion = sdk.isEmpty ? rel : '$rel (SDK $sdk)';
} else { } else {
os = Platform.operatingSystem; // macOS/linux/windows os = Platform.operatingSystem;
osVersion = Platform.operatingSystemVersion; osVersion = Platform.operatingSystemVersion;
} }
// Model + userDeviceName + iPad fallback } catch (_) {
// keep defaults
}
String hardwareModel = 'Unknown'; String hardwareModel = 'Unknown';
String userDeviceName = ''; String userDeviceName = 'Unknown';
bool fallbackIsTablet = false; bool fallbackIsTablet = false;
try {
if (kIsWeb) { if (kIsWeb) {
hardwareModel = 'Browser'; hardwareModel = 'Browser';
userDeviceName = 'Web Client';
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo; final ios = await deviceInfo.iosInfo;
hardwareModel = ios.utsname.machine; hardwareModel = (ios.utsname.machine).trim();
userDeviceName = ios.name; userDeviceName = (ios.name).trim();
fallbackIsTablet = final modelLower = (ios.model).toLowerCase();
(ios.model.toLowerCase().contains('ipad')) || (ios.utsname.machine.toLowerCase().startsWith('ipad')); final machineLower = (ios.utsname.machine).toLowerCase();
fallbackIsTablet = modelLower.contains('ipad') || machineLower.startsWith('ipad');
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
final and = await deviceInfo.androidInfo; final and = await deviceInfo.androidInfo;
final brand = and.brand.trim(); final brand = (and.brand).trim();
final model = and.model.trim(); final model = (and.model).trim();
hardwareModel = [brand, model].where((e) => e.isNotEmpty).join(' '); hardwareModel = [brand, model].where((e) => e.isNotEmpty).join(' ');
userDeviceName = and.device; userDeviceName = (and.device).trim().isEmpty ? 'Android Device' : (and.device).trim();
fallbackIsTablet = false; } 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, hardwareType: hardwareType,
hardwareModel: hardwareModel, hardwareModel: hardwareModel,
operatingSystem: os, operatingSystem: os,
...@@ -110,6 +135,8 @@ class DeviceInfo { ...@@ -110,6 +135,8 @@ class DeviceInfo {
appVersion: pkg.version, appVersion: pkg.version,
appBuildNumber: pkg.buildNumber, appBuildNumber: pkg.buildNumber,
); );
return _cachedDetails!;
} }
/// Không cần BuildContext: dùng kích thước của view đầu tiên từ PlatformDispatcher. /// Không cần BuildContext: dùng kích thước của view đầu tiên từ PlatformDispatcher.
......
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';
}
...@@ -41,7 +41,10 @@ class AppConfig { ...@@ -41,7 +41,10 @@ class AppConfig {
Future<void> loadEnv() async { Future<void> loadEnv() async {
Map<String, dynamic>? cfg; 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 _tryLoadAsset('assets/config/env.json');
cfg ??= await _tryLoadFromMethodChannel(); cfg ??= await _tryLoadFromMethodChannel();
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
......
...@@ -14,7 +14,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { ...@@ -14,7 +14,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
print('_firebaseMessagingBackgroundHandler ${message.toMap()}'); print('_firebaseMessagingBackgroundHandler ${message.toMap()}');
// Android: data-only message sẽ không tự hiển thị. Tự show local notification // 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 data = message.data;
final title = message.notification?.title ?? data['title']?.toString(); final title = message.notification?.title ?? data['title']?.toString();
final body = message.notification?.body ?? (data['body']?.toString() ?? data['content']?.toString()); final body = message.notification?.body ?? (data['body']?.toString() ?? data['content']?.toString());
...@@ -102,17 +102,41 @@ Future<void> handleLocalNotificationLaunchIfAny() async { ...@@ -102,17 +102,41 @@ Future<void> handleLocalNotificationLaunchIfAny() async {
} }
Future<void> initFirebaseAndFcm() async { Future<void> initFirebaseAndFcm() async {
FirebaseMessaging? messaging;
try {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
final messaging = FirebaseMessaging.instance; messaging = FirebaseMessaging.instance;
// Quyền iOS / Android 13+ // Quyền iOS / Android 13+ / Web: chỉ tắt trên Web, mobile giữ nguyên
if (Platform.isIOS) { 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); await messaging.requestPermission(alert: true, badge: true, sound: true);
} else { } else {
await messaging.requestPermission(); // Android 13+ POST_NOTIFICATIONS await messaging.requestPermission(); // Android 13+ POST_NOTIFICATIONS
} }
await _initLocalNotifications(); 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;
}
// Foreground: Android không tự hiển thị -> ta show local notification // Foreground: Android không tự hiển thị -> ta show local notification
FirebaseMessaging.onMessage.listen((message) { FirebaseMessaging.onMessage.listen((message) {
...@@ -122,7 +146,8 @@ Future<void> initFirebaseAndFcm() async { ...@@ -122,7 +146,8 @@ Future<void> initFirebaseAndFcm() async {
print('Data: ${message.data}'); print('Data: ${message.data}');
print('Notification: ${message.notification?.title} - ${message.notification?.body}'); 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 n = message.notification;
final title = n?.title ?? (message.data['title']?.toString()); final title = n?.title ?? (message.data['title']?.toString());
final body = n?.body ?? (message.data['body']?.toString()); final body = n?.body ?? (message.data['body']?.toString());
...@@ -142,16 +167,62 @@ Future<void> initFirebaseAndFcm() async { ...@@ -142,16 +167,62 @@ Future<void> initFirebaseAndFcm() async {
payload: message.data.isNotEmpty ? jsonEncode(message.data) : null, payload: message.data.isNotEmpty ? jsonEncode(message.data) : null,
); );
} }
// } }
}); });
// User click notification mở app (khi app đang chạy ở background) // User click notification mở app (khi app đang chạy ở background)
FirebaseMessaging.onMessageOpenedApp.listen((message) { FirebaseMessaging.onMessageOpenedApp.listen((message) {
NotificationRouter.handleRemoteMessage(message); NotificationRouter.handleRemoteMessage(message);
}); });
// Initial message sẽ được xử lý sau khi runApp trong main.dart // Initial message sẽ được xử lý sau khi runApp trong main.dart
// Lấy token để test gửi // Lấy token để test gửi (bật cho mobile, tắt cho web)
if (messaging != null && !kIsWeb) {
try {
final token = await messaging.getToken(); final token = await messaging.getToken();
// if (kDebugMode) { if (kDebugMode) {
print('FCM token: $token'); 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;
}
} }
import 'package:firebase_messaging/firebase_messaging.dart'; 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_client_all_request.dart';
import 'package:mypoint_flutter_app/networking/restful_api_viewmodel.dart'; import 'package:mypoint_flutter_app/networking/restful_api_viewmodel.dart';
import 'package:mypoint_flutter_app/preference/data_preference.dart'; import 'package:mypoint_flutter_app/preference/data_preference.dart';
...@@ -9,6 +10,7 @@ class PushTokenService extends RestfulApiViewModel { ...@@ -9,6 +10,7 @@ class PushTokenService extends RestfulApiViewModel {
factory PushTokenService() => _instance; factory PushTokenService() => _instance;
static Future<void> uploadIfLogged({String? fcmToken}) async { static Future<void> uploadIfLogged({String? fcmToken}) async {
if (kIsWeb) return;
final isLogged = DataPreference.instance.logged; final isLogged = DataPreference.instance.logged;
if (!isLogged) return; if (!isLogged) return;
final token = fcmToken ?? await FirebaseMessaging.instance.getToken(); final token = fcmToken ?? await FirebaseMessaging.instance.getToken();
......
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
...@@ -13,6 +14,8 @@ import 'env_loader.dart'; ...@@ -13,6 +14,8 @@ import 'env_loader.dart';
import 'networking/dio_http_service.dart'; import 'networking/dio_http_service.dart';
import 'firebase/push_notification.dart'; import 'firebase/push_notification.dart';
import 'firebase/push_setup.dart'; import 'firebase/push_setup.dart';
import 'configs/url_params.dart';
import 'web/web_helper.dart';
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>(); final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
void main() async { void main() async {
...@@ -21,8 +24,16 @@ void main() async { ...@@ -21,8 +24,16 @@ void main() async {
await DataPreference.instance.init(); await DataPreference.instance.init();
DioHttpService(); DioHttpService();
Get.put(HeaderThemeController(), permanent: true); Get.put(HeaderThemeController(), permanent: true);
// Web không dùng FCM: bỏ init Firebase Messaging để tránh SW/permission issues
if (!kIsWeb) {
await initFirebaseAndFcm(); 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(); await UserPointManager().fetchUserPoint();
}
// Đọc URL parameters cho web
_handleWebUrlParams();
runApp(const MyApp()); runApp(const MyApp());
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
AppLoading().attach(); AppLoading().attach();
...@@ -33,6 +44,26 @@ void main() async { ...@@ -33,6 +44,26 @@ void main() async {
handleLocalNotificationLaunchIfAny(); 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 { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
......
...@@ -28,6 +28,7 @@ class ErrorMapper { ...@@ -28,6 +28,7 @@ class ErrorMapper {
} }
static String? _extractErrorMessage(dynamic data) { static String? _extractErrorMessage(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
return data['message']?.toString() ?? data['error_message']?.toString() ?? data['errorMessage']?.toString(); return data['message']?.toString() ?? data['error_message']?.toString() ?? data['errorMessage']?.toString();
} }
......
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:mypoint_flutter_app/configs/api_paths.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/base/base_response_model.dart';
import 'package:mypoint_flutter_app/configs/constants.dart'; import 'package:mypoint_flutter_app/configs/constants.dart';
...@@ -80,8 +81,9 @@ import '../screen/voucher/models/search_product_response_model.dart'; ...@@ -80,8 +81,9 @@ import '../screen/voucher/models/search_product_response_model.dart';
extension RestfulAPIClientAllRequest on RestfulAPIClient { extension RestfulAPIClientAllRequest on RestfulAPIClient {
Future<BaseResponseModel<UpdateResponseModel>> checkUpdateApp() async { Future<BaseResponseModel<UpdateResponseModel>> checkUpdateApp() async {
String version = Platform.version; final operatingSystem = kIsWeb ? "web" : Platform.operatingSystem;
final body = {"operating_system": "iOS", "software_model": "MyPoint", "version": version, "build_number": "1"}; 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)); return requestNormal(APIPaths.checkUpdate, Method.POST, body, (data) => UpdateResponseModel.fromJson(data as Json));
} }
...@@ -95,7 +97,7 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient { ...@@ -95,7 +97,7 @@ extension RestfulAPIClientAllRequest on RestfulAPIClient {
} }
Future<BaseResponseModel<CheckPhoneResponseModel>> checkPhoneNumber(String phone) async { 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"; var key = "$phone+_=$deviceKey/*8854";
final body = {"device_key": deviceKey, "phone_number": phone, "key": key.toSha256()}; final body = {"device_key": deviceKey, "phone_number": phone, "key": key.toSha256()};
print('body: $body'); print('body: $body');
......
...@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; ...@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../model/auth/login_token_response_model.dart'; import '../model/auth/login_token_response_model.dart';
import '../model/auth/profile_response_model.dart'; import '../model/auth/profile_response_model.dart';
import '../screen/popup_manager/popup_manager_viewmodel.dart'; import '../screen/popup_manager/popup_manager_viewmodel.dart';
import '../web/web_helper_stub.dart';
class DataPreference { class DataPreference {
static final DataPreference _instance = DataPreference._internal(); static final DataPreference _instance = DataPreference._internal();
...@@ -76,6 +77,7 @@ class DataPreference { ...@@ -76,6 +77,7 @@ class DataPreference {
Future<void> clearLoginToken() async { Future<void> clearLoginToken() async {
_loginToken = null; _loginToken = null;
webClearStorage();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove('login_token'); await prefs.remove('login_token');
await PopupManagerViewModel.instance.reset(); await PopupManagerViewModel.instance.reset();
......
...@@ -3,6 +3,7 @@ import 'package:get/get.dart'; ...@@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart'; import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import '../../../resources/base_color.dart'; import '../../../resources/base_color.dart';
import '../../../shared/router_gage.dart'; import '../../../shared/router_gage.dart';
import '../../../widgets/image_loader.dart';
import '../model/affiliate_brand_model.dart'; import '../model/affiliate_brand_model.dart';
class AffiliateBrand extends StatelessWidget { class AffiliateBrand extends StatelessWidget {
...@@ -79,16 +80,22 @@ Widget buildAffiliateBrandItem(AffiliateBrandModel brand) { ...@@ -79,16 +80,22 @@ Widget buildAffiliateBrandItem(AffiliateBrandModel brand) {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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, width: imageWidth,
height: imageWidth, // ✅ 1:1 tỉ lệ height: imageWidth,
child: ClipOval(
child: Image.network(
brand.logo ?? '',
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
......
...@@ -20,19 +20,28 @@ class HomeGreetingHeader extends StatelessWidget { ...@@ -20,19 +20,28 @@ class HomeGreetingHeader extends StatelessWidget {
final heightSize = heightContent ?? (width * 86 / 375 + 112); final heightSize = heightContent ?? (width * 86 / 375 + 112);
final name = DataPreference.instance.displayName; final name = DataPreference.instance.displayName;
final level = DataPreference.instance.rankName ?? "Hạng Đồng"; 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( return Stack(
children: [ children: [
SizedBox( Container(
color: Colors.black,
height: heightSize, height: heightSize,
width: double.infinity, width: double.infinity,
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 44 + kToolbarHeight + 20,
width: double.infinity,
child: loadNetworkImage( child: loadNetworkImage(
url: dataHeader.background, url: dataHeader.background,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholderAsset: "assets/images/bg_header_navi.png", placeholderAsset: "assets/images/bg_header_navi.png",
), ),
), ),
),
),
Positioned( Positioned(
bottom: heightWhiteBox + 16, bottom: heightWhiteBox + 16,
left: 16, left: 16,
......
...@@ -21,6 +21,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState { ...@@ -21,6 +21,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
final TextEditingController _phoneController = TextEditingController(); final TextEditingController _phoneController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final loginVM = Get.put(LoginViewModel()); final loginVM = Get.put(LoginViewModel());
bool _autoSubmitted = false; // prevent duplicate submits when reaching 6 chars
late final String phoneNumber; late final String phoneNumber;
late String fullName = ""; late String fullName = "";
...@@ -178,7 +179,19 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState { ...@@ -178,7 +179,19 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
focusNode: _focusNode, focusNode: _focusNode,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
obscureText: !vm.isPasswordVisible.value, 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( decoration: InputDecoration(
hintText: "Nhập mật khẩu", hintText: "Nhập mật khẩu",
prefixIcon: const Icon(Icons.password, color: BaseColor.second500), prefixIcon: const Icon(Icons.password, color: BaseColor.second500),
......
...@@ -52,29 +52,28 @@ class LoginViewModel extends RestfulApiViewModel { ...@@ -52,29 +52,28 @@ class LoginViewModel extends RestfulApiViewModel {
isPasswordVisible.value = !isPasswordVisible.value; isPasswordVisible.value = !isPasswordVisible.value;
} }
void onLoginPressed(String phone) { Future<void> onLoginPressed(String phone) async {
if (password.value.isEmpty) return; if (password.value.isEmpty) return;
showLoading(); showLoading();
client.login(phone, password.value).then((value) async { final response = await client.login(phone, password.value);
hideLoading(); hideLoading();
_handleLoginResponse(value, phone); _handleLoginResponse(response, phone);
});
} }
void _getUserProfile() { Future<void> _getUserProfile() async {
showLoading(); showLoading();
client.getUserProfile().then((value) async { final response = await client.getUserProfile();
hideLoading(); final userProfile = response.data;
final userProfile = value.data; if (response.isSuccess && userProfile != null) {
if (value.isSuccess && userProfile != null) {
await DataPreference.instance.saveUserProfile(userProfile); await DataPreference.instance.saveUserProfile(userProfile);
hideLoading();
Get.offAllNamed(mainScreen); Get.offAllNamed(mainScreen);
} else { } else {
DataPreference.instance.clearLoginToken(); hideLoading();
final mgs = value.errorMessage ?? Constants.commonError; await DataPreference.instance.clearLoginToken();
final mgs = response.errorMessage ?? Constants.commonError;
onShowAlertError?.call(mgs); onShowAlertError?.call(mgs);
} }
});
} }
void onChangePhonePressed() { void onChangePhonePressed() {
...@@ -86,17 +85,16 @@ class LoginViewModel extends RestfulApiViewModel { ...@@ -86,17 +85,16 @@ class LoginViewModel extends RestfulApiViewModel {
} }
} }
void onForgotPassPressed(String phone) { Future<void> onForgotPassPressed(String phone) async {
showLoading(); showLoading();
client.otpCreateNew(phone).then((value) { final response = await client.otpCreateNew(phone);
hideLoading(); hideLoading();
if (!value.isSuccess) return; if (!response.isSuccess) return;
Get.to( Get.to(
OtpScreen( OtpScreen(
repository: ForgotPassOTPRepository(phone, value.data?.resendAfterSecond ?? Constants.otpTtl), repository: ForgotPassOTPRepository(phone, response.data?.resendAfterSecond ?? Constants.otpTtl),
), ),
); );
});
} }
Future<void> _handleLoginResponse(BaseResponseModel<LoginTokenResponseModel> response, String phone) async { Future<void> _handleLoginResponse(BaseResponseModel<LoginTokenResponseModel> response, String phone) async {
...@@ -104,7 +102,7 @@ class LoginViewModel extends RestfulApiViewModel { ...@@ -104,7 +102,7 @@ class LoginViewModel extends RestfulApiViewModel {
await DataPreference.instance.saveLoginToken(response.data!); await DataPreference.instance.saveLoginToken(response.data!);
// Upload FCM token after login // Upload FCM token after login
await PushTokenService.uploadIfLogged(); await PushTokenService.uploadIfLogged();
_getUserProfile(); await _getUserProfile();
return; return;
} }
final errorMsg = response.errorMessage ?? Constants.commonError; final errorMsg = response.errorMessage ?? Constants.commonError;
...@@ -133,12 +131,13 @@ class LoginViewModel extends RestfulApiViewModel { ...@@ -133,12 +131,13 @@ class LoginViewModel extends RestfulApiViewModel {
} }
final bioToken = await DataPreference.instance.getBioToken(phone) ?? ""; final bioToken = await DataPreference.instance.getBioToken(phone) ?? "";
if (bioToken.isEmpty) { 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!\nVui 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!\nVui lòng đăng nhập > cài đặt để kích hoạt tính năng");
return; return;
} }
client.loginWithBiometric(phone).then((value) async { showLoading();
final response = await client.loginWithBiometric(phone);
hideLoading(); hideLoading();
_handleLoginResponse(value, phone); _handleLoginResponse(response, phone);
});
} }
} }
...@@ -3,6 +3,7 @@ import 'package:get/get.dart'; ...@@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:mypoint_flutter_app/extensions/num_extension.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/product_mobile_card_viewmodel.dart';
import 'package:mypoint_flutter_app/screen/mobile_card/usable_mobile_card_popup.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 'package:mypoint_flutter_app/widgets/image_loader.dart';
import '../../base/base_screen.dart'; import '../../base/base_screen.dart';
import '../../base/basic_state.dart'; import '../../base/basic_state.dart';
...@@ -43,18 +44,17 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w ...@@ -43,18 +44,17 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( if (_viewModel.mobileCardSections.isNotEmpty)
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text("Chọn nhà mạng", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
_buildSectionNetwork(), _buildSectionNetwork(),
const SizedBox(height: 24), const SizedBox(height: 24),
if (_viewModel.products.isNotEmpty)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text("Mệnh giá thẻ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), child: Text("Mệnh giá thẻ", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildProductItem(), _buildProductItem(),
if (_viewModel.products.isNotEmpty)
SafeArea( SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
...@@ -80,7 +80,14 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w ...@@ -80,7 +80,14 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w
Widget _buildSectionNetwork() { Widget _buildSectionNetwork() {
final widthCardItem = MediaQuery.of(context).size.width / 2.5; final widthCardItem = MediaQuery.of(context).size.width / 2.5;
return SizedBox( 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, height: widthCardItem * 9 / 16,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
...@@ -115,19 +122,27 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w ...@@ -115,19 +122,27 @@ class _ProductMobileCardScreenState extends BaseState<ProductMobileCardScreen> w
); );
}, },
), ),
),
],
); );
} }
Widget _buildProductItem() { Widget _buildProductItem() {
const double kItemHeight = 80; const double kItemHeight = 80;
final widthItem = (MediaQuery.of(context).size.width - 12*3)/2; final widthItem = (MediaQuery.of(context).size.width - 12 * 3) / 2;
return Expanded( return
(_viewModel.products.isEmpty) ?
const Expanded(
child: Center(
child: EmptyWidget(),
),
) : Expanded(
child: GridView.count( child: GridView.count(
crossAxisCount: 2, crossAxisCount: 2,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
childAspectRatio: widthItem/kItemHeight, childAspectRatio: widthItem / kItemHeight,
children: children:
_viewModel.products.map((product) { _viewModel.products.map((product) {
final isSelected = _viewModel.selectedProduct?.id == product.id; final isSelected = _viewModel.selectedProduct?.id == product.id;
......
...@@ -37,10 +37,4 @@ class OnboardingViewModel extends RestfulApiViewModel { ...@@ -37,10 +37,4 @@ class OnboardingViewModel extends RestfulApiViewModel {
checkPhoneRes.value = value; checkPhoneRes.value = value;
}); });
} }
@override
void onInit() {
super.onInit();
fetchOnboardingContent(); // Gọi API khi khởi tạo ViewModel
}
} }
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