diff --git a/android/app/google-services.json b/android/app/google-services.json
index f50b47d..70f010c 100644
--- a/android/app/google-services.json
+++ b/android/app/google-services.json
@@ -192,6 +192,42 @@
]
}
}
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:218052201497:android:a5188541e76de06f766e88",
+ "android_client_info": {
+ "package_name": "com.fleetmap.wuizy"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "218052201497-73igrj6hlp8jkrso1sim49q4kodp61m0.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyBVLNLhmrau9rbXv-e9yKC8qftQrR1MJ9Q"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "218052201497-73igrj6hlp8jkrso1sim49q4kodp61m0.apps.googleusercontent.com",
+ "client_type": 3
+ },
+ {
+ "client_id": "218052201497-27fuda922siafb8uql7m1rirk9jrhtu3.apps.googleusercontent.com",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "com.fleetmap.manager"
+ }
+ }
+ ]
+ }
+ }
}
],
"configuration_version": "1"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 9b60cb3..4cdcb62 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
@@ -36,6 +37,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/grey_user_icon.svg b/assets/grey_user_icon.svg
new file mode 100644
index 0000000..c7b3ef9
--- /dev/null
+++ b/assets/grey_user_icon.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/assets/red_user_icon.svg b/assets/red_user_icon.svg
new file mode 100644
index 0000000..52935f3
--- /dev/null
+++ b/assets/red_user_icon.svg
@@ -0,0 +1,33 @@
+
+
diff --git a/ios/Podfile b/ios/Podfile
new file mode 100644
index 0000000..620e46e
--- /dev/null
+++ b/ios/Podfile
@@ -0,0 +1,43 @@
+# Uncomment this line to define a global platform for your project
+# platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ end
+end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
new file mode 100644
index 0000000..cb45782
--- /dev/null
+++ b/ios/Podfile.lock
@@ -0,0 +1,202 @@
+PODS:
+ - Firebase/CoreOnly (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - Firebase/Crashlytics (12.4.0):
+ - Firebase/CoreOnly
+ - FirebaseCrashlytics (~> 12.4.0)
+ - Firebase/Messaging (12.4.0):
+ - Firebase/CoreOnly
+ - FirebaseMessaging (~> 12.4.0)
+ - firebase_core (4.2.1):
+ - Firebase/CoreOnly (= 12.4.0)
+ - Flutter
+ - firebase_crashlytics (5.0.5):
+ - Firebase/Crashlytics (= 12.4.0)
+ - firebase_core
+ - Flutter
+ - firebase_messaging (16.0.4):
+ - Firebase/Messaging (= 12.4.0)
+ - firebase_core
+ - Flutter
+ - FirebaseCore (12.4.0):
+ - FirebaseCoreInternal (~> 12.4.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/Logger (~> 8.1)
+ - FirebaseCoreExtension (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - FirebaseCoreInternal (12.4.0):
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - FirebaseCrashlytics (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - FirebaseInstallations (~> 12.4.0)
+ - FirebaseRemoteConfigInterop (~> 12.4.0)
+ - FirebaseSessions (~> 12.4.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/Environment (~> 8.1)
+ - nanopb (~> 3.30910.0)
+ - PromisesObjC (~> 2.4)
+ - FirebaseInstallations (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
+ - PromisesObjC (~> 2.4)
+ - FirebaseMessaging (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - FirebaseInstallations (~> 12.4.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/Reachability (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
+ - nanopb (~> 3.30910.0)
+ - FirebaseRemoteConfigInterop (12.4.0)
+ - FirebaseSessions (12.4.0):
+ - FirebaseCore (~> 12.4.0)
+ - FirebaseCoreExtension (~> 12.4.0)
+ - FirebaseInstallations (~> 12.4.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
+ - nanopb (~> 3.30910.0)
+ - PromisesSwift (~> 2.1)
+ - Flutter (1.0.0)
+ - flutter_local_notifications (0.0.1):
+ - Flutter
+ - GoogleDataTransport (10.1.0):
+ - nanopb (~> 3.30910.0)
+ - PromisesObjC (~> 2.4)
+ - GoogleUtilities/AppDelegateSwizzler (8.1.0):
+ - GoogleUtilities/Environment
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Network
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Environment (8.1.0):
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Logger (8.1.0):
+ - GoogleUtilities/Environment
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Network (8.1.0):
+ - GoogleUtilities/Logger
+ - "GoogleUtilities/NSData+zlib"
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Reachability
+ - "GoogleUtilities/NSData+zlib (8.1.0)":
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/Privacy (8.1.0)
+ - GoogleUtilities/Reachability (8.1.0):
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Privacy
+ - GoogleUtilities/UserDefaults (8.1.0):
+ - GoogleUtilities/Logger
+ - GoogleUtilities/Privacy
+ - MapLibre (6.19.1)
+ - maplibre_gl (0.24.1):
+ - Flutter
+ - MapLibre (= 6.19.1)
+ - nanopb (3.30910.0):
+ - nanopb/decode (= 3.30910.0)
+ - nanopb/encode (= 3.30910.0)
+ - nanopb/decode (3.30910.0)
+ - nanopb/encode (3.30910.0)
+ - package_info_plus (0.4.5):
+ - Flutter
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - PromisesObjC (2.4.0)
+ - PromisesSwift (2.4.0):
+ - PromisesObjC (= 2.4.0)
+ - share_plus (0.0.1):
+ - Flutter
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - url_launcher_ios (0.0.1):
+ - Flutter
+
+DEPENDENCIES:
+ - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
+ - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
+ - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
+ - Flutter (from `Flutter`)
+ - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
+ - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
+ - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
+ - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+ - share_plus (from `.symlinks/plugins/share_plus/ios`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+
+SPEC REPOS:
+ trunk:
+ - Firebase
+ - FirebaseCore
+ - FirebaseCoreExtension
+ - FirebaseCoreInternal
+ - FirebaseCrashlytics
+ - FirebaseInstallations
+ - FirebaseMessaging
+ - FirebaseRemoteConfigInterop
+ - FirebaseSessions
+ - GoogleDataTransport
+ - GoogleUtilities
+ - MapLibre
+ - nanopb
+ - PromisesObjC
+ - PromisesSwift
+
+EXTERNAL SOURCES:
+ firebase_core:
+ :path: ".symlinks/plugins/firebase_core/ios"
+ firebase_crashlytics:
+ :path: ".symlinks/plugins/firebase_crashlytics/ios"
+ firebase_messaging:
+ :path: ".symlinks/plugins/firebase_messaging/ios"
+ Flutter:
+ :path: Flutter
+ flutter_local_notifications:
+ :path: ".symlinks/plugins/flutter_local_notifications/ios"
+ maplibre_gl:
+ :path: ".symlinks/plugins/maplibre_gl/ios"
+ package_info_plus:
+ :path: ".symlinks/plugins/package_info_plus/ios"
+ path_provider_foundation:
+ :path: ".symlinks/plugins/path_provider_foundation/darwin"
+ share_plus:
+ :path: ".symlinks/plugins/share_plus/ios"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ url_launcher_ios:
+ :path: ".symlinks/plugins/url_launcher_ios/ios"
+
+SPEC CHECKSUMS:
+ Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
+ firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
+ firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081
+ firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
+ FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
+ FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
+ FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6
+ FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395
+ FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2
+ FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5
+ FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766
+ FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
+ GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
+ GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
+ MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952
+ maplibre_gl: d83126f1b19adee5e1071c453b421efd5fc99883
+ nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
+ package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
+ path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+ PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
+ PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
+ share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+ shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+
+PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+
+COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 5bdfcad..55c8177 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -43,6 +43,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 0CA5E2332EF0A9D000CD0965 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
14501B43A07ED6D585D5115D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
@@ -146,6 +147,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
+ 0CA5E2332EF0A9D000CD0965 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
@@ -201,6 +203,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
A1EB2AD03057DF25EF1FCA9D /* [CP] Embed Pods Frameworks */,
+ B83303F3076ABB8BB851F36E /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -321,6 +324,23 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
+ B83303F3076ABB8BB851F36E /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
EB885BCB868030A6B2AE8E9B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -473,6 +493,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 57X9MD32BX;
ENABLE_BITCODE = NO;
@@ -656,6 +677,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 57X9MD32BX;
ENABLE_BITCODE = NO;
@@ -679,6 +701,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 57X9MD32BX;
ENABLE_BITCODE = NO;
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..903def2
--- /dev/null
+++ b/ios/Runner/Runner.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ development
+
+
diff --git a/lib/main.dart b/lib/main.dart
index 16e0f41..a305a4d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -9,6 +9,7 @@ import 'firebase_options.dart';
import 'pages/login_page.dart';
import 'pages/main_page.dart';
import 'services/auth_service.dart';
+import 'services/notification_service.dart';
import 'theme.dart';
Future main() async {
@@ -18,6 +19,11 @@ Future main() async {
options: DefaultFirebaseOptions.currentPlatform,
);
+ // Initialize notifications
+ if (!kIsWeb) {
+ await NotificationService().initialize();
+ }
+
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
@@ -25,6 +31,11 @@ Future main() async {
return true;
};
+ // Initialize notifications
+ if (!kIsWeb) {
+ await NotificationService().initialize();
+ }
+
runApp(const App());
}
diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart
index 2cf1471..e33d520 100644
--- a/lib/services/api_service.dart
+++ b/lib/services/api_service.dart
@@ -56,10 +56,12 @@ class ApiService {
}
Future> fetchPositions() async {
- return _fetchList(
+ final positions = await _fetchList(
endpoint: '/api/positions',
fromJson: Position.fromJson
);
+ // Filter out invalid positions
+ return positions.where((position) => position.valid).toList();
}
Future> fetchGeofences() async {
@@ -103,10 +105,12 @@ class ApiService {
}) async {
final fromParam = from.toUtc().toIso8601String();
final toParam = to.toUtc().toIso8601String();
- return _fetchList(
+ final positions = await _fetchList(
endpoint: '/api/reports/route?deviceId=$deviceId&from=$fromParam&to=$toParam',
fromJson: Position.fromJson
);
+ // Filter out invalid positions
+ return positions.where((position) => position.valid).toList();
}
Future> fetchTrips({
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
new file mode 100644
index 0000000..c9bc45f
--- /dev/null
+++ b/lib/services/notification_service.dart
@@ -0,0 +1,136 @@
+import 'dart:developer' as dev;
+import 'package:firebase_messaging/firebase_messaging.dart';
+import 'package:flutter/foundation.dart' show kIsWeb;
+
+/// Top-level function to handle background messages
+@pragma('vm:entry-point')
+Future firebaseMessagingBackgroundHandler(RemoteMessage message) async {
+ dev.log('Background message received: ${message.messageId}', name: 'FCM');
+}
+
+class NotificationService {
+ static final NotificationService _instance = NotificationService._internal();
+ factory NotificationService() => _instance;
+ NotificationService._internal();
+
+ final FirebaseMessaging _fcm = FirebaseMessaging.instance;
+ String? _fcmToken;
+
+ String? get fcmToken => _fcmToken;
+
+ /// Initialize Firebase Cloud Messaging
+ Future initialize() async {
+ if (kIsWeb) {
+ dev.log('Notifications not supported on web', name: 'FCM');
+ return;
+ }
+
+ try {
+ // Request permission for iOS
+ final settings = await _fcm.requestPermission(
+ alert: true,
+ announcement: false,
+ badge: true,
+ carPlay: false,
+ criticalAlert: false,
+ provisional: false,
+ sound: true,
+ );
+
+ if (settings.authorizationStatus == AuthorizationStatus.authorized) {
+ dev.log('User granted notification permission', name: 'FCM');
+ } else if (settings.authorizationStatus == AuthorizationStatus.provisional) {
+ dev.log('User granted provisional notification permission', name: 'FCM');
+ } else {
+ dev.log('User declined notification permission', name: 'FCM');
+ return;
+ }
+
+ // Get FCM token
+ _fcmToken = await _fcm.getToken();
+ if (_fcmToken != null) {
+ dev.log('FCM Token: $_fcmToken', name: 'FCM');
+ // TODO: Send token to backend to register device
+ }
+
+ // Listen to token refresh
+ _fcm.onTokenRefresh.listen((newToken) {
+ _fcmToken = newToken;
+ dev.log('FCM Token refreshed: $newToken', name: 'FCM');
+ // TODO: Send new token to backend
+ });
+
+ // Handle foreground messages
+ FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
+
+ // Handle notification taps when app is in background
+ FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
+
+ // Check if app was opened from a notification
+ final initialMessage = await _fcm.getInitialMessage();
+ if (initialMessage != null) {
+ _handleNotificationTap(initialMessage);
+ }
+
+ // Register background message handler
+ FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
+
+ dev.log('Notification service initialized', name: 'FCM');
+ } catch (e, stack) {
+ dev.log('Error initializing notifications', name: 'FCM', error: e, stackTrace: stack);
+ }
+ }
+
+ /// Handle messages received while app is in foreground
+ void _handleForegroundMessage(RemoteMessage message) {
+ dev.log('Foreground message received: ${message.messageId}', name: 'FCM');
+ dev.log('Title: ${message.notification?.title}', name: 'FCM');
+ dev.log('Body: ${message.notification?.body}', name: 'FCM');
+ dev.log('Data: ${message.data}', name: 'FCM');
+
+ // TODO: Show in-app notification or update UI
+ }
+
+ /// Handle notification tap (when user taps on notification)
+ void _handleNotificationTap(RemoteMessage message) {
+ dev.log('Notification tapped: ${message.messageId}', name: 'FCM');
+ dev.log('Data: ${message.data}', name: 'FCM');
+
+ // TODO: Navigate to relevant screen based on notification data
+ // For example, if notification contains deviceId, navigate to device details
+ }
+
+ /// Subscribe to a topic
+ Future subscribeToTopic(String topic) async {
+ if (kIsWeb) return;
+
+ try {
+ await _fcm.subscribeToTopic(topic);
+ dev.log('Subscribed to topic: $topic', name: 'FCM');
+ } catch (e) {
+ dev.log('Error subscribing to topic $topic', name: 'FCM', error: e);
+ }
+ }
+
+ /// Unsubscribe from a topic
+ Future unsubscribeFromTopic(String topic) async {
+ if (kIsWeb) return;
+
+ try {
+ await _fcm.unsubscribeFromTopic(topic);
+ dev.log('Unsubscribed from topic: $topic', name: 'FCM');
+ } catch (e) {
+ dev.log('Error unsubscribing from topic $topic', name: 'FCM', error: e);
+ }
+ }
+
+ /// Send FCM token to backend
+ Future registerTokenWithBackend() async {
+ if (_fcmToken == null) return;
+
+ // TODO: Implement API call to send token to backend
+ // Example:
+ // await ApiService().registerFcmToken(_fcmToken!);
+ dev.log('TODO: Send FCM token to backend: $_fcmToken', name: 'FCM');
+ }
+}
diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart
index 959d31f..bc407c0 100644
--- a/lib/utils/constants.dart
+++ b/lib/utils/constants.dart
@@ -1,5 +1,5 @@
const categoryIcons = [
- 'truck', 'car'
+ 'truck', 'car', 'person'
];
const colors = [
@@ -26,5 +26,5 @@ String get googleMapsClientId {
return '';
}
-const double selectedZoomLevel=15;
+const double selectedZoomLevel=14;
const int maxGeofences=100;
diff --git a/lib/widgets/device_route.dart b/lib/widgets/device_route.dart
index 309094f..47bc79f 100644
--- a/lib/widgets/device_route.dart
+++ b/lib/widgets/device_route.dart
@@ -159,6 +159,30 @@ class _DeviceRouteState extends State {
if (_positions.isEmpty) return items;
+ // For AirTags, just list different positions without trip/stop processing
+ if (widget.device.model?.toLowerCase() == 'airtag') {
+ // Filter to only include positions with different coordinates
+ final differentPositions = [];
+ for (int i = 0; i < _positions.length; i++) {
+ if (i == 0 ||
+ _positions[i].latitude != _positions[i - 1].latitude ||
+ _positions[i].longitude != _positions[i - 1].longitude) {
+ differentPositions.add(_positions[i]);
+ }
+ }
+
+ // Add filtered positions to items
+ for (int i = 0; i < differentPositions.length; i++) {
+ final isLastPosition = i == differentPositions.length - 1;
+ items.add(_PositionItem(
+ differentPositions[i],
+ isFirst: i == 0,
+ label: (!isLastPosition && i > 0) ? 'AirTag Location' : null,
+ ));
+ }
+ return items;
+ }
+
// Add first position
items.add(_PositionItem(_positions.first, isFirst: true));
@@ -675,6 +699,9 @@ class _PositionCard extends StatelessWidget {
} else if (label == 'Stop') {
icon = Icons.stop_circle;
iconColor = colors.error;
+ } else if (label == 'AirTag Location') {
+ icon = Icons.location_on;
+ iconColor = colors.primary;
} else if (isFirst) {
icon = Icons.flag;
iconColor = colors.tertiary;
diff --git a/lib/widgets/map/style_selector.dart b/lib/widgets/map/style_selector.dart
index 6dcac9d..ace5c91 100644
--- a/lib/widgets/map/style_selector.dart
+++ b/lib/widgets/map/style_selector.dart
@@ -8,6 +8,8 @@ class MapStyleSelector extends StatefulWidget {
final Function(int) onStyleSelected;
final bool geofencesLayer;
final Function() onLayerSelected;
+ final Function() onZoomIn;
+ final Function() onZoomOut;
const MapStyleSelector({
super.key,
@@ -16,6 +18,8 @@ class MapStyleSelector extends StatefulWidget {
required this.onStyleSelected,
required this.geofencesLayer,
required this.onLayerSelected,
+ required this.onZoomIn,
+ required this.onZoomOut,
});
@override
@@ -30,7 +34,7 @@ class _MapStyleSelectorState extends State {
final l10n = AppLocalizations.of(context)!;
return Positioned(
- top: 60,
+ top: 0,
right: 0,
child: SafeArea(
child: AnimatedSize(
@@ -65,6 +69,59 @@ class _MapStyleSelectorState extends State {
if (_menuExpanded) ...[
const Divider(height: 1),
+ Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ InkWell(
+ onTap: widget.onZoomIn,
+ borderRadius: BorderRadius.circular(20),
+ child: Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ ),
+ child: Icon(
+ Icons.add,
+ size: 20,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ InkWell(
+ onTap: widget.onZoomOut,
+ borderRadius: BorderRadius.circular(20),
+ child: Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ ),
+ child: Icon(
+ Icons.remove,
+ size: 20,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const Divider(height: 1),
+
...List.generate(MapStyles.configs.length, (index) {
final config = MapStyles.configs[index];
final isSelected = widget.selectedStyleIndex == index;
@@ -135,7 +192,7 @@ class _MapStyleSelectorState extends State {
],
),
),
- )
+ ),
],
],
),
diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart
index cc88c38..22a05ff 100644
--- a/lib/widgets/map_view.dart
+++ b/lib/widgets/map_view.dart
@@ -99,6 +99,22 @@ class _MapViewState extends State {
await mapController!.setLayerVisibility(MapStyles.geofenceLabelLayerId, _geofencesSelected);
}
+ Future _zoomIn() async {
+ if (mapController == null) return;
+ await mapController!.animateCamera(
+ CameraUpdate.zoomIn(),
+ duration: const Duration(milliseconds: 200),
+ );
+ }
+
+ Future _zoomOut() async {
+ if (mapController == null) return;
+ await mapController!.animateCamera(
+ CameraUpdate.zoomOut(),
+ duration: const Duration(milliseconds: 200),
+ );
+ }
+
@override
void didUpdateWidget(MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -253,6 +269,10 @@ class _MapViewState extends State {
icon = Icons.stop_circle;
iconColor = colors.error;
iconName = 'position-stop';
+ } else if (widget.positionLabel == 'AirTag Location') {
+ icon = Icons.location_on;
+ iconColor = colors.primary;
+ iconName = 'position-airtag-location';
} else {
// Day start/end positions
final isFirst = widget.isFirstPosition ?? true;
@@ -344,9 +364,13 @@ class _MapViewState extends State {
Future addImageFromAsset(String name, String assetName) async {
dev.log('adding $name, $assetName');
- final bytes = await rootBundle.load(assetName);
- final list = bytes.buffer.asUint8List();
- return mapController!.addImage(name, list);
+ try {
+ final bytes = await rootBundle.load(assetName);
+ final list = bytes.buffer.asUint8List();
+ return mapController!.addImage(name, list);
+ } catch (e) {
+ dev.log('$e');
+ }
}
Future addImageFromIcon(String name, IconData icon, Color color, {double size = 48}) async {
@@ -837,6 +861,8 @@ class _MapViewState extends State {
onStyleSelected: _applyStyle,
geofencesLayer: _geofencesSelected,
onLayerSelected: _layerSelected,
+ onZoomIn: _zoomIn,
+ onZoomOut: _zoomOut,
)
],
);
diff --git a/lib/widgets/profile_view.dart b/lib/widgets/profile_view.dart
index 732b4fc..81eca04 100644
--- a/lib/widgets/profile_view.dart
+++ b/lib/widgets/profile_view.dart
@@ -1,6 +1,10 @@
+import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:manager/l10n/app_localizations.dart';
+import 'package:package_info_plus/package_info_plus.dart';
import '../services/auth_service.dart';
+import '../services/notification_service.dart';
class ProfileView extends StatelessWidget {
final int deviceCount;
@@ -101,6 +105,21 @@ class ProfileView extends StatelessWidget {
),
),
+ // Show FCM Token Button (only on mobile)
+ if (!kIsWeb)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: ElevatedButton.icon(
+ onPressed: () => _showFcmToken(context),
+ icon: const Icon(Icons.notifications_active),
+ label: const Text('Show FCM Token'),
+ style: ElevatedButton.styleFrom(
+ minimumSize: const Size(double.infinity, 50),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+
// Logout Button
Padding(
padding: const EdgeInsets.all(16),
@@ -115,6 +134,28 @@ class ProfileView extends StatelessWidget {
),
),
),
+
+ // Version Information
+ FutureBuilder(
+ future: PackageInfo.fromPlatform(),
+ builder: (context, snapshot) {
+ if (snapshot.hasData) {
+ final info = snapshot.data!;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Center(
+ child: Text(
+ 'Version ${info.version} (${info.buildNumber})',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ }
+ return const SizedBox.shrink();
+ },
+ ),
],
),
);
@@ -122,6 +163,64 @@ class ProfileView extends StatelessWidget {
);
}
+ void _showFcmToken(BuildContext context) {
+ final token = NotificationService().fcmToken;
+
+ if (token == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('FCM token not available yet. Please try again in a moment.'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ return;
+ }
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('FCM Token'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Use this token to send test notifications from Firebase Console:',
+ style: TextStyle(fontSize: 12),
+ ),
+ const SizedBox(height: 12),
+ SelectableText(
+ token,
+ style: const TextStyle(
+ fontSize: 10,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: token));
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Token copied to clipboard!'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ Navigator.pop(context);
+ },
+ child: const Text('Copy'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ );
+ }
+
Future _handleLogout(BuildContext context, AuthService authService) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog(
@@ -176,4 +275,4 @@ class ProfileView extends StatelessWidget {
],
);
}
-}
\ No newline at end of file
+}
diff --git a/pubspec.lock b/pubspec.lock
index 62ebfde..ac39221 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
+ dbus:
+ dependency: transitive
+ description:
+ name: dbus
+ sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.11"
fake_async:
dependency: transitive
description:
@@ -169,6 +177,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.8.15"
+ firebase_messaging:
+ dependency: "direct main"
+ description:
+ name: firebase_messaging
+ sha256: "22086f857d2340f5d973776cfd542d3fb30cf98e1c643c3aa4a7520bb12745bb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "16.0.4"
+ firebase_messaging_platform_interface:
+ dependency: transitive
+ description:
+ name: firebase_messaging_platform_interface
+ sha256: a59920cbf2eb7c83d34a5f354331210ffec116b216dc72d864d8b8eb983ca398
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.7.4"
+ firebase_messaging_web:
+ dependency: transitive
+ description:
+ name: firebase_messaging_web
+ sha256: "1183e40e6fd2a279a628951cc3b639fcf5ffe7589902632db645011eb70ebefb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.0"
fixnum:
dependency: transitive
description:
@@ -198,6 +230,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
+ flutter_local_notifications:
+ dependency: "direct main"
+ description:
+ name: flutter_local_notifications
+ sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
+ url: "https://pub.dev"
+ source: hosted
+ version: "19.5.0"
+ flutter_local_notifications_linux:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_linux
+ sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_local_notifications_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_platform_interface
+ sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.1.0"
+ flutter_local_notifications_windows:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_windows
+ sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.3"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -357,6 +421,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.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:
dependency: transitive
description:
@@ -570,6 +650,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
+ timezone:
+ dependency: transitive
+ description:
+ name: timezone
+ sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.1"
typed_data:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index a16e368..1ffb7e6 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -2,7 +2,7 @@ name: manager
description: "Track Fleet"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
-version: 3.2.0+1
+version: 3.3.2+1
environment:
sdk: ^3.9.2
@@ -26,6 +26,10 @@ dependencies:
share_plus: ^12.0.1
firebase_core: ^4.2.1
firebase_crashlytics: ^5.0.5
+ firebase_messaging: ^16.0.4
+ flutter_local_notifications: ^19.0.1
+ package_info_plus: ^8.1.3
+ webview_flutter: ^4.13.0
dev_dependencies:
flutter_test:
diff --git a/scripts/_generate_icons.sh b/scripts/_generate_icons.sh
index 1da5dd3..da02826 100755
--- a/scripts/_generate_icons.sh
+++ b/scripts/_generate_icons.sh
@@ -101,3 +101,7 @@ for COLOR_SPEC in "${COLOR_ARRAY[@]}"; do
fi
echo " - ${OUTPUT_DIR}/${COLOR_NAME}/ (hex: $COLOR_HEX)"
done
+
+rsvg-convert -w 50 -h 50 assets/red_user_icon.svg > "${OUTPUT_DIR}/person_red_000.0.png"
+rsvg-convert -w 50 -h 50 assets/green_user_icon.svg > "${OUTPUT_DIR}/person_green_000.0.png"
+rsvg-convert -w 50 -h 50 assets/grey_user_icon.svg > "${OUTPUT_DIR}/person_grey_000.0.png"