Compare commits
5 Commits
597c6a0e57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4546e7d8e8 | ||
|
|
fc6a4f038e | ||
|
|
e3632d4445 | ||
|
|
f130820131 | ||
|
|
4cfe000172 |
@@ -161,6 +161,8 @@ PODS:
|
|||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (3.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (3.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
|
- objective_c (0.0.1):
|
||||||
|
- Flutter
|
||||||
- onesignal_flutter (5.3.4):
|
- onesignal_flutter (5.3.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- OneSignalXCFramework (= 5.2.14)
|
- OneSignalXCFramework (= 5.2.14)
|
||||||
@@ -212,6 +214,8 @@ PODS:
|
|||||||
- OneSignalXCFramework/OneSignalOutcomes
|
- OneSignalXCFramework/OneSignalOutcomes
|
||||||
- open_file_ios (0.0.1):
|
- open_file_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- package_info_plus (0.4.5):
|
||||||
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -219,6 +223,11 @@ PODS:
|
|||||||
- SDWebImage (5.21.4):
|
- SDWebImage (5.21.4):
|
||||||
- SDWebImage/Core (= 5.21.4)
|
- SDWebImage/Core (= 5.21.4)
|
||||||
- SDWebImage/Core (5.21.4)
|
- SDWebImage/Core (5.21.4)
|
||||||
|
- Sentry/HybridSDK (8.56.2)
|
||||||
|
- sentry_flutter (9.8.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- Sentry/HybridSDK (= 8.56.2)
|
||||||
- share_plus (0.0.1):
|
- share_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
@@ -242,10 +251,13 @@ DEPENDENCIES:
|
|||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||||
|
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||||
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
|
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
|
||||||
- OneSignalXCFramework (= 5.2.14)
|
- OneSignalXCFramework (= 5.2.14)
|
||||||
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
||||||
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
@@ -269,6 +281,7 @@ SPEC REPOS:
|
|||||||
- OneSignalXCFramework
|
- OneSignalXCFramework
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
|
- Sentry
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
@@ -292,12 +305,18 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/integration_test/ios"
|
:path: ".symlinks/plugins/integration_test/ios"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||||
|
objective_c:
|
||||||
|
:path: ".symlinks/plugins/objective_c/ios"
|
||||||
onesignal_flutter:
|
onesignal_flutter:
|
||||||
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
||||||
open_file_ios:
|
open_file_ios:
|
||||||
:path: ".symlinks/plugins/open_file_ios/ios"
|
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||||
|
package_info_plus:
|
||||||
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
sentry_flutter:
|
||||||
|
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -331,12 +350,16 @@ SPEC CHECKSUMS:
|
|||||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||||
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
|
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||||
onesignal_flutter: f69ff09eeaf41cea4b7a841de5a61e79e7fd9a5a
|
onesignal_flutter: f69ff09eeaf41cea4b7a841de5a61e79e7fd9a5a
|
||||||
OneSignalXCFramework: 7112f3e89563e41ebc23fe807788f11985ac541c
|
OneSignalXCFramework: 7112f3e89563e41ebc23fe807788f11985ac541c
|
||||||
open_file_ios: 461db5853723763573e140de3193656f91990d9e
|
open_file_ios: 461db5853723763573e140de3193656f91990d9e
|
||||||
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
||||||
|
Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7
|
||||||
|
sentry_flutter: f074f75557daea0e1dd9607381a05cc0e3e456fe
|
||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
|||||||
@@ -56,21 +56,10 @@ class AuthInterceptor extends Interceptor {
|
|||||||
// Get session data from secure storage
|
// Get session data from secure storage
|
||||||
final sid = await _secureStorage.read(key: 'frappe_sid');
|
final sid = await _secureStorage.read(key: 'frappe_sid');
|
||||||
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
|
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
|
||||||
final fullName = await _secureStorage.read(key: 'frappe_full_name');
|
|
||||||
final userId = await _secureStorage.read(key: 'frappe_user_id');
|
|
||||||
|
|
||||||
if (sid != null && csrfToken != null) {
|
if (sid != null && csrfToken != null) {
|
||||||
// Build cookie header with all required fields
|
// Only send sid in Cookie header - other fields are not needed
|
||||||
final cookieHeader = [
|
options.headers['Cookie'] = 'sid=$sid';
|
||||||
'sid=$sid',
|
|
||||||
'full_name=${fullName ?? "User"}',
|
|
||||||
'system_user=no',
|
|
||||||
'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}',
|
|
||||||
'user_image=',
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
// Add Frappe session headers
|
|
||||||
options.headers['Cookie'] = cookieHeader;
|
|
||||||
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ Future<CacheOptions> cacheOptions(Ref ref) async {
|
|||||||
Future<Dio> dio(Ref ref) async {
|
Future<Dio> dio(Ref ref) async {
|
||||||
final dio = Dio();
|
final dio = Dio();
|
||||||
|
|
||||||
|
|
||||||
// Base configuration
|
// Base configuration
|
||||||
dio
|
dio
|
||||||
..options = BaseOptions(
|
..options = BaseOptions(
|
||||||
@@ -466,7 +467,7 @@ Future<Dio> dio(Ref ref) async {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Add interceptors in order
|
// Add interceptors in order
|
||||||
// 1. Custom Curl interceptor (first to log cURL commands)
|
// 1. Custom Curl interceptor (logs cURL commands)
|
||||||
// Uses debugPrint and developer.log for better visibility
|
// Uses debugPrint and developer.log for better visibility
|
||||||
..interceptors.add(CustomCurlLoggerInterceptor())
|
..interceptors.add(CustomCurlLoggerInterceptor())
|
||||||
// 2. Logging interceptor
|
// 2. Logging interceptor
|
||||||
|
|||||||
@@ -89,13 +89,8 @@ class FrappeAuthService {
|
|||||||
|
|
||||||
const url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
|
const url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
|
||||||
|
|
||||||
// Build cookie header
|
// Get stored session - only need sid and csrf_token
|
||||||
final storedSession = await getStoredSession();
|
final storedSession = await getStoredSession();
|
||||||
final cookieHeader = _buildCookieHeader(
|
|
||||||
sid: storedSession!['sid']!,
|
|
||||||
fullName: storedSession['fullName']!,
|
|
||||||
userId: storedSession['userId']!,
|
|
||||||
);
|
|
||||||
|
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
url,
|
url,
|
||||||
@@ -109,7 +104,7 @@ class FrappeAuthService {
|
|||||||
},
|
},
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': cookieHeader,
|
'Cookie': 'sid=${storedSession!['sid']!}',
|
||||||
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
|
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -203,31 +198,13 @@ class FrappeAuthService {
|
|||||||
return sid != null && csrfToken != null;
|
return sid != null && csrfToken != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build cookie header string
|
|
||||||
String _buildCookieHeader({
|
|
||||||
required String sid,
|
|
||||||
required String fullName,
|
|
||||||
required String userId,
|
|
||||||
}) {
|
|
||||||
return [
|
|
||||||
'sid=$sid',
|
|
||||||
'full_name=$fullName',
|
|
||||||
'system_user=no',
|
|
||||||
'user_id=${Uri.encodeComponent(userId)}',
|
|
||||||
'user_image=',
|
|
||||||
].join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get headers for Frappe API requests
|
/// Get headers for Frappe API requests
|
||||||
|
/// Only sends sid in Cookie - other fields are not needed
|
||||||
Future<Map<String, String>> getHeaders() async {
|
Future<Map<String, String>> getHeaders() async {
|
||||||
final session = await ensureSession();
|
final session = await ensureSession();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Cookie': _buildCookieHeader(
|
'Cookie': 'sid=${session['sid']!}',
|
||||||
sid: session['sid']!,
|
|
||||||
fullName: session['fullName']!,
|
|
||||||
userId: session['userId']!,
|
|
||||||
),
|
|
||||||
'X-Frappe-Csrf-Token': session['csrfToken']!,
|
'X-Frappe-Csrf-Token': session['csrfToken']!,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|||||||
168
lib/core/services/onesignal_service.dart
Normal file
168
lib/core/services/onesignal_service.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:onesignal_flutter/onesignal_flutter.dart';
|
||||||
|
|
||||||
|
/// OneSignal service for managing push notifications and external user ID.
|
||||||
|
///
|
||||||
|
/// This service handles:
|
||||||
|
/// - Initializing OneSignal SDK
|
||||||
|
/// - Setting external user ID after login (using phone number)
|
||||||
|
/// - Restoring external user ID on app startup
|
||||||
|
/// - Clearing external user ID on logout
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// // Initialize in main.dart
|
||||||
|
/// await OneSignalService.init(appId: 'your-app-id');
|
||||||
|
///
|
||||||
|
/// // After successful login
|
||||||
|
/// await OneSignalService.login(phoneNumber);
|
||||||
|
///
|
||||||
|
/// // On logout
|
||||||
|
/// await OneSignalService.logout();
|
||||||
|
/// ```
|
||||||
|
class OneSignalService {
|
||||||
|
OneSignalService._();
|
||||||
|
|
||||||
|
/// OneSignal App ID - Replace with your actual App ID from OneSignal dashboard
|
||||||
|
static const String _defaultAppId = '778ca22d-c719-4ec8-86cb-a6b911166066';
|
||||||
|
|
||||||
|
/// Initialize OneSignal SDK
|
||||||
|
///
|
||||||
|
/// Must be called before using any other OneSignal methods.
|
||||||
|
/// Sets up push subscription observers and requests notification permission.
|
||||||
|
///
|
||||||
|
/// [appId] - Optional App ID override (uses default if not provided)
|
||||||
|
/// [requestPermission] - Whether to request notification permission (default: true)
|
||||||
|
static Future<void> init({
|
||||||
|
String? appId,
|
||||||
|
bool requestPermission = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Set debug log level (verbose in debug, none in release)
|
||||||
|
OneSignal.Debug.setLogLevel(kDebugMode ? OSLogLevel.verbose : OSLogLevel.none);
|
||||||
|
|
||||||
|
// Initialize with App ID
|
||||||
|
OneSignal.initialize(appId ?? _defaultAppId);
|
||||||
|
debugPrint('🔔 OneSignal initialized');
|
||||||
|
|
||||||
|
// Add push subscription observer to track subscription state changes
|
||||||
|
OneSignal.User.pushSubscription.addObserver((state) {
|
||||||
|
debugPrint('🔔 Push subscription state changed:');
|
||||||
|
debugPrint(' Previous - optedIn: ${state.previous.optedIn}, id: ${state.previous.id}');
|
||||||
|
debugPrint(' Current - optedIn: ${state.current.optedIn}, id: ${state.current.id}');
|
||||||
|
debugPrint(' Subscription ID: ${state.current.id}');
|
||||||
|
debugPrint(' Push Token: ${state.current.token}');
|
||||||
|
|
||||||
|
if (state.current.id != null) {
|
||||||
|
debugPrint('🔔 ✅ Device successfully subscribed!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add notification permission observer
|
||||||
|
OneSignal.Notifications.addPermissionObserver((isGranted) {
|
||||||
|
debugPrint('🔔 Notification permission changed: $isGranted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request permission if enabled
|
||||||
|
if (requestPermission) {
|
||||||
|
final accepted = await OneSignal.Notifications.requestPermission(true);
|
||||||
|
debugPrint('🔔 Permission request result: $accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give OneSignal SDK time to complete initialization and server registration
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Log current subscription status
|
||||||
|
_logSubscriptionStatus();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to initialize - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log current subscription status for debugging
|
||||||
|
static void _logSubscriptionStatus() {
|
||||||
|
final optedIn = OneSignal.User.pushSubscription.optedIn;
|
||||||
|
final id = OneSignal.User.pushSubscription.id;
|
||||||
|
final token = OneSignal.User.pushSubscription.token;
|
||||||
|
|
||||||
|
debugPrint('🔔 Current subscription status:');
|
||||||
|
debugPrint(' Opted In: $optedIn');
|
||||||
|
debugPrint(' Subscription ID: $id');
|
||||||
|
debugPrint(' Push Token: $token');
|
||||||
|
|
||||||
|
if (id == null) {
|
||||||
|
debugPrint('🔔 ⚠️ Subscription ID is null - check device connectivity and OneSignal app ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login user to OneSignal by setting external user ID.
|
||||||
|
///
|
||||||
|
/// This associates the device with the user's phone number,
|
||||||
|
/// allowing targeted push notifications to specific users.
|
||||||
|
///
|
||||||
|
/// [phoneNumber] - The user's phone number (used as external ID)
|
||||||
|
static Future<void> login(String phoneNumber) async {
|
||||||
|
try {
|
||||||
|
// Set external user ID for targeting
|
||||||
|
await OneSignal.login(phoneNumber);
|
||||||
|
debugPrint('🔔 OneSignal: login - external_id set to $phoneNumber');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set external user ID - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user from OneSignal by removing external user ID.
|
||||||
|
///
|
||||||
|
/// This disassociates the device from the user,
|
||||||
|
/// so notifications won't be sent to this specific user anymore.
|
||||||
|
static Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await OneSignal.logout();
|
||||||
|
debugPrint('🔔 OneSignal: logout - external_id cleared');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to clear external user ID - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a tag to the user for segmentation.
|
||||||
|
///
|
||||||
|
/// Tags can be used to segment users for targeted notifications.
|
||||||
|
/// Example: tier = "diamond", role = "contractor"
|
||||||
|
static Future<void> setTag(String key, String value) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.addTagWithKey(key, value);
|
||||||
|
debugPrint('🔔 OneSignal: tag set - $key: $value');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple tags at once.
|
||||||
|
static Future<void> setTags(Map<String, String> tags) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.addTags(tags);
|
||||||
|
debugPrint('🔔 OneSignal: tags set - $tags');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set tags - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a tag from the user.
|
||||||
|
static Future<void> removeTag(String key) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.removeTag(key);
|
||||||
|
debugPrint('🔔 OneSignal: tag removed - $key');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to remove tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the OneSignal subscription ID (player ID).
|
||||||
|
///
|
||||||
|
/// This is the device-specific ID used by OneSignal.
|
||||||
|
static String? get subscriptionId => OneSignal.User.pushSubscription.id;
|
||||||
|
|
||||||
|
/// Check if push notifications are enabled.
|
||||||
|
static bool get isPushEnabled =>
|
||||||
|
OneSignal.User.pushSubscription.optedIn ?? false;
|
||||||
|
}
|
||||||
251
lib/core/services/sentry_service.dart
Normal file
251
lib/core/services/sentry_service.dart
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
/// Sentry service for error tracking and performance monitoring.
|
||||||
|
///
|
||||||
|
/// This service handles:
|
||||||
|
/// - Initializing Sentry SDK
|
||||||
|
/// - Capturing exceptions and errors
|
||||||
|
/// - Capturing custom messages
|
||||||
|
/// - Setting user context after login
|
||||||
|
/// - Performance monitoring
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// // Initialize in main.dart
|
||||||
|
/// await SentryService.init(
|
||||||
|
/// dsn: 'your-sentry-dsn',
|
||||||
|
/// appRunner: () => runApp(MyApp()),
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// // Capture exception
|
||||||
|
/// SentryService.captureException(error, stackTrace: stackTrace);
|
||||||
|
///
|
||||||
|
/// // Capture message
|
||||||
|
/// SentryService.captureMessage('User performed action X');
|
||||||
|
///
|
||||||
|
/// // Set user context after login
|
||||||
|
/// SentryService.setUser(userId: '123', email: 'user@example.com');
|
||||||
|
/// ```
|
||||||
|
class SentryService {
|
||||||
|
SentryService._();
|
||||||
|
|
||||||
|
/// Sentry DSN - Replace with your actual DSN from Sentry dashboard
|
||||||
|
static const String _dsn = 'https://2c5893508a29e5ea750b64d5ee31aeef@o4509632266436608.ingest.us.sentry.io/4510464530972672';
|
||||||
|
|
||||||
|
/// Initialize Sentry SDK
|
||||||
|
///
|
||||||
|
/// Must be called before runApp() in main.dart.
|
||||||
|
/// Wraps the app with Sentry error boundary.
|
||||||
|
///
|
||||||
|
/// [dsn] - Optional DSN override (uses default if not provided)
|
||||||
|
/// [appRunner] - The function that runs the app (typically runApp(MyApp()))
|
||||||
|
/// [environment] - Environment name (e.g., 'development', 'production')
|
||||||
|
static Future<void> init({
|
||||||
|
String? dsn,
|
||||||
|
required Future<void> Function() appRunner,
|
||||||
|
String? environment,
|
||||||
|
}) async {
|
||||||
|
// Get package info for release version
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final release = 'partner@${packageInfo.version}+${packageInfo.buildNumber}';
|
||||||
|
|
||||||
|
await SentryFlutter.init(
|
||||||
|
(options) {
|
||||||
|
options
|
||||||
|
..dsn = dsn ?? _dsn
|
||||||
|
|
||||||
|
// Release version: worker@1.0.1+29
|
||||||
|
..release = release
|
||||||
|
|
||||||
|
// Environment configuration
|
||||||
|
..environment = environment ?? (kReleaseMode ? 'production' : 'development')
|
||||||
|
|
||||||
|
// Performance monitoring
|
||||||
|
..tracesSampleRate = kReleaseMode ? 0.2 : 1.0 // 20% in prod, 100% in dev
|
||||||
|
..profilesSampleRate = kReleaseMode ? 0.2 : 1.0
|
||||||
|
|
||||||
|
// Enable automatic instrumentation
|
||||||
|
..enableAutoPerformanceTracing = true
|
||||||
|
|
||||||
|
// Capture failed requests
|
||||||
|
..captureFailedRequests = true
|
||||||
|
|
||||||
|
// Debug mode settings
|
||||||
|
..debug = kDebugMode
|
||||||
|
|
||||||
|
// Add app-specific tags
|
||||||
|
..beforeSend = (event, hint) {
|
||||||
|
// Filter out certain errors if needed
|
||||||
|
// Return null to drop the event
|
||||||
|
// return event;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
appRunner: appRunner,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('🔴 Sentry initialized (release: $release, enabled: ${!kDebugMode})');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture an exception with optional stack trace
|
||||||
|
///
|
||||||
|
/// [exception] - The exception to capture
|
||||||
|
/// [stackTrace] - Optional stack trace
|
||||||
|
/// [hint] - Optional hint with additional context
|
||||||
|
static Future<void> captureException(
|
||||||
|
dynamic exception, {
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
Hint? hint,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.captureException(
|
||||||
|
exception,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
hint: hint,
|
||||||
|
);
|
||||||
|
debugPrint('🔴 Sentry: Exception captured - ${exception.runtimeType}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to capture exception - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a custom message
|
||||||
|
///
|
||||||
|
/// [message] - The message to capture
|
||||||
|
/// [level] - Severity level (default: info)
|
||||||
|
/// [params] - Optional parameters to include
|
||||||
|
static Future<void> captureMessage(
|
||||||
|
String message, {
|
||||||
|
SentryLevel level = SentryLevel.info,
|
||||||
|
Map<String, dynamic>? params,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.captureMessage(
|
||||||
|
message,
|
||||||
|
level: level,
|
||||||
|
withScope: params != null
|
||||||
|
? (scope) {
|
||||||
|
params.forEach((key, value) {
|
||||||
|
scope.setExtra(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
debugPrint('🔴 Sentry: Message captured - $message');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to capture message - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set user context for error tracking
|
||||||
|
///
|
||||||
|
/// Call this after successful login to associate errors with users.
|
||||||
|
///
|
||||||
|
/// [userId] - User's unique identifier
|
||||||
|
/// [email] - User's email (optional)
|
||||||
|
/// [username] - User's display name (optional)
|
||||||
|
/// [extras] - Additional user data (optional)
|
||||||
|
static Future<void> setUser({
|
||||||
|
required String userId,
|
||||||
|
String? email,
|
||||||
|
String? username,
|
||||||
|
Map<String, dynamic>? extras,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setUser(SentryUser(
|
||||||
|
id: userId,
|
||||||
|
email: email,
|
||||||
|
username: username,
|
||||||
|
data: extras,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
debugPrint('🔴 Sentry: User set - $userId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set user - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear user context on logout
|
||||||
|
static Future<void> clearUser() async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setUser(null);
|
||||||
|
});
|
||||||
|
debugPrint('🔴 Sentry: User cleared');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to clear user - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a breadcrumb for tracking user actions
|
||||||
|
///
|
||||||
|
/// Breadcrumbs are used to track the sequence of events leading to an error.
|
||||||
|
///
|
||||||
|
/// [message] - Description of the action
|
||||||
|
/// [category] - Category of the breadcrumb (e.g., 'navigation', 'ui.click')
|
||||||
|
/// [data] - Additional data (optional)
|
||||||
|
static Future<void> addBreadcrumb({
|
||||||
|
required String message,
|
||||||
|
String? category,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
SentryLevel level = SentryLevel.info,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.addBreadcrumb(Breadcrumb(
|
||||||
|
message: message,
|
||||||
|
category: category,
|
||||||
|
data: data,
|
||||||
|
level: level,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to add breadcrumb - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a tag for filtering in Sentry dashboard
|
||||||
|
///
|
||||||
|
/// [key] - Tag name
|
||||||
|
/// [value] - Tag value
|
||||||
|
static Future<void> setTag(String key, String value) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setTag(key, value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set extra context data
|
||||||
|
///
|
||||||
|
/// [key] - Context key
|
||||||
|
/// [value] - Context value (will be serialized)
|
||||||
|
static Future<void> setExtra(String key, dynamic value) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setExtra(key, value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set extra - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a performance transaction
|
||||||
|
///
|
||||||
|
/// [name] - Transaction name
|
||||||
|
/// [operation] - Operation type (e.g., 'http.client', 'ui.load')
|
||||||
|
///
|
||||||
|
/// Returns the transaction to be finished later.
|
||||||
|
static ISentrySpan? startTransaction(String name, String operation) {
|
||||||
|
try {
|
||||||
|
return Sentry.startTransaction(name, operation);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to start transaction - $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,15 @@ extension StringExtensions on String {
|
|||||||
if (cleaned.length < 10) return this;
|
if (cleaned.length < 10) return this;
|
||||||
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove leading "#" from string (e.g., "#SO-001" -> "SO-001")
|
||||||
|
/// Useful for backend IDs that start with "#"
|
||||||
|
String get withoutHash {
|
||||||
|
if (startsWith('#')) {
|
||||||
|
return substring(1);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
..when(
|
..when(
|
||||||
data: (user) {
|
data: (user) {
|
||||||
if (user != null && mounted) {
|
if (user != null && mounted) {
|
||||||
|
// Analytics (logLogin & setUserId) are handled in auth_provider
|
||||||
// Navigate to home on success
|
// Navigate to home on success
|
||||||
context.goHome();
|
context.goHome();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import 'package:worker/features/auth/presentation/providers/customer_groups_prov
|
|||||||
import 'package:worker/features/auth/presentation/providers/session_provider.dart';
|
import 'package:worker/features/auth/presentation/providers/session_provider.dart';
|
||||||
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
||||||
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Registration Page
|
/// Registration Page
|
||||||
///
|
///
|
||||||
@@ -345,6 +346,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// Log sign up analytics event
|
||||||
|
AnalyticsService.logSignUp(method: 'phone');
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import 'package:worker/core/constants/api_constants.dart';
|
import 'package:worker/core/constants/api_constants.dart';
|
||||||
import 'package:worker/core/network/dio_client.dart';
|
import 'package:worker/core/network/dio_client.dart';
|
||||||
import 'package:worker/core/services/frappe_auth_service.dart';
|
import 'package:worker/core/services/frappe_auth_service.dart';
|
||||||
|
import 'package:worker/core/services/onesignal_service.dart';
|
||||||
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
||||||
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
|
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
|
||||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
part 'auth_provider.g.dart';
|
part 'auth_provider.g.dart';
|
||||||
|
|
||||||
@@ -99,7 +101,11 @@ class Auth extends _$Auth {
|
|||||||
final fullName = await secureStorage.read(key: 'frappe_full_name');
|
final fullName = await secureStorage.read(key: 'frappe_full_name');
|
||||||
|
|
||||||
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
|
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
|
||||||
// User is logged in and wants to be remembered, create User entity
|
// User is logged in and wants to be remembered
|
||||||
|
// Restore OneSignal external user ID for targeted notifications
|
||||||
|
await OneSignalService.login(userId);
|
||||||
|
|
||||||
|
// Create User entity
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -182,6 +188,14 @@ class Auth extends _$Auth {
|
|||||||
// Save rememberMe preference
|
// Save rememberMe preference
|
||||||
await _localDataSource.saveRememberMe(rememberMe);
|
await _localDataSource.saveRememberMe(rememberMe);
|
||||||
|
|
||||||
|
// Set user ID for analytics tracking
|
||||||
|
await AnalyticsService.setUserId(phoneNumber);
|
||||||
|
// Log login event
|
||||||
|
await AnalyticsService.logLogin(method: 'phone');
|
||||||
|
|
||||||
|
// Set OneSignal external user ID for targeted notifications
|
||||||
|
await OneSignalService.login(phoneNumber);
|
||||||
|
|
||||||
// Create and return User entity
|
// Create and return User entity
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
@@ -218,6 +232,12 @@ class Auth extends _$Auth {
|
|||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(() async {
|
||||||
final frappeService = await _frappeAuthService;
|
final frappeService = await _frappeAuthService;
|
||||||
|
|
||||||
|
// Clear user ID from analytics
|
||||||
|
await AnalyticsService.setUserId(null);
|
||||||
|
|
||||||
|
// Clear OneSignal external user ID
|
||||||
|
await OneSignalService.logout();
|
||||||
|
|
||||||
// Clear saved session
|
// Clear saved session
|
||||||
await _localDataSource.clearSession();
|
await _localDataSource.clearSession();
|
||||||
await frappeService.clearSession();
|
await frappeService.clearSession();
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
|||||||
Auth create() => Auth();
|
Auth create() => Auth();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authHash() => r'd851980cad7a624f00eba69e19d8a4fee22008e7';
|
String _$authHash() => r'2aaad43ba390e824b5aa8d95bc14e514c421c8ef';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import 'package:worker/core/theme/typography.dart';
|
|||||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
||||||
import 'package:worker/features/cart/presentation/widgets/cart_item_widget.dart';
|
import 'package:worker/features/cart/presentation/widgets/cart_item_widget.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
|
||||||
/// Cart Page
|
/// Cart Page
|
||||||
///
|
///
|
||||||
@@ -34,6 +36,7 @@ class CartPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _CartPageState extends ConsumerState<CartPage> {
|
class _CartPageState extends ConsumerState<CartPage> {
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
|
bool _hasLoggedViewCart = false;
|
||||||
|
|
||||||
// Cart is initialized once in home_page.dart at app startup
|
// Cart is initialized once in home_page.dart at app startup
|
||||||
// Provider has keepAlive: true, so no need to reload here
|
// Provider has keepAlive: true, so no need to reload here
|
||||||
@@ -42,12 +45,28 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
// and in checkout button handler for checkout flow.
|
// and in checkout button handler for checkout flow.
|
||||||
// No dispose() method needed - using ref.read() in dispose() is unsafe.
|
// No dispose() method needed - using ref.read() in dispose() is unsafe.
|
||||||
|
|
||||||
|
void _logViewCartOnce(CartState cartState) {
|
||||||
|
if (_hasLoggedViewCart || cartState.isEmpty) return;
|
||||||
|
_hasLoggedViewCart = true;
|
||||||
|
|
||||||
|
AnalyticsService.logViewCart(
|
||||||
|
cartValue: cartState.selectedTotal,
|
||||||
|
items: cartState.items.map((item) => AnalyticsEventItem(
|
||||||
|
itemId: item.product.productId,
|
||||||
|
itemName: item.product.name,
|
||||||
|
price: item.product.basePrice,
|
||||||
|
quantity: item.quantity.toInt(),
|
||||||
|
)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final cartState = ref.watch(cartProvider);
|
final cartState = ref.watch(cartProvider);
|
||||||
|
|
||||||
|
// Log view cart analytics event only once when page opens
|
||||||
|
_logViewCartOnce(cartState);
|
||||||
|
|
||||||
final itemCount = cartState.itemCount;
|
final itemCount = cartState.itemCount;
|
||||||
final hasSelection = cartState.selectedCount > 0;
|
final hasSelection = cartState.selectedCount > 0;
|
||||||
@@ -331,6 +350,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
/// Build error banner (shown at top when there's an error but cart has items)
|
/// Build error banner (shown at top when there's an error but cart has items)
|
||||||
Widget _buildErrorBanner(String errorMessage) {
|
Widget _buildErrorBanner(String errorMessage) {
|
||||||
|
print(errorMessage);
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
@@ -442,6 +462,17 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
// Log remove from cart analytics for selected items
|
||||||
|
for (final item in cartState.items) {
|
||||||
|
if (cartState.selectedItems[item.product.productId] == true) {
|
||||||
|
AnalyticsService.logRemoveFromCart(
|
||||||
|
productId: item.product.productId,
|
||||||
|
productName: item.product.name,
|
||||||
|
price: item.product.basePrice,
|
||||||
|
quantity: item.quantity.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
ref.read(cartProvider.notifier).deleteSelected();
|
ref.read(cartProvider.notifier).deleteSelected();
|
||||||
context.pop();
|
context.pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import 'package:worker/features/cart/presentation/widgets/payment_method_section
|
|||||||
import 'package:worker/features/cart/presentation/widgets/price_negotiation_section.dart';
|
import 'package:worker/features/cart/presentation/widgets/price_negotiation_section.dart';
|
||||||
import 'package:worker/features/orders/presentation/providers/order_status_provider.dart';
|
import 'package:worker/features/orders/presentation/providers/order_status_provider.dart';
|
||||||
import 'package:worker/features/orders/presentation/providers/payment_terms_provider.dart';
|
import 'package:worker/features/orders/presentation/providers/payment_terms_provider.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
|
||||||
/// Checkout Page
|
/// Checkout Page
|
||||||
///
|
///
|
||||||
@@ -104,6 +106,22 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final total = subtotal - memberDiscount + shipping;
|
final total = subtotal - memberDiscount + shipping;
|
||||||
|
|
||||||
|
// Log begin checkout analytics event
|
||||||
|
if (cartItemsData.isNotEmpty) {
|
||||||
|
AnalyticsService.logBeginCheckout(
|
||||||
|
value: total,
|
||||||
|
items: cartItemsData.map((itemData) {
|
||||||
|
final cartItem = itemData as CartItemData;
|
||||||
|
return AnalyticsEventItem(
|
||||||
|
itemId: cartItem.product.productId,
|
||||||
|
itemName: cartItem.product.name,
|
||||||
|
price: cartItem.product.basePrice,
|
||||||
|
quantity: cartItem.quantity.toInt(),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:worker/core/utils/extensions.dart';
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
import 'package:worker/core/widgets/loading_indicator.dart';
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/core/theme/typography.dart';
|
import 'package:worker/core/theme/typography.dart';
|
||||||
@@ -113,8 +113,13 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Product Image (bigger: 100x100)
|
// Product Image (bigger: 100x100) - tap to navigate to product detail
|
||||||
ClipRRect(
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// Navigate to product detail with product ID in path
|
||||||
|
context.push('/products/${widget.item.product.productId}');
|
||||||
|
},
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: widget.item.product.thumbnail.isNotEmpty
|
imageUrl: widget.item.product.thumbnail.isNotEmpty
|
||||||
@@ -145,6 +150,7 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
@@ -193,14 +199,15 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Quantity TextField
|
// Quantity TextField - uses text keyboard for Done button on iOS/Android
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _quantityController,
|
controller: _quantityController,
|
||||||
focusNode: _quantityFocusNode,
|
focusNode: _quantityFocusNode,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: AppTypography.titleMedium.copyWith(
|
style: AppTypography.titleMedium.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
actions: [
|
actions: [
|
||||||
QuickAction(
|
QuickAction(
|
||||||
icon: FontAwesomeIcons.circlePlus,
|
icon: FontAwesomeIcons.circlePlus,
|
||||||
label: 'Ghi nhận điểm',
|
label: 'Tham gia sự kiện',
|
||||||
onTap: () => context.push(RouteNames.pointsRecords),
|
onTap: () => context.push(RouteNames.pointsRecords),
|
||||||
),
|
),
|
||||||
QuickAction(
|
QuickAction(
|
||||||
@@ -216,7 +216,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
|
|
||||||
// Sample Houses & News Section
|
// Sample Houses & News Section
|
||||||
QuickActionSection(
|
QuickActionSection(
|
||||||
title: 'Nhà mẫu, dự án & tin tức',
|
title: 'Nhà mẫu & Dự án',
|
||||||
actions: [
|
actions: [
|
||||||
QuickAction(
|
QuickAction(
|
||||||
icon: FontAwesomeIcons.houseCircleCheck,
|
icon: FontAwesomeIcons.houseCircleCheck,
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
|
|
||||||
// Invoice Number
|
// Invoice Number
|
||||||
Text(
|
Text(
|
||||||
'#${invoice.name}',
|
invoice.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -192,7 +192,7 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
_MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate),
|
_MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate),
|
||||||
if (invoice.orderId != null) ...[
|
if (invoice.orderId != null) ...[
|
||||||
const SizedBox(width: 32),
|
const SizedBox(width: 32),
|
||||||
_MetaItem(label: 'Đơn hàng:', value: '#${invoice.orderId}'),
|
_MetaItem(label: 'Đơn hàng:', value: '${invoice.orderId}'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class _InvoiceCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${invoice.name}',
|
invoice.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -252,7 +252,7 @@ class _InvoiceCard extends StatelessWidget {
|
|||||||
if (invoice.orderId != null)
|
if (invoice.orderId != null)
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Đơn hàng:',
|
label: 'Đơn hàng:',
|
||||||
value: '#${invoice.orderId}',
|
value: '${invoice.orderId}',
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Tổng tiền:',
|
label: 'Tổng tiền:',
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ class PointsHistoryPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Mã tham chiếu: #${entry.entryId}',
|
'Mã tham chiếu: ${entry.entryId}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ class PointsRecordsPage extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${record.recordId}',
|
record.recordId,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:worker/features/loyalty/presentation/providers/gifts_provider.da
|
|||||||
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/widgets/points_balance_card.dart';
|
import 'package:worker/features/loyalty/presentation/widgets/points_balance_card.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/widgets/reward_card.dart';
|
import 'package:worker/features/loyalty/presentation/widgets/reward_card.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Rewards Page
|
/// Rewards Page
|
||||||
///
|
///
|
||||||
@@ -441,6 +442,12 @@ class RewardsPage extends ConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
GiftCatalog gift,
|
GiftCatalog gift,
|
||||||
) {
|
) {
|
||||||
|
// Log spend points analytics event
|
||||||
|
AnalyticsService.logSpendPoints(
|
||||||
|
points: gift.pointsCost,
|
||||||
|
itemName: gift.name,
|
||||||
|
);
|
||||||
|
|
||||||
// Deduct points
|
// Deduct points
|
||||||
ref.read(loyaltyPointsProvider.notifier).deductPoints(gift.pointsCost);
|
ref.read(loyaltyPointsProvider.notifier).deductPoints(gift.pointsCost);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class NotificationsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Use Flutter hooks for local state management
|
// Use Flutter hooks for local state management
|
||||||
final selectedCategory = useState<String>('general');
|
final selectedCategory = useState<String>('general');
|
||||||
final notificationsAsync = ref.watch(
|
final notificationsAsync = ref.watch(
|
||||||
@@ -34,13 +36,19 @@ class NotificationsPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: SafeArea(
|
appBar: AppBar(
|
||||||
child: Column(
|
title: Text(
|
||||||
|
'Thông báo',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
elevation: AppBarSpecs.elevation,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
_buildHeader(),
|
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
_buildTabs(context, selectedCategory),
|
_buildTabs(context, selectedCategory),
|
||||||
|
|
||||||
@@ -60,33 +68,6 @@ class NotificationsPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build header
|
|
||||||
Widget _buildHeader() {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Thông báo',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Color(0xFF212121),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Order Number and Status Badge
|
// Order Number and Status Badge
|
||||||
Text(
|
Text(
|
||||||
'#${order.name}',
|
order.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Order Success Page
|
/// Order Success Page
|
||||||
class OrderSuccessPage extends StatelessWidget {
|
class OrderSuccessPage extends StatelessWidget {
|
||||||
@@ -37,6 +38,15 @@ class OrderSuccessPage extends StatelessWidget {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||||
|
|
||||||
|
// Log purchase analytics event (only for actual purchases, not negotiations)
|
||||||
|
if (!isNegotiation && total != null) {
|
||||||
|
AnalyticsService.logPurchase(
|
||||||
|
orderId: orderNumber,
|
||||||
|
value: total!,
|
||||||
|
items: [], // Items not available in this page
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class PaymentDetailPage extends ConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#$invoiceNumber',
|
'$invoiceNumber',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ class PaymentsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Mã giao dịch:',
|
label: 'Mã giao dịch:',
|
||||||
value: '#${payment.name}',
|
value: '${payment.name}',
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Loại giao dịch:',
|
label: 'Loại giao dịch:',
|
||||||
@@ -325,7 +325,7 @@ class _TransactionCard extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${payment.name}',
|
payment.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class OrderCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Order number
|
// Order number
|
||||||
Text(
|
Text(
|
||||||
'#${order.name}',
|
'${order.name}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -138,7 +138,15 @@ class ProductsRemoteDataSource {
|
|||||||
throw Exception('Product not found: $itemCode');
|
throw Exception('Product not found: $itemCode');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProductModel.fromJson(message as Map<String, dynamic>);
|
// Handle API error response: {success: false, message: "Item not found"}
|
||||||
|
if (message is Map<String, dynamic>) {
|
||||||
|
if (message['success'] == false) {
|
||||||
|
throw Exception(message['message'] ?? 'Product not found: $itemCode');
|
||||||
|
}
|
||||||
|
return ProductModel.fromJson(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Invalid response format for product: $itemCode');
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.response?.statusCode == 404) {
|
if (e.response?.statusCode == 404) {
|
||||||
throw Exception('Product not found: $itemCode');
|
throw Exception('Product not found: $itemCode');
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
|||||||
),
|
),
|
||||||
body: productAsync.when(
|
body: productAsync.when(
|
||||||
data: (product) {
|
data: (product) {
|
||||||
|
// Log view item analytics event
|
||||||
|
AnalyticsService.logViewItem(
|
||||||
|
productId: product.productId,
|
||||||
|
productName: product.name,
|
||||||
|
price: product.basePrice,
|
||||||
|
brand: product.itemGroupName,
|
||||||
|
category: product.itemGroupName,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Scrollable content
|
// Scrollable content
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
part 'search_query_provider.g.dart';
|
part 'search_query_provider.g.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ class SearchQuery extends _$SearchQuery {
|
|||||||
// Only update if query still meets requirements after delay
|
// Only update if query still meets requirements after delay
|
||||||
if (trimmedQuery.length >= 2) {
|
if (trimmedQuery.length >= 2) {
|
||||||
state = trimmedQuery;
|
state = trimmedQuery;
|
||||||
|
// Log search analytics event
|
||||||
|
AnalyticsService.logSearch(searchTerm: trimmedQuery);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$searchQueryHash() => r'3a4178c8c220a1016d20887d7bd97cd157f777f8';
|
String _$searchQueryHash() => r'62ee3245dca6a43fb276bee72ba6e6d16238e69b';
|
||||||
|
|
||||||
/// Search Query Provider
|
/// Search Query Provider
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ class _QuotesPageState extends ConsumerState<QuotesPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${quote.quoteNumber}',
|
'${quote.quoteNumber}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Request ID
|
// Request ID
|
||||||
Text(
|
Text(
|
||||||
'#${request.id}',
|
'${request.id}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -509,21 +509,7 @@ class _RequestCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header: Code and Status
|
// Header: Code and Status
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Mã yêu cầu: #${request.id}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
@@ -542,7 +528,16 @@ class _RequestCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Mã yêu cầu: #${request.id}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -566,16 +561,16 @@ class _RequestCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (request.plainDescription.isNotEmpty) ...[
|
// if (request.plainDescription.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
// const SizedBox(height: 4),
|
||||||
// Description
|
// // Description
|
||||||
Text(
|
// Text(
|
||||||
request.plainDescription,
|
// request.plainDescription,
|
||||||
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
// style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
maxLines: 2,
|
// maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
// overflow: TextOverflow.ellipsis,
|
||||||
),
|
// ),
|
||||||
],
|
// ],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
101
lib/main.dart
101
lib/main.dart
@@ -1,17 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:onesignal_flutter/onesignal_flutter.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:worker/app.dart';
|
import 'package:worker/app.dart';
|
||||||
import 'package:worker/core/database/app_settings_box.dart';
|
import 'package:worker/core/database/app_settings_box.dart';
|
||||||
import 'package:worker/core/database/hive_initializer.dart';
|
import 'package:worker/core/database/hive_initializer.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:worker/core/services/onesignal_service.dart';
|
||||||
import 'firebase_options.dart';
|
import 'package:worker/core/services/sentry_service.dart';
|
||||||
|
import 'package:worker/firebase_options.dart';
|
||||||
|
|
||||||
/// Main entry point of the Worker Mobile App
|
/// Main entry point of the Worker Mobile App
|
||||||
///
|
///
|
||||||
@@ -45,72 +45,35 @@ void main() async {
|
|||||||
|
|
||||||
/// Initialize all app dependencies with comprehensive error handling
|
/// Initialize all app dependencies with comprehensive error handling
|
||||||
Future<void> _initializeApp() async {
|
Future<void> _initializeApp() async {
|
||||||
// Set up error handlers before anything else
|
// Initialize Sentry first to capture any initialization errors
|
||||||
|
// Sentry wraps the app runner to capture errors during startup
|
||||||
|
await SentryService.init(
|
||||||
|
appRunner: () async {
|
||||||
|
// Set up error handlers
|
||||||
_setupErrorHandlers();
|
_setupErrorHandlers();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize core dependencies in parallel for faster startup
|
// Initialize core dependencies in parallel for faster startup
|
||||||
await Future.wait([_initializeHive(), _initializeSharedPreferences()]);
|
await Future.wait([_initializeHive(), _initializeSharedPreferences()]);
|
||||||
|
|
||||||
// Initialize OneSignal with verbose logging for debugging
|
// Initialize OneSignal push notifications
|
||||||
OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
|
await OneSignalService.init();
|
||||||
|
|
||||||
// Initialize with your OneSignal App ID
|
|
||||||
OneSignal.initialize("778ca22d-c719-4ec8-86cb-a6b911166066");
|
|
||||||
|
|
||||||
debugPrint('🔔 OneSignal initialized');
|
|
||||||
|
|
||||||
// Add observer BEFORE requesting permission to catch the subscription event
|
|
||||||
OneSignal.User.pushSubscription.addObserver((state) {
|
|
||||||
debugPrint('🔔 Push subscription state changed:');
|
|
||||||
debugPrint(' Previous - optedIn: ${state.previous.optedIn}, id: ${state.previous.id}');
|
|
||||||
debugPrint(' Current - optedIn: ${state.current.optedIn}, id: ${state.current.id}');
|
|
||||||
debugPrint(' Subscription ID: ${state.current.id}');
|
|
||||||
debugPrint(' Push Token: ${state.current.token}');
|
|
||||||
|
|
||||||
// Save subscription info when available
|
|
||||||
if (state.current.id != null) {
|
|
||||||
debugPrint('🔔 ✅ Device successfully subscribed!');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add notification permission observer
|
|
||||||
OneSignal.Notifications.addPermissionObserver((isGranted) {
|
|
||||||
debugPrint('🔔 Notification permission changed: $isGranted');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Request permission - TRUE to show the native permission prompt
|
|
||||||
final accepted = await OneSignal.Notifications.requestPermission(true);
|
|
||||||
debugPrint('🔔 Permission request result: $accepted');
|
|
||||||
|
|
||||||
// Give OneSignal SDK time to complete initialization and server registration
|
|
||||||
// This is necessary because the subscription happens asynchronously
|
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
// Check current subscription status after initialization completes
|
|
||||||
final optedIn = OneSignal.User.pushSubscription.optedIn;
|
|
||||||
final id = OneSignal.User.pushSubscription.id;
|
|
||||||
final token = OneSignal.User.pushSubscription.token;
|
|
||||||
|
|
||||||
debugPrint('🔔 Current subscription status (after delay):');
|
|
||||||
debugPrint(' Opted In: $optedIn');
|
|
||||||
debugPrint(' Subscription ID: $id');
|
|
||||||
debugPrint(' Push Token: $token');
|
|
||||||
|
|
||||||
if (id == null) {
|
|
||||||
debugPrint('🔔 ⚠️ Subscription ID is still null - check device connectivity and OneSignal app ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the app with Riverpod ProviderScope
|
// Run the app with Riverpod ProviderScope
|
||||||
runApp(const ProviderScope(child: WorkerApp()));
|
runApp(const ProviderScope(child: WorkerApp()));
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
// Critical initialization error - show error screen
|
// Critical initialization error - capture and show error screen
|
||||||
debugPrint('Failed to initialize app: $error');
|
debugPrint('Failed to initialize app: $error');
|
||||||
debugPrint('StackTrace: $stackTrace');
|
debugPrint('StackTrace: $stackTrace');
|
||||||
|
|
||||||
|
// Report to Sentry
|
||||||
|
await SentryService.captureException(error, stackTrace: stackTrace);
|
||||||
|
|
||||||
// Run minimal error app
|
// Run minimal error app
|
||||||
runApp(_buildErrorApp(error, stackTrace));
|
runApp(_buildErrorApp(error, stackTrace));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize Hive database
|
/// Initialize Hive database
|
||||||
@@ -164,7 +127,8 @@ Future<void> _initializeSharedPreferences() async {
|
|||||||
|
|
||||||
/// Set up global error handlers
|
/// Set up global error handlers
|
||||||
///
|
///
|
||||||
/// Captures and logs all Flutter framework errors and uncaught exceptions
|
/// Captures and logs all Flutter framework errors and uncaught exceptions.
|
||||||
|
/// Reports errors to Sentry for crash analytics.
|
||||||
void _setupErrorHandlers() {
|
void _setupErrorHandlers() {
|
||||||
// Handle Flutter framework errors
|
// Handle Flutter framework errors
|
||||||
FlutterError.onError = (FlutterErrorDetails details) {
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
@@ -176,8 +140,11 @@ void _setupErrorHandlers() {
|
|||||||
debugPrint('StackTrace: ${details.stack}');
|
debugPrint('StackTrace: ${details.stack}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, you would send to crash analytics service
|
// Report to Sentry
|
||||||
// Example: FirebaseCrashlytics.instance.recordFlutterError(details);
|
SentryService.captureException(
|
||||||
|
details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle errors outside of Flutter framework
|
// Handle errors outside of Flutter framework
|
||||||
@@ -187,27 +154,11 @@ void _setupErrorHandlers() {
|
|||||||
debugPrint('StackTrace: $stackTrace');
|
debugPrint('StackTrace: $stackTrace');
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, you would send to crash analytics service
|
// Report to Sentry
|
||||||
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
|
SentryService.captureException(error, stackTrace: stackTrace);
|
||||||
|
|
||||||
return true; // Return true to indicate error was handled
|
return true; // Return true to indicate error was handled
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle zone errors (async errors not caught by Flutter)
|
|
||||||
runZonedGuarded(
|
|
||||||
() {
|
|
||||||
// App will run in this zone
|
|
||||||
},
|
|
||||||
(error, stackTrace) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('Zone Error: $error');
|
|
||||||
debugPrint('StackTrace: $stackTrace');
|
|
||||||
}
|
|
||||||
|
|
||||||
// In production, you would send to crash analytics service
|
|
||||||
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build minimal error app when initialization fails
|
/// Build minimal error app when initialization fails
|
||||||
|
|||||||
48
pubspec.lock
48
pubspec.lock
@@ -892,6 +892,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2+1"
|
version: "0.2.2+1"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.2"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1044,6 +1052,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: "64e35e1e2e79da4e83f2ace3bf4e5437cef523f46c7db2eba9a1419c49573790"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1132,6 +1148,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1324,6 +1356,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
|
sentry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sentry
|
||||||
|
sha256: "10a0bc25f5f21468e3beeae44e561825aaa02cdc6829438e73b9b64658ff88d9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.8.0"
|
||||||
|
sentry_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sentry_flutter
|
||||||
|
sha256: aafbf41c63c98a30b17bdbf3313424d5102db62b08735c44bff810f277e786a5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.8.0"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.1+26
|
version: 1.0.1+29
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -73,6 +73,7 @@ dependencies:
|
|||||||
|
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
intl: ^0.20.0
|
intl: ^0.20.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
@@ -86,6 +87,9 @@ dependencies:
|
|||||||
|
|
||||||
onesignal_flutter: ^5.3.4
|
onesignal_flutter: ^5.3.4
|
||||||
|
|
||||||
|
# Error Tracking
|
||||||
|
sentry_flutter: ^9.8.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^14.6.2
|
go_router: ^14.6.2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user