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"