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 {
static var directionInApp = "IN-APP";
static var phoneNumberCount = 10;
static var timeoutSeconds = 30;
static const loadingTimeoutSeconds = 10;
static const loadingTimeoutSeconds = 30;
}
class ErrorCodes {
......
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.
......
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 {
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) {
......
......@@ -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;
}
}
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();
......
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});
......
......@@ -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();
}
......
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');
......
......@@ -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();
......
......@@ -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(
......
......@@ -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(
......
......@@ -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),
......
......@@ -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!\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;
}
client.loginWithBiometric(phone).then((value) async {
hideLoading();
_handleLoginResponse(value, phone);
});
showLoading();
final response = await client.loginWithBiometric(phone);
hideLoading();
_handleLoginResponse(response, phone);
}
}
......@@ -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) {
......
......@@ -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
}
}
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