From b523877b5b859c71b34bf675010e8d46ecef1bcf Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:36:04 -0700 Subject: [PATCH 01/20] esri map testing --- .gitignore | 1 + android/app/build.gradle | 4 +- .../jniLibs/arm64-v8a/libarcgis_maps_ffi.so | 1 + .../main/jniLibs/arm64-v8a/libruntimecore.so | 1 + .../main/jniLibs/x86_64/libarcgis_maps_ffi.so | 1 + .../src/main/jniLibs/x86_64/libruntimecore.so | 1 + arcgis_maps_core | 1 + ios/Podfile | 7 +- ios/Podfile.lock | 213 +- ios/Runner.xcodeproj/project.pbxproj | 46 +- .../xcshareddata/xcschemes/Runner.xcscheme | 3 + lib/app_router.dart | 3 +- lib/main.dart | 7 + lib/ui/esrimap/esrimap.dart | 1815 +++++++++++++++++ lib/ui/navigator/bottom.dart | 10 +- pubspec.lock | 134 +- pubspec.yaml | 1 + 17 files changed, 2131 insertions(+), 118 deletions(-) create mode 120000 android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so create mode 120000 android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so create mode 120000 android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so create mode 120000 android/app/src/main/jniLibs/x86_64/libruntimecore.so create mode 120000 arcgis_maps_core create mode 100644 lib/ui/esrimap/esrimap.dart diff --git a/.gitignore b/.gitignore index 52b9332cd..051e9cde9 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ android/app/google-services.json !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +arcgis_maps_core/ diff --git a/android/app/build.gradle b/android/app/build.gradle index af8272248..7e970a7de 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,7 +15,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "edu.ucsd" - compileSdk = flutter.compileSdkVersion + compileSdk = 36 ndkVersion = "27.0.12077973" compileOptions { @@ -34,7 +34,7 @@ android { defaultConfig { applicationId = "edu.ucsd" - minSdkVersion = 23 + minSdkVersion = 28 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so b/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so new file mode 120000 index 000000000..1e21672b9 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so @@ -0,0 +1 @@ +../../../../../../arcgis_maps_core/android/arm64-v8a/libarcgis_maps_ffi.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so b/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so new file mode 120000 index 000000000..ccf507a89 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so @@ -0,0 +1 @@ +../../../../../../arcgis_maps_core/android/arm64-v8a/libruntimecore.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so b/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so new file mode 120000 index 000000000..0c4280a61 --- /dev/null +++ b/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so @@ -0,0 +1 @@ +../../../../../../arcgis_maps_core/android/x86_64/libarcgis_maps_ffi.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libruntimecore.so b/android/app/src/main/jniLibs/x86_64/libruntimecore.so new file mode 120000 index 000000000..f685544a6 --- /dev/null +++ b/android/app/src/main/jniLibs/x86_64/libruntimecore.so @@ -0,0 +1 @@ +../../../../../../arcgis_maps_core/android/x86_64/libruntimecore.so \ No newline at end of file diff --git a/arcgis_maps_core b/arcgis_maps_core new file mode 120000 index 000000000..18242e155 --- /dev/null +++ b/arcgis_maps_core @@ -0,0 +1 @@ +/Users/alessioyu/.pub-cache/hosted/pub.dev/arcgis_maps-200.7.0+4560/arcgis_maps_core \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index b18002d35..8f22b17e1 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '15.0' +platform :ios, '17.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -31,6 +31,9 @@ target 'Runner' do use_frameworks! use_modular_headers! + pod 'Runtimecore', :podspec => '../arcgis_maps_core/ios/Runtimecore.podspec' + pod 'arcgis_maps_ffi', :podspec => '../arcgis_maps_core/ios/arcgis_maps_ffi.podspec' + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end @@ -41,7 +44,7 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0' # Remove unused permissions here # More information: https://github.com/BaseflowIT/flutter-permission-handler/blob/develop/permission_handler/ios/Classes/PermissionHandlerEnums.h # e.g. 'PERMISSION_CAMERA=0' for to remove camera permission diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2bd3bf343..106768598 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,88 +1,92 @@ PODS: - app_links (0.0.1): - Flutter + - arcgis_maps (200.7.0.4560): + - arcgis_maps_ffi + - Flutter + - Runtimecore + - arcgis_maps_ffi (200.7.0.4560) - connectivity_plus (0.0.1): - Flutter - - FlutterMacOS - device_info_plus (0.0.1): - Flutter - - Firebase/CoreOnly (12.6.0): - - FirebaseCore (~> 12.6.0) - - Firebase/Crashlytics (12.6.0): + - Firebase/CoreOnly (12.9.0): + - FirebaseCore (~> 12.9.0) + - Firebase/Crashlytics (12.9.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.6.0) - - Firebase/Messaging (12.6.0): + - FirebaseCrashlytics (~> 12.9.0) + - Firebase/Messaging (12.9.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.6.0) - - firebase_analytics (12.1.0): + - FirebaseMessaging (~> 12.9.0) + - firebase_analytics (12.1.3): - firebase_core - - FirebaseAnalytics (= 12.6.0) + - FirebaseAnalytics (= 12.9.0) - Flutter - - firebase_core (4.3.0): - - Firebase/CoreOnly (= 12.6.0) + - firebase_core (4.5.0): + - Firebase/CoreOnly (= 12.9.0) - Flutter - - firebase_crashlytics (5.0.6): - - Firebase/Crashlytics (= 12.6.0) + - firebase_crashlytics (5.0.8): + - Firebase/Crashlytics (= 12.9.0) - firebase_core - Flutter - - firebase_messaging (16.1.0): - - Firebase/Messaging (= 12.6.0) + - firebase_messaging (16.1.2): + - Firebase/Messaging (= 12.9.0) - firebase_core - Flutter - - FirebaseAnalytics (12.6.0): - - FirebaseAnalytics/Default (= 12.6.0) - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseAnalytics (12.9.0): + - FirebaseAnalytics/Default (= 12.9.0) + - FirebaseCore (~> 12.9.0) + - FirebaseInstallations (~> 12.9.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) - - GoogleAppMeasurement/Default (= 12.6.0) + - FirebaseAnalytics/Default (12.9.0): + - FirebaseCore (~> 12.9.0) + - FirebaseInstallations (~> 12.9.0) + - GoogleAppMeasurement/Default (= 12.9.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (12.6.0): - - FirebaseCoreInternal (~> 12.6.0) + - FirebaseCore (12.9.0): + - FirebaseCoreInternal (~> 12.9.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreInternal (12.6.0): + - FirebaseCoreExtension (12.9.0): + - FirebaseCore (~> 12.9.0) + - FirebaseCoreInternal (12.9.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) - - FirebaseRemoteConfigInterop (~> 12.6.0) - - FirebaseSessions (~> 12.6.0) + - FirebaseCrashlytics (12.9.0): + - FirebaseCore (~> 12.9.0) + - FirebaseInstallations (~> 12.9.0) + - FirebaseRemoteConfigInterop (~> 12.9.0) + - FirebaseSessions (~> 12.9.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.6.0): - - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (12.9.0): + - FirebaseCore (~> 12.9.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseMessaging (12.9.0): + - FirebaseCore (~> 12.9.0) + - FirebaseInstallations (~> 12.9.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.6.0) - - FirebaseSessions (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreExtension (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (12.9.0) + - FirebaseSessions (12.9.0): + - FirebaseCore (~> 12.9.0) + - FirebaseCoreExtension (~> 12.9.0) + - FirebaseInstallations (~> 12.9.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) @@ -93,6 +97,8 @@ PODS: - Flutter - flutter_secure_storage (6.0.0): - Flutter + - flutter_web_auth_2 (3.0.0): + - Flutter - geolocator_apple (1.2.0): - Flutter - FlutterMacOS @@ -107,23 +113,23 @@ PODS: - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.6.0): + - GoogleAppMeasurement/Core (12.9.0): - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.6.0): + - GoogleAppMeasurement/Default (12.9.0): - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.6.0) - - GoogleAppMeasurement/IdentitySupport (= 12.6.0) + - GoogleAppMeasurement/Core (= 12.9.0) + - GoogleAppMeasurement/IdentitySupport (= 12.9.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.6.0): - - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (12.9.0): + - GoogleAppMeasurement/Core (= 12.9.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -162,6 +168,8 @@ PODS: - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - haptic_feedback (0.5.1): + - Flutter - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) @@ -169,17 +177,40 @@ PODS: - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) + - Runtimecore (200.7.0.4560) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqlite3 (3.52.0): + - sqlite3/common (= 3.52.0) + - sqlite3/common (3.52.0) + - sqlite3/dbstatvtab (3.52.0): + - sqlite3/common + - sqlite3/fts5 (3.52.0): + - sqlite3/common + - sqlite3/math (3.52.0): + - sqlite3/common + - sqlite3/perf-threadsafe (3.52.0): + - sqlite3/common + - sqlite3/rtree (3.52.0): + - sqlite3/common + - sqlite3/session (3.52.0): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.52.0) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -190,7 +221,9 @@ PODS: DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) + - arcgis_maps (from `.symlinks/plugins/arcgis_maps/ios`) + - arcgis_maps_ffi (from `../arcgis_maps_core/ios/arcgis_maps_ffi.podspec`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -199,12 +232,15 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) + - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - Runtimecore (from `../arcgis_maps_core/ios/Runtimecore.podspec`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - wifi_connection (from `.symlinks/plugins/wifi_connection/ios`) @@ -230,12 +266,17 @@ SPEC REPOS: - nanopb - PromisesObjC - PromisesSwift + - sqlite3 EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" + arcgis_maps: + :path: ".symlinks/plugins/arcgis_maps/ios" + arcgis_maps_ffi: + :podspec: "../arcgis_maps_core/ios/arcgis_maps_ffi.podspec" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/darwin" + :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" firebase_analytics: @@ -252,18 +293,24 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_web_auth_2: + :path: ".symlinks/plugins/flutter_web_auth_2/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" google_maps_flutter_ios: :path: ".symlinks/plugins/google_maps_flutter_ios/ios" + haptic_feedback: + :path: ".symlinks/plugins/haptic_feedback/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + Runtimecore: + :podspec: "../arcgis_maps_core/ios/Runtimecore.podspec" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: @@ -273,44 +320,50 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: c5161ac5ab5383ad046884568b4b91cb52df5d91 - connectivity_plus: b21496ab28d1324eb59885d888a4d83b98531f01 + arcgis_maps: 08507a3e132cdc12a7d7358183aa971d62647ee4 + arcgis_maps_ffi: b1418b0e72ad292ab9314100134da9f175de99f0 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 - firebase_analytics: 4f9cca09e65f6c2944a862c6dc86f6bed9fb769c - firebase_core: ba00a168e719694f38960502ceb560285603d073 - firebase_crashlytics: 13f4b77e9ce2a84b1f8ea07f293db5b6213ce1cf - firebase_messaging: bf0e29321927edc02a563c984dbfa5b063864b15 - FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 - FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 - FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f - FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e - FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e - FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad - FirebaseMessaging: a61bc42dcab3f7a346d94bbb54dab2c9435b18b2 - FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 - FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 + firebase_analytics: 6903a46192a92993abde88152a14ce1df1c0de2f + firebase_core: afac1aac13c931e0401c7e74ed1276112030efab + firebase_crashlytics: a316d8dddba772359d93dc38d303ed964579b7a6 + firebase_messaging: 7cb2727feb789751fc6936bcc8e08408970e2820 + FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 + FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 + FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1 + FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 + FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c + FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa + FirebaseMessaging: 7d6cdbff969127c4151c824fe432f0e301210f15 + FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d + FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96 google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264 GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee + GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + Runtimecore: 7a7850ba03155bcf66f96ff15834319b7865ed89 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d wifi_connection: a73e16eb2fe4e40ca32a6d69c8609130603bb62d -PODFILE CHECKSUM: c12be671b3fe135b062a73a5acc25fefbe51c40b +PODFILE CHECKSUM: 327a920f85ee4988ff213b8c0b4a413604d472f9 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 096808a0b..c6fd98ceb 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -176,7 +176,6 @@ TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = G789749RTK; LastSwiftMigration = 0910; ProvisioningStyle = Automatic; }; @@ -284,19 +283,25 @@ "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", "${BUILT_PRODUCTS_DIR}/PromisesSwift/Promises.framework", + "${BUILT_PRODUCTS_DIR}/app_links/app_links.framework", + "${BUILT_PRODUCTS_DIR}/arcgis_maps/arcgis_maps.framework", "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", + "${BUILT_PRODUCTS_DIR}/flutter_web_auth_2/flutter_web_auth_2.framework", "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", + "${BUILT_PRODUCTS_DIR}/haptic_feedback/haptic_feedback.framework", "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", - "${BUILT_PRODUCTS_DIR}/uni_links2/uni_links2.framework", + "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", + "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", - "${BUILT_PRODUCTS_DIR}/webview_flutter/webview_flutter.framework", + "${BUILT_PRODUCTS_DIR}/webview_flutter_wkwebview/webview_flutter_wkwebview.framework", "${BUILT_PRODUCTS_DIR}/wifi_connection/wifi_connection.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/Runtimecore/Runtimecore.framework/Runtimecore", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/arcgis_maps_ffi/arcgis_maps_ffi.framework/arcgis_maps_ffi", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( @@ -312,19 +317,25 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Promises.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/app_links.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/arcgis_maps.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_web_auth_2.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/haptic_feedback.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/uni_links2.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webview_flutter.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webview_flutter_wkwebview.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wifi_connection.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Runtimecore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/arcgis_maps_ffi.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -477,7 +488,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -490,8 +501,13 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.ucsd.ucsandiego; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -620,7 +636,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -633,9 +649,14 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.ucsd.ucsandiego; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -657,7 +678,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -670,8 +691,13 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.ucsd.ucsandiego; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2ba47ae65..2c31b4c90 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> @@ -34,11 +35,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/lib/app_router.dart b/lib/app_router.dart index 137062593..52418ccf7 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -35,6 +35,7 @@ import 'package:campus_mobile_experimental/ui/shuttle/add_shuttle_stops_view.dar import 'package:campus_mobile_experimental/ui/shuttle/manage_shuttle_view.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:campus_mobile_experimental/ui/esrimap/esrimap.dart'; class Router { static Route generateRoute(RouteSettings settings) { @@ -51,7 +52,7 @@ class Router { return Home(); }); case RoutePaths.MAP: - return MaterialPageRoute(builder: (_) => prefix0.Maps()); + return MaterialPageRoute(builder: (_) => EsriMap()); case RoutePaths.MAP_SEARCH: return MaterialPageRoute(builder: (_) => MapSearchView()); case RoutePaths.NOTIFICATIONS: diff --git a/lib/main.dart b/lib/main.dart index a7fc8234b..ece87217f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:google_maps_flutter_android/google_maps_flutter_android.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -38,6 +39,12 @@ void main() async { // dotenv loading await dotenv.load(isOptional: true); + // Initialize ArcGIS + final arcgisAgeKey = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; + if (arcgisAgeKey.isNotEmpty) { + ArcGISEnvironment.apiKey = arcgisAgeKey; + } + /// Enable crash analytics - https://firebase.flutter.dev/docs/crashlytics/usage#toggle-crashlytics-collection await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart new file mode 100644 index 000000000..6696836ce --- /dev/null +++ b/lib/ui/esrimap/esrimap.dart @@ -0,0 +1,1815 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:arcgis_maps/arcgis_maps.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// ----------------------------------------------------------------------------- +// Model +// ----------------------------------------------------------------------------- + +/// Unified search result model for both Buildings and POIs. +class MapSearchResult { + final String name; + final String subtitle; + final double latitude; + final double longitude; + final MapSearchSource source; + final String address; + final String description; + final String? websiteUrl; + + const MapSearchResult({ + required this.name, + required this.subtitle, + required this.latitude, + required this.longitude, + required this.source, + this.address = '', + this.description = '', + this.websiteUrl, + }); + + /// Serialize to JSON for SharedPreferences storage. + Map toJson() => { + 'name': name, + 'subtitle': subtitle, + 'latitude': latitude, + 'longitude': longitude, + 'source': source.index, + 'address': address, + 'description': description, + 'websiteUrl': websiteUrl, + }; + + /// Deserialize from JSON. + factory MapSearchResult.fromJson(Map json) { + return MapSearchResult( + name: json['name'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + source: MapSearchSource.values[json['source'] as int? ?? 0], + address: json['address'] as String? ?? '', + description: json['description'] as String? ?? '', + websiteUrl: json['websiteUrl'] as String?, + ); + } +} + +enum MapSearchSource { building, poi } + +// ----------------------------------------------------------------------------- +// Category definitions +// ----------------------------------------------------------------------------- + +class _SearchCategory { + final String label; + final IconData icon; + final String poiClassValue; // maps to POI "Class" field value + + const _SearchCategory({ + required this.label, + required this.icon, + required this.poiClassValue, + }); +} + +const _categories = [ + _SearchCategory( + label: 'Parking', + icon: Icons.local_parking, + poiClassValue: 'Parking', + ), + _SearchCategory( + label: 'Dining', + icon: Icons.restaurant, + poiClassValue: 'Dining and Beverage', + ), + _SearchCategory( + label: 'Recreation', + icon: Icons.fitness_center, + poiClassValue: 'Athletic Facilities', + ), + _SearchCategory( + label: 'Transit', + icon: Icons.directions_bus, + poiClassValue: 'Transit', + ), +]; + +// ----------------------------------------------------------------------------- +// Slide-over header delegate +// ----------------------------------------------------------------------------- + +class _SlideOverHeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + final double height; + final Color backgroundColor; + + _SlideOverHeaderDelegate({ + required this.child, + required this.height, + required this.backgroundColor, + }); + + @override + double get minExtent => height; + @override + double get maxExtent => height; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + height: height, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: shrinkOffset == 0 + ? const BorderRadius.vertical(top: Radius.circular(16)) + : null, + ), + child: child, + ); + } + + @override + bool shouldRebuild(covariant _SlideOverHeaderDelegate oldDelegate) { + return child != oldDelegate.child || + height != oldDelegate.height || + backgroundColor != oldDelegate.backgroundColor; + } +} + +// ----------------------------------------------------------------------------- +// Widget +// ----------------------------------------------------------------------------- + +class EsriMap extends StatefulWidget { + const EsriMap({Key? key}) : super(key: key); + + @override + State createState() => _EsriMapState(); +} + +class _EsriMapState extends State with AutomaticKeepAliveClientMixin { + final _mapViewController = ArcGISMapView.createController(); + late final ArcGISMap _map; + final _graphicsOverlay = GraphicsOverlay(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + + // Slide-over controllers + final _categorySheetController = DraggableScrollableController(); + final _detailSheetController = DraggableScrollableController(); + + // Service endpoints + static const _buildingsQueryUrl = + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'AdministrationServices/Buildings_Public/MapServer/0/query'; + static const _poiQueryUrl = + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Points_Of_Interest/FeatureServer/0/query'; + + // SharedPreferences key for recent searches + static const _recentSearchesKey = 'esri_map_recent_searches'; + static const _maxRecentSearches = 5; + + // Search state + List _searchResults = []; + List _allPoiClasses = []; // all distinct Class values from the server + List _matchingPoiClasses = []; // live-filtered subset while typing + bool _showResults = false; + bool _isSearching = false; + bool _showSuggestions = false; + + // Recent searches + List _recentSearches = []; + + // Detail slide-over state + MapSearchResult? _selectedResult; + MapSearchResult? _lastSelectedResult; + + // Results currently plotted as pins on the map (from category search) + List _mappedResults = []; + + // Full unfiltered result set for the active category (used by "See All") + List _allCategoryResults = []; + + // Whether to show the "See All" pill button + bool _showSeeAll = false; + + // Whether to show the full category list panel + bool _showCategoryList = false; + + // The active category (for display in the "See All" label) + _SearchCategory? _activeCategory; + + // Location display + final _locationDataSource = SystemLocationDataSource(); + bool _locationStarted = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _initMap(); + _loadRecentSearches(); + _fetchAllPoiClasses(); + // Listen for focus changes to show/hide suggestions + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + _categorySheetController.dispose(); + _detailSheetController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _initMap() { + final hillshadeLayer = ArcGISTiledLayer.withUri( + Uri.parse( + 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', + ), + ); + + final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( + Uri.parse( + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', + ), + ); + + final basemap = Basemap(); + basemap.baseLayers.add(hillshadeLayer); + basemap.baseLayers.add(campusVectorTileLayer); + + _map = ArcGISMap.withBasemap(basemap); + _map.initialViewpoint = Viewpoint.fromCenter( + ArcGISPoint( + x: -117.2340, + y: 32.8801, + spatialReference: SpatialReference.wgs84, + ), + scale: 24000, + ); + } + + void _onMapViewReady() { + _mapViewController.arcGISMap = _map; + _mapViewController.interactionOptions.rotateEnabled = false; + _mapViewController.graphicsOverlays.add(_graphicsOverlay); + + // Wire up location display — blue dot, no auto-pan on start + _mapViewController.locationDisplay.dataSource = _locationDataSource; + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + + _startLocationDisplay(); + } + + Future _startLocationDisplay() async { + try { + await _locationDataSource.start(); + setState(() => _locationStarted = true); + } on ArcGISException catch (e) { + debugPrint('Location error: ${e.message}'); + } catch (e) { + debugPrint('Location error: $e'); + } + } + + void _onFocusChanged() { + if (_focusNode.hasFocus && _searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Recent searches persistence + // --------------------------------------------------------------------------- + + Future _loadRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_recentSearchesKey); + if (jsonStr != null) { + try { + final list = jsonDecode(jsonStr) as List; + setState(() { + _recentSearches = list + .map((e) => + MapSearchResult.fromJson(e as Map)) + .toList(); + }); + } catch (_) { + // ignore corrupted data + } + } + } + + Future _saveRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = + jsonEncode(_recentSearches.map((r) => r.toJson()).toList()); + await prefs.setString(_recentSearchesKey, jsonStr); + } + + void _addToRecentSearches(MapSearchResult result) { + // Remove duplicate if exists (match by name + source) + _recentSearches.removeWhere( + (r) => r.name == result.name && r.source == result.source); + // Insert at front + _recentSearches.insert(0, result); + // Trim to max + if (_recentSearches.length > _maxRecentSearches) { + _recentSearches = _recentSearches.sublist(0, _maxRecentSearches); + } + _saveRecentSearches(); + } + + void _removeFromRecentSearches(int index) { + setState(() { + _recentSearches.removeAt(index); + }); + _saveRecentSearches(); + } + + // --------------------------------------------------------------------------- + // Query helpers + // --------------------------------------------------------------------------- + + String _escSql(String input) => input.replaceAll("'", "''"); + + /// Query the Buildings (AGE) MapServer + Future> _queryBuildings(String query) async { + await dotenv.load(fileName: ".env"); + final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; + final escaped = _escSql(query); + final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " + "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; + + final uri = Uri.parse(_buildingsQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '8', + 'f': 'json', + if (token.isNotEmpty) 'token': token, + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final name = + (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; + final alias = (attrs['BuildingAliases'] as String?) ?? ''; + final street = (attrs['StreetAddress'] as String?) ?? ''; + final city = (attrs['City'] as String?) ?? ''; + final zip = (attrs['Zipcode'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final addressParts = [ + if (street.isNotEmpty) street, + if (city.isNotEmpty) city, + if (zip.isNotEmpty) zip, + ]; + final fullAddress = addressParts.join(', '); + final subtitle = alias.isNotEmpty ? alias : 'Building'; + + return MapSearchResult( + name: name, + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.building, + address: fullAddress, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + /// Fetches all distinct POI Class values from the FeatureServer and caches them. + Future _fetchAllPoiClasses() async { + try { + final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { + 'where': '1=1', + 'outFields': 'Class', + 'returnDistinctValues': 'true', + 'orderByFields': 'Class', + 'returnGeometry': 'false', + 'resultRecordCount': '200', + 'f': 'json', + }); + final response = await http.get(uri); + if (response.statusCode != 200) return; + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + final classes = features + .map((f) => (f['attributes']['Class'] as String?) ?? '') + .where((c) => c.isNotEmpty) + .toList() + ..sort(); + setState(() => _allPoiClasses = classes); + print(_allPoiClasses); + } catch (e) { + debugPrint('Failed to fetch POI classes: $e'); + } + } + + /// Query the POIs (AGO) FeatureServer by text search. + Future> _queryPOIs(String query) async { + final escaped = _escSql(query); + final where = "UpdatedName LIKE '%$escaped%' " + "OR C3DName LIKE '%$escaped%' " + "OR C3DKeywords LIKE '%$escaped%'"; + return _executePOIQuery(where); + } + + /// Query POIs filtered by a specific Class value (for category taps). + Future> _queryPOIsByClass(String classValue) async { + final escaped = _escSql(classValue); + final where = "Class = '$escaped'"; + return _executePOIQuery(where, maxResults: 100); + } + + /// Shared POI query execution. + Future> _executePOIQuery( + String where, { + int maxResults = 8, + }) async { + final uri = Uri.parse(_poiQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '$maxResults', + 'f': 'json', + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final updatedName = (attrs['UpdatedName'] as String?) ?? ''; + final c3dName = (attrs['C3DName'] as String?) ?? ''; + final name = updatedName.isNotEmpty ? updatedName : c3dName; + final poiClass = (attrs['Class'] as String?) ?? ''; + final subclass = (attrs['Subclass'] as String?) ?? ''; + final description = (attrs['C3DDescription'] as String?) ?? ''; + final url = (attrs['URL'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final subtitle = + subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; + + return MapSearchResult( + name: name.isNotEmpty ? name : 'Unknown POI', + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.poi, + description: description, + websiteUrl: url.isNotEmpty ? url : null, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + // --------------------------------------------------------------------------- + // Search actions + // --------------------------------------------------------------------------- + + /// Text search: query both buildings and POIs in parallel. + Future _performSearch(String query) async { + if (query.trim().isEmpty) return; + + // Recompute category matches on submit + final q = query.toLowerCase(); + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _isSearching = true; + _showResults = true; + _showSuggestions = false; + _selectedResult = null; + _matchingPoiClasses = matched; + }); + + try { + final results = await Future.wait([ + _queryBuildings(query), + _queryPOIs(query), + ]); + final merged = [...results[0], ...results[1]]; + setState(() { + _searchResults = merged; + _isSearching = false; + }); + } catch (e) { + print('Search error: $e'); + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + + /// Returns the current visible map extent projected to WGS84. + Envelope? _getViewportEnvelopeWGS84() { + final vp = _mapViewController.getCurrentViewpoint( + ViewpointType.boundingGeometry, + ); + if (vp == null) return null; + final geom = vp.targetGeometry; + if (geom == null) return null; + final projected = GeometryEngine.project( + geom, + outputSpatialReference: SpatialReference.wgs84, + ); + return projected as Envelope?; + } + + /// Filters a result list to only those within the current map viewport. + /// Returns the full list unchanged if the envelope can't be determined. + List _filterToViewport(List results) { + final env = _getViewportEnvelopeWGS84(); + if (env == null) return results; + return results.where((r) { + return r.latitude >= env.yMin && + r.latitude <= env.yMax && + r.longitude >= env.xMin && + r.longitude <= env.xMax; + }).toList(); + } + + /// distance in meters between two WGS84 points + double _distanceMeters( + double lat1, double lng1, double lat2, double lng2) { + const r = 6371000.0; + final dLat = (lat2 - lat1) * math.pi / 180; + final dLng = (lng2 - lng1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + /// Returns the user's current WGS84 lat/lng, or null if unavailable. + (double lat, double lng)? _getUserLatLng() { + final pos = _mapViewController.locationDisplay.location?.position; + if (pos == null) return null; + final wgs = GeometryEngine.project( + pos, + outputSpatialReference: SpatialReference.wgs84, + ) as ArcGISPoint?; + if (wgs == null) return null; + return (wgs.y, wgs.x); + } + + /// Plot a list of results as pins on the map. + void _plotResultsOnMap(List results) { + _graphicsOverlay.graphics.clear(); + _mappedResults = results; + for (int i = 0; i < results.length; i++) { + final result = results[i]; + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.red, + size: 14, + ), + ); + // Store the list index so we can retrieve the result on tap + graphic.attributes['resultIndex'] = i; + _graphicsOverlay.graphics.add(graphic); + } + } + + /// Zoom the map to fit all mapped results, with padding. + void _zoomToResults(List results) { + if (results.isEmpty) return; + + if (results.length == 1) { + final r = results.first; + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter( + ArcGISPoint( + x: r.longitude, + y: r.latitude, + spatialReference: SpatialReference.wgs84, + ), + scale: 5000, + ), + ); + return; + } + + double minLat = results.map((r) => r.latitude).reduce((a, b) => a < b ? a : b); + double maxLat = results.map((r) => r.latitude).reduce((a, b) => a > b ? a : b); + double minLng = results.map((r) => r.longitude).reduce((a, b) => a < b ? a : b); + double maxLng = results.map((r) => r.longitude).reduce((a, b) => a > b ? a : b); + + const padding = 0.005; + final envelope = Envelope.fromXY( + xMin: minLng - padding, + yMin: minLat - padding, + xMax: maxLng + padding, + yMax: maxLat + padding, + spatialReference: SpatialReference.wgs84, + ); + _mapViewController.setViewpointAnimated( + Viewpoint.fromTargetExtent(envelope), + ); + } + + /// Category search: query POIs filtered by Class. + /// Category search: query POIs filtered by Class and plot all as map pins. + Future _performCategorySearch(_SearchCategory category) async { + setState(() { + _isSearching = true; + _showResults = false; + _showSuggestions = false; + _selectedResult = null; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = category; + _searchController.text = category.label; + }); + _focusNode.unfocus(); + + try { + final allResults = await _queryPOIsByClass(category.poiClassValue); + + // Always plot ALL pins — no zoom, no viewport filtering. + // The user pans/zooms freely to discover them. + _plotResultsOnMap(allResults); + + setState(() { + _allCategoryResults = allResults; + // Show "Zoom to all" pill whenever there are results + _showSeeAll = allResults.isNotEmpty; + _isSearching = false; + }); + } catch (e) { + print('Category search error: $e'); + setState(() { + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _isSearching = false; + }); + } + } + + /// Triggers a category-style search from a raw POI class string. + Future _performClassSearch(String classValue) async { + final category = _SearchCategory( + label: _labelForClass(classValue), + icon: _iconForClass(classValue), + poiClassValue: classValue, + ); + await _performCategorySearch(category); + } + + /// Plots all results for the active category + void _seeAllCategoryResults() { + if (_allCategoryResults.isEmpty) return; + _zoomToResults(_allCategoryResults); + setState(() => _showSeeAll = false); + } + + void _selectResult(MapSearchResult result) { + _graphicsOverlay.graphics.clear(); + + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: result.source == MapSearchSource.building + ? Colors.blue + : Colors.red, + size: 14, + ), + ); + _graphicsOverlay.graphics.add(graphic); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _showResults = false; + _showSuggestions = false; + _searchController.text = result.name; + _selectedResult = result; + _lastSelectedResult = result; + }); + _focusNode.unfocus(); + + // Save to recent searches + _addToRecentSearches(result); + } + + /// Called when the ArcGISMapView is tapped + /// Identifies any graphic at the tap location and shows its detail panel + Future _onMapTap(Offset screenPoint) async { + // Dismiss any open dropdowns first + if (_showSuggestions || _showResults) { + setState(() { + _showSuggestions = false; + _showResults = false; + }); + _focusNode.unfocus(); + return; + } + + // Only attempt identify when pins are on the map + if (_mappedResults.isEmpty) { + if (_selectedResult != null) _closeDetail(); + return; + } + + try { + final identifyResult = await _mapViewController.identifyGraphicsOverlay( + _graphicsOverlay, + screenPoint: screenPoint, + tolerance: 12.0, + maximumResults: 1, + ); + + if (identifyResult.graphics.isNotEmpty) { + final tappedGraphic = identifyResult.graphics.first; + final index = tappedGraphic.attributes['resultIndex'] as int?; + if (index != null && index >= 0 && index < _mappedResults.length) { + _selectResultFromPin(_mappedResults[index]); + } + } else { + // Tapped empty space — close detail if open + if (_selectedResult != null) _closeDetail(); + } + } catch (e) { + print('Identify error: $e'); + } + } + + /// Select a result from a map pin tap + /// keeps all existing category pins visible + void _selectResultFromPin(MapSearchResult result) { + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _selectedResult = result; + _lastSelectedResult = result; + _searchController.text = result.name; + _showResults = false; + _showSuggestions = false; + _showCategoryList = false; + }); + _focusNode.unfocus(); + _addToRecentSearches(result); + } + + void _closeDetail() { + setState(() { + _selectedResult = null; + // If a category search is active, reopen the list view + if (_allCategoryResults.isNotEmpty) { + _showCategoryList = true; + } + }); + } + + void _reopenDetail() { + if (_lastSelectedResult != null) { + setState(() { + _selectedResult = _lastSelectedResult; + }); + } + } + + void _recenterOnUser() { + if (!_locationStarted) { + _startLocationDisplay(); + return; + } + final loc = _mapViewController.locationDisplay.location; + if (loc == null) return; + + // Briefly snap to recenter mode, then release back to free pan + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.recenter; + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + } + }); + } + + void _clearSearch() { + _searchController.clear(); + _graphicsOverlay.graphics.clear(); + setState(() { + _searchResults = []; + _matchingPoiClasses = []; + _mappedResults = []; + _allCategoryResults = []; + _showResults = false; + _showSuggestions = false; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + _selectedResult = null; + _lastSelectedResult = null; + }); + } + + Future _launchDirections(MapSearchResult result) async { + final uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&destination=${result.latitude},${result.longitude}', + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + Future _launchWebsite(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + IconData _iconForResult(MapSearchResult result) { + return result.source == MapSearchSource.building + ? Icons.business + : Icons.place; + } + + /// Returns the icon for a POI class — uses the hardcoded category icon if one + /// exists, otherwise falls back to a generic place icon. + IconData _iconForClass(String classValue) { + return _categories + .firstWhere( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase(), + orElse: () => const _SearchCategory( + label: '', icon: Icons.place, poiClassValue: ''), + ) + .icon; + } + + /// Converts a raw class value like "Food and Beverage" into a display label. + /// Uses the hardcoded category label if available, otherwise returns as-is. + String _labelForClass(String classValue) { + final match = _categories.where( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase()); + return match.isNotEmpty ? match.first.label : classValue; + } + + // --------------------------------------------------------------------------- + // Suggestions panel (categories + recent searches) + // --------------------------------------------------------------------------- + + Widget _buildSuggestionsPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[850] : Colors.white; + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: bgColor, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category section header + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Suggested', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 12), + + // Category icons row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _categories + .map((cat) => _buildCategoryChip(context, cat)) + .toList(), + ), + + // Recent searches section + if (_recentSearches.isNotEmpty) ...[ + SizedBox(height: 16), + Divider(height: 1), + SizedBox(height: 8), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Recently viewed', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 4), + ...List.generate(_recentSearches.length, (index) { + final recent = _recentSearches[index]; + return ListTile( + dense: true, + leading: Icon( + Icons.history, + size: 20, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + title: Text( + recent.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: GestureDetector( + onTap: () => _removeFromRecentSearches(index), + child: Icon( + Icons.close, + size: 16, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + ), + onTap: () => _selectResult(recent), + ); + }), + ], + ], + ), + ), + ); + } + + Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _performCategorySearch(category), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + category.icon, + size: 24, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + SizedBox(height: 6), + Text( + category.label, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ); + } + + Widget _buildSeeAllButton(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final total = _allCategoryResults.length; + + return GestureDetector( + onTap: _seeAllCategoryResults, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: isDark ? Colors.grey[850] : Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.layers_outlined, + size: 16, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 6), + Text( + 'See all $total results', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Map interaction → collapse slideover + // --------------------------------------------------------------------------- + + void _onMapPointerDown(PointerDownEvent _) { + // Collapse whichever sheet is active to the minimum snap + if (_showCategoryList && _selectedResult == null) { + if (_categorySheetController.isAttached) { + _categorySheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + if (_selectedResult != null) { + if (_detailSheetController.isAttached) { + _detailSheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + } + + // --------------------------------------------------------------------------- + // Shared slide-over template + // --------------------------------------------------------------------------- + + /// Builds a consistent DraggableScrollableSheet with a pinned header. + /// [headerTitle] and [headerIcon] are shown in the header row. + /// [onClose] is called when the X button is tapped. + /// [trailing] is an optional widget shown before the close button (e.g. "Search here"). + /// [sliverBody] is the scrollable content below the header. + static const _slideOverHeaderHeight = 80.0; + + Widget _buildSlideOverContent({ + required BuildContext context, + required ScrollController scrollController, + required String headerTitle, + IconData? headerIcon, + required VoidCallback onClose, + Widget? trailing, + required List sliverBody, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[900]! : Colors.white; + + return Material( + elevation: 8, + color: bgColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SlideOverHeaderDelegate( + height: _slideOverHeaderHeight, + backgroundColor: bgColor, + child: Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.grey[700] : Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 8), + child: Row( + children: [ + if (headerIcon != null) ...[ + Icon( + headerIcon, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + headerTitle, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ), + if (trailing != null) trailing!, + GestureDetector( + onTap: onClose, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + isDark ? Colors.grey[800] : Colors.grey[200], + ), + child: Icon( + Icons.close, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + // Divider below header + SliverToBoxAdapter( + child: Divider(height: 1), + ), + ...sliverBody, + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // List view + // --------------------------------------------------------------------------- + Widget _buildCategoryListPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final label = _activeCategory?.label ?? 'Results'; + + final viewport = _filterToViewport(_allCategoryResults); + final userPos = _getUserLatLng(); + final results = List.from(viewport); + if (userPos != null) { + results.sort((a, b) => + _distanceMeters(userPos.$1, userPos.$2, a.latitude, a.longitude) + .compareTo( + _distanceMeters( + userPos.$1, userPos.$2, b.latitude, b.longitude))); + } + + // Content-aware sizing: estimate content height as fraction of screen + final screenHeight = MediaQuery.of(context).size.height; + // Each list tile ~56px + header ~80px + divider + final contentHeight = + _slideOverHeaderHeight + (results.length * 56.0).clamp(56.0, 600.0); + final contentFraction = (contentHeight / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.45 ? contentFraction : 0.45; + final maxSize = contentFraction < 0.80 ? contentFraction.clamp(0.45, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _categorySheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + final headerTitle = results.isEmpty + ? '$label - None in view' + : '$label - ${results.length} in view'; + + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: headerTitle, + headerIcon: _activeCategory?.icon ?? Icons.place, + onClose: () => setState(() => _showCategoryList = false), + trailing: TextButton.icon( + icon: Icon( + Icons.refresh, + size: 16, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + label: Text( + 'Search here', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + ), + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: () => setState(() {}), + ), + sliverBody: results.isEmpty + ? [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_off, + size: 32, + color: isDark + ? Colors.grey[600] + : Colors.grey[400]), + const SizedBox(height: 8), + Text( + 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: isDark + ? Colors.grey[500] + : Colors.grey[600], + ), + ), + ], + ), + ), + ), + ] + : [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final r = results[index]; + final dist = userPos != null + ? _distanceMeters(userPos.$1, userPos.$2, + r.latitude, r.longitude) + : null; + final distLabel = dist == null + ? null + : dist < 1000 + ? '${dist.round()} m away' + : '${(dist / 1000).toStringAsFixed(1)} km away'; + + return Column( + children: [ + ListTile( + splashColor: Colors.transparent, + leading: Icon( + _iconForResult(r), + size: 20, + color: isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + title: Text( + r.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + subtitle: distLabel != null + ? Text(distLabel, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + )) + : (r.subtitle.isNotEmpty + ? Text(r.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 12)) + : null), + dense: true, + onTap: () => _selectResultFromPin(r), + ), + if (index < results.length - 1) + const Divider(height: 1), + ], + ); + }, + childCount: results.length, + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Detail slide-over + // --------------------------------------------------------------------------- + + Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final detailText = result.source == MapSearchSource.building + ? result.address + : result.description; + final categoryLabel = result.source == MapSearchSource.building + ? 'Building' + : result.subtitle; + + // Content-aware sizing: estimate how tall the detail content is + final screenHeight = MediaQuery.of(context).size.height; + // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 + double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; + if (detailText.isNotEmpty) contentEst += 60; + final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.30 ? contentFraction : 0.30; + final maxSize = + contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _detailSheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: result.name, + headerIcon: _iconForResult(result), + onClose: _closeDetail, + sliverBody: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category label + Text( + categoryLabel, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + + // Detail text + if (detailText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + detailText, + style: TextStyle( + fontSize: 14, + color: + isDark ? Colors.grey[400] : Colors.grey[600], + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 16), + + // Action buttons + Row( + children: [ + if (result.websiteUrl != null) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _launchWebsite(result.websiteUrl!), + icon: const Icon(Icons.language, size: 18), + label: const Text('View website'), + style: OutlinedButton.styleFrom( + foregroundColor: isDark + ? const Color(0xFFFFCD00) + : null, + side: isDark + ? const BorderSide( + color: Color(0xFFFFCD00)) + : null, + padding: const EdgeInsets.symmetric( + vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: FilledButton.icon( + onPressed: () => _launchDirections(result), + icon: const Icon(Icons.directions, size: 18), + label: const Text('Get Directions'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + super.build(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + body: Stack( + children: [ + // Map — Listener detects pointer-down to collapse slideovers + Column( + children: [ + Expanded( + child: Listener( + onPointerDown: _onMapPointerDown, + child: ArcGISMapView( + controllerProvider: () => _mapViewController, + onMapViewReady: _onMapViewReady, + onTap: _onMapTap, + ), + ), + ), + ], + ), + + // Floating search bar + dropdown + Positioned( + top: 8, + left: 12, + right: 12, + child: Column( + children: [ + // Search bar + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Row( + children: [ + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + Icons.search, + color: isDark ? Colors.white70 : Colors.grey[600], + ), + ), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + textInputAction: TextInputAction.search, + onSubmitted: _performSearch, + onChanged: (text) { + if (text.isEmpty) { + setState(() { + _showResults = false; + _searchResults = []; + _matchingPoiClasses = []; + _showSuggestions = _focusNode.hasFocus; + }); + } else { + final q = text.toLowerCase(); + // Match against server-fetched classes + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + // Always include hardcoded category class values as a fallback + // so chips appear even if the server fetch is still in flight + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _showSuggestions = false; + _matchingPoiClasses = matched; + }); + } + }, + onTap: () { + // Close detail panel when user taps search bar + if (_selectedResult != null) { + setState(() { + _selectedResult = null; + }); + } + // Show suggestions if text is empty + if (_searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + }, + style: TextStyle(fontSize: 16), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + hintText: 'Search buildings, places...', + ), + ), + ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear), + onPressed: _clearSearch, + ), + ], + ), + ), + + SizedBox(height: 4), + + // Suggestions panel (categories + recents) + if (_showSuggestions && !_showResults) + _buildSuggestionsPanel(context), + + // Search results dropdown + if (_showResults || (_matchingPoiClasses.isNotEmpty && _searchController.text.isNotEmpty && !_showSuggestions)) + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Category matches (always at top) ────────────────────────── + if (_matchingPoiClasses.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Row( + children: [ + Icon(Icons.category_outlined, + size: 13, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + const SizedBox(width: 6), + Text( + 'CATEGORIES', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: isDark ? Colors.grey[500] : Colors.grey[500], + ), + ), + ], + ), + ), + ..._matchingPoiClasses.map( + (classValue) => ListTile( + leading: Icon( + _iconForClass(classValue), + size: 20, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + title: Text(_labelForClass(classValue)), + subtitle: Text( + classValue, // show raw class value as subtitle so user knows what they're getting + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + ), + dense: true, + onTap: () => _performClassSearch(classValue), + ), + ), + if (_showResults && (_isSearching || _searchResults.isNotEmpty)) + const Divider(height: 1), + ], + + // ── POI / building results ───────────────────────────────────── + if (_showResults) + _isSearching + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : _searchResults.isEmpty + // Only show "no results" if there are also no category matches + ? (_matchingPoiClasses.isEmpty + ? const Padding( + padding: EdgeInsets.all(16), + child: Text('No results found'), + ) + : const SizedBox.shrink()) + : ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _searchResults.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final result = _searchResults[index]; + return ListTile( + splashColor: Colors.transparent, + leading: Icon(_iconForResult(result)), + title: Text(result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: result.subtitle.isNotEmpty + ? Text(result.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis) + : null, + dense: true, + onTap: () => _selectResult(result), + ); + }, + ), + ), + + ], + ), + ), + ], + ), + ), + + + // "See All" pill — shown when category results are filtered to viewport + if (_showSeeAll && _selectedResult == null) + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: _buildSeeAllButton(context), + ), + ), + // Bottom-right FAB cluster: list view button + info button + if (_selectedResult == null) + Positioned( + right: 16, + bottom: 32, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // List view button — only when a category search is active + if (_allCategoryResults.isNotEmpty) ...[ + FloatingActionButton.small( + heroTag: 'listBtn', + onPressed: () { + setState(() { + _showCategoryList = !_showCategoryList; + }); + }, + backgroundColor: _showCategoryList + ? Theme.of(context).colorScheme.primary + : (isDark ? Colors.grey[800] : null), + foregroundColor: _showCategoryList + ? Colors.white + : (isDark ? Colors.white : null), + child: Icon( + _showCategoryList ? Icons.map_outlined : Icons.list, + ), + ), + const SizedBox(height: 10), + ], + if (_lastSelectedResult != null) + FloatingActionButton.small( + heroTag: 'infoBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _reopenDetail, + child: const Icon(Icons.info_outline), + ), + if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'locateBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _recenterOnUser, + child: const Icon(Icons.my_location), + ), + ], + ), + ), + + // Category list panel — shown when list button is toggled + if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) + _buildCategoryListPanel(context), + + // Detail slide-over + if (_selectedResult != null) + _buildDetailSlideOver(context, _selectedResult!), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/navigator/bottom.dart b/lib/ui/navigator/bottom.dart index b176481e4..c0229ac4e 100644 --- a/lib/ui/navigator/bottom.dart +++ b/lib/ui/navigator/bottom.dart @@ -4,7 +4,8 @@ import 'package:campus_mobile_experimental/core/providers/bottom_nav.dart'; import 'package:campus_mobile_experimental/core/wrappers/push_notifications.dart'; import 'package:campus_mobile_experimental/ui/ai_assistant/ai_assistant.dart'; import 'package:campus_mobile_experimental/ui/home/home.dart'; -import 'package:campus_mobile_experimental/ui/map/map.dart' as prefix0; +// import 'package:campus_mobile_experimental/ui/map/map.dart' as prefix0; +import 'package:campus_mobile_experimental/ui/esrimap/esrimap.dart'; import 'package:campus_mobile_experimental/ui/navigator/top.dart'; import 'package:campus_mobile_experimental/ui/notifications/notifications_list_view.dart'; import 'package:campus_mobile_experimental/ui/profile/profile.dart'; @@ -32,7 +33,7 @@ class _BottomTabBarState extends State { var currentTab = [ Home(), - prefix0.Maps(), + EsriMap(), AIAssistantTab(), NotificationsListView(), Profile(), @@ -48,14 +49,15 @@ class _BottomTabBarState extends State { drawerScrimColor: Colors.transparent, backgroundColor: provider.currentIndex == 0 ? lightPrimaryColor : theme.scaffoldBackgroundColor, appBar: isAssistantTab - ? null - : PreferredSize(preferredSize: Size.fromHeight(57), child: Provider.of(context).appBar), + ? null + : PreferredSize(preferredSize: Size.fromHeight(50), child: Provider.of(context).appBar), body: PushNotificationWrapper( child: IndexedStack( index: provider.currentIndex, children: currentTab, ), ), + bottomNavigationBar: Container( decoration: BoxDecoration( color: theme.bottomNavigationBarTheme.backgroundColor, diff --git a/pubspec.lock b/pubspec.lock index d536f8129..2027b9fa2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.1" + arcgis_maps: + dependency: "direct main" + description: + name: arcgis_maps + sha256: "6f8d34e8dc484b297fe70e729fa774df8b8cf85191933ddc63d1398e63e7be7a" + url: "https://pub.dev" + source: hosted + version: "200.7.0+4560" args: dependency: transitive description: @@ -149,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -241,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" device_info_plus: dependency: "direct main" description: @@ -265,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.9.0" + dio_cache_interceptor: + dependency: transitive + description: + name: dio_cache_interceptor + sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + dio_cache_interceptor_db_store: + dependency: transitive + description: + name: dio_cache_interceptor_db_store + sha256: "2c17538d7c00f62ee74941e497b31187d91d256b63507106ad79f8fcd797a775" + url: "https://pub.dev" + source: hosted + version: "6.0.0" dio_web_adapter: dependency: transitive description: @@ -281,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + drift: + dependency: transitive + description: + name: drift + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + url: "https://pub.dev" + source: hosted + version: "2.31.0" encrypt: dependency: "direct main" description: @@ -548,6 +588,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -689,6 +745,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + haptic_feedback: + dependency: transitive + description: + name: haptic_feedback + sha256: "3422bf2a55c541c9e3f029197b9a94d262d2dda19191a022eb310e5dd922e9e0" + url: "https://pub.dev" + source: hosted + version: "0.5.1+2" hive: dependency: "direct main" description: @@ -781,26 +845,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" linkify: dependency: transitive description: @@ -817,6 +881,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -837,18 +909,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" measured_size: dependency: "direct main" description: @@ -861,10 +933,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1214,6 +1286,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" stack_trace: dependency: transitive description: @@ -1258,10 +1346,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.9" timezone: dependency: transitive description: @@ -1394,10 +1482,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" visibility_detector: dependency: "direct main" description: @@ -1503,6 +1591,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" xdg_directories: dependency: transitive description: @@ -1528,5 +1624,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 42dfdde32..22606facc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: url: https://github.com/UCSD/wifi_connection.git ref: e0fb416f81daedeabb1eedef22041e67f30f792b device_info_plus: ^11.5.0 + arcgis_maps: ^200.7.0+4560 dependency_overrides: google_maps_flutter_ios: 2.15.7 value_layout_builder: ^0.5.0 # Fix Flutter 3.32.0 compatibility From 98d604b48ca753a51000b3de23c56e06109dc909 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:28:19 -0700 Subject: [PATCH 02/20] esri map with nav and poi search --- lib/ui/esrimap/esrimap.dart | 1015 +++++++++++++-- lib/ui/esrimap/esrimap_models.dart | 1815 +++++++++++++++++++++++++++ lib/ui/esrimap/esrimap_service.dart | 1815 +++++++++++++++++++++++++++ 3 files changed, 4512 insertions(+), 133 deletions(-) create mode 100644 lib/ui/esrimap/esrimap_models.dart create mode 100644 lib/ui/esrimap/esrimap_service.dart diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 6696836ce..f28fcbe3b 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -212,6 +212,23 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final _locationDataSource = SystemLocationDataSource(); bool _locationStarted = false; + // Routing + final _routeGraphicsOverlay = GraphicsOverlay(); + bool _isRouting = false; + bool _hasRoute = false; + bool _routeFailed = false; + String _travelMode = 'Walking'; + double _routeTravelTimeMinutes = 0; + List _routeManeuvers = []; + bool _showRouteFields = false; + final _fromController = TextEditingController(); + final _toController = TextEditingController(); + final _fromFocusNode = FocusNode(); + final _toFocusNode = FocusNode(); + String? _activeRouteField; + (double, double)? _fromLatLng; + MapSearchResult? _routeDestination; + @override bool get wantKeepAlive => true; @@ -223,6 +240,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _fetchAllPoiClasses(); // Listen for focus changes to show/hide suggestions _focusNode.addListener(_onFocusChanged); + _fromFocusNode.addListener(_onFromFocusChanged); + _toFocusNode.addListener(_onToFocusChanged); } @override @@ -230,6 +249,10 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _categorySheetController.dispose(); _detailSheetController.dispose(); _searchController.dispose(); + _fromController.dispose(); + _toController.dispose(); + _fromFocusNode.dispose(); + _toFocusNode.dispose(); _focusNode.dispose(); super.dispose(); } @@ -266,6 +289,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _mapViewController.arcGISMap = _map; _mapViewController.interactionOptions.rotateEnabled = false; _mapViewController.graphicsOverlays.add(_graphicsOverlay); + _mapViewController.graphicsOverlays.add(_routeGraphicsOverlay); // Wire up location display — blue dot, no auto-pan on start _mapViewController.locationDisplay.dataSource = _locationDataSource; @@ -295,6 +319,44 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } } + void _onFromFocusChanged() { + if (_fromFocusNode.hasFocus) { + setState(() { + _activeRouteField = 'from'; + if (_fromController.text.isEmpty) { + _showSuggestions = true; + _showResults = false; + } + }); + if (_detailSheetController.isAttached) { + _detailSheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + } + + void _onToFocusChanged() { + if (_toFocusNode.hasFocus) { + setState(() { + _activeRouteField = 'to'; + if (_toController.text.isEmpty) { + _showSuggestions = true; + _showResults = false; + } + }); + if (_detailSheetController.isAttached) { + _detailSheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + } + // --------------------------------------------------------------------------- // Recent searches persistence // --------------------------------------------------------------------------- @@ -677,6 +739,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _searchController.text = category.label; }); _focusNode.unfocus(); + _fromFocusNode.unfocus(); + _toFocusNode.unfocus(); try { final allResults = await _queryPOIsByClass(category.poiClassValue); @@ -720,6 +784,48 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _selectResult(MapSearchResult result) { + // Handle result selection within route fields + if (_showRouteFields && _activeRouteField != null) { + if (_activeRouteField == 'from') { + _fromController.text = result.name; + _fromLatLng = (result.latitude, result.longitude); + _graphicsOverlay.graphics.clear(); + setState(() { + _showResults = false; + _showSuggestions = false; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _fromFocusNode.unfocus(); + // Re-solve route if we already have a destination + if (_routeDestination != null) { + _solveRoute(_routeDestination!, originLatLng: _fromLatLng); + } + } else if (_activeRouteField == 'to') { + _toController.text = result.name; + _routeDestination = result; + _graphicsOverlay.graphics.clear(); + setState(() { + _selectedResult = result; + _lastSelectedResult = result; + _showResults = false; + _showSuggestions = false; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _toFocusNode.unfocus(); + _solveRoute(result, originLatLng: _fromLatLng); + } + _addToRecentSearches(result); + return; + } + _graphicsOverlay.graphics.clear(); final point = ArcGISPoint( @@ -802,6 +908,47 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { /// Select a result from a map pin tap /// keeps all existing category pins visible void _selectResultFromPin(MapSearchResult result) { + // In routing mode, treat pin tap based on active route field + if (_showRouteFields) { + if (_activeRouteField == 'from') { + _fromController.text = result.name; + _fromLatLng = (result.latitude, result.longitude); + _graphicsOverlay.graphics.clear(); + setState(() { + _showResults = false; + _showSuggestions = false; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _fromFocusNode.unfocus(); + if (_routeDestination != null) { + _solveRoute(_routeDestination!, originLatLng: _fromLatLng); + } + } else { + _toController.text = result.name; + _routeDestination = result; + _graphicsOverlay.graphics.clear(); + setState(() { + _selectedResult = result; + _lastSelectedResult = result; + _showResults = false; + _showSuggestions = false; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _toFocusNode.unfocus(); + _solveRoute(result, originLatLng: _fromLatLng); + } + _addToRecentSearches(result); + return; + } + final point = ArcGISPoint( x: result.longitude, y: result.latitude, @@ -827,8 +974,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { void _closeDetail() { setState(() { _selectedResult = null; - // If a category search is active, reopen the list view - if (_allCategoryResults.isNotEmpty) { + // If a category search is active and not in routing mode, reopen the list view + if (_allCategoryResults.isNotEmpty && + !_showRouteFields && !_hasRoute && !_routeFailed) { _showCategoryList = true; } }); @@ -863,8 +1011,17 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { void _clearSearch() { _searchController.clear(); + _fromController.clear(); + _toController.clear(); _graphicsOverlay.graphics.clear(); + _routeGraphicsOverlay.graphics.clear(); setState(() { + _hasRoute = false; + _routeFailed = false; + _travelMode = 'Walking'; + _routeTravelTimeMinutes = 0; + _routeManeuvers = []; + _showRouteFields = false; _searchResults = []; _matchingPoiClasses = []; _mappedResults = []; @@ -876,10 +1033,24 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _activeCategory = null; _selectedResult = null; _lastSelectedResult = null; + _activeRouteField = null; + _fromLatLng = null; + _routeDestination = null; }); } - Future _launchDirections(MapSearchResult result) async { + Future _launchWebsite(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + // --------------------------------------------------------------------------- + // Routing + // --------------------------------------------------------------------------- + +/* Future _launchDirections(MapSearchResult result) async { final uri = Uri.parse( 'https://www.google.com/maps/dir/?api=1' '&destination=${result.latitude},${result.longitude}', @@ -887,13 +1058,178 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } - } + } */ - Future _launchWebsite(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); + static const _routeServiceUrl = + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Wayfinding/Campus_Wayfinding_Network/NAServer/Route'; + + Future _solveRoute(MapSearchResult destination, {String? travelMode, (double, double)? originLatLng}) async { + // Use provided origin or fall back to GPS + final userLatLng = originLatLng ?? _getUserLatLng(); + if (userLatLng == null) { + setState(() => _isRouting = false); + return; } + + final mode = travelMode ?? _travelMode; + + setState(() { + _isRouting = true; + _routeFailed = false; + _travelMode = mode; + }); + + try { + await dotenv.load(fileName: ".env"); + final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; + + final routeTask = RouteTask.withUri(Uri.parse(_routeServiceUrl)); + routeTask.apiKey = token; + await routeTask.load(); + + final params = await routeTask.createDefaultParameters(); + params.returnDirections = true; + final taskInfo = routeTask.getRouteTaskInfo(); + final matchingMode = taskInfo.travelModes + .where((m) => m.name == mode) + .firstOrNull; + if (matchingMode != null) { + params.travelMode = matchingMode; + } + + final origin = Stop(ArcGISPoint( + x: userLatLng.$2, + y: userLatLng.$1, + spatialReference: SpatialReference.wgs84, + )); + final dest = Stop(ArcGISPoint( + x: destination.longitude, + y: destination.latitude, + spatialReference: SpatialReference.wgs84, + )); + params.setStops([origin, dest]); + + final result = await routeTask.solveRoute(params); + if (result.routes.isEmpty) { + setState(() { + _isRouting = false; + _routeFailed = true; + }); + return; + } + + final route = result.routes.first; + _routeTravelTimeMinutes = route.totalTime; + _routeManeuvers = route.directionManeuvers; + + final routeGeometry = route.routeGeometry; + if (routeGeometry == null) { + setState(() { + _isRouting = false; + _routeFailed = true; + }); + return; + } + + _routeGraphicsOverlay.graphics.clear(); + final routeGraphic = Graphic( + geometry: routeGeometry, + symbol: SimpleLineSymbol( + style: SimpleLineSymbolStyle.solid, + color: Colors.blue, + width: 4, + ), + ); + _routeGraphicsOverlay.graphics.add(routeGraphic); + + // End point marker + final whiteOutline = SimpleLineSymbol( + style: SimpleLineSymbolStyle.solid, + color: Colors.white, + width: 2, + ); + _routeGraphicsOverlay.graphics.add(Graphic( + geometry: ArcGISPoint( + x: destination.longitude, + y: destination.latitude, + spatialReference: SpatialReference.wgs84, + ), + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.red, + size: 12, + )..outline = whiteOutline, + )); + + // Start point marker (only when origin is not GPS) + if (originLatLng != null) { + _routeGraphicsOverlay.graphics.add(Graphic( + geometry: ArcGISPoint( + x: originLatLng.$2, + y: originLatLng.$1, + spatialReference: SpatialReference.wgs84, + ), + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.blue, + size: 12, + )..outline = whiteOutline, + )); + } + + // Zoom to the route extent with extra bottom padding so the line + // stays centred in the visible map area above the detail slide-over. + final extent = routeGeometry.extent; + final dx = extent.width * 0.2; + final dyTop = extent.height * 0.5; + final dyBottom = extent.height * 0.75; + final paddedExtent = Envelope.fromXY( + xMin: extent.xMin - dx, + yMin: extent.yMin - dyBottom, + xMax: extent.xMax + dx, + yMax: extent.yMax + dyTop, + spatialReference: extent.spatialReference, + ); + _mapViewController.setViewpointAnimated( + Viewpoint.fromTargetExtent(paddedExtent), + ); + + _graphicsOverlay.graphics.clear(); + _mappedResults = []; + _allCategoryResults = []; + + setState(() { + _isRouting = false; + _hasRoute = true; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + } catch (e) { + debugPrint('Route solve error: $e'); + setState(() { + _isRouting = false; + _routeFailed = true; + }); + } + } + + void _clearRoute() { + _routeGraphicsOverlay.graphics.clear(); + _fromController.clear(); + _toController.clear(); + setState(() { + _hasRoute = false; + _routeFailed = false; + _travelMode = 'Walking'; + _routeTravelTimeMinutes = 0; + _routeManeuvers = []; + _showRouteFields = false; + _activeRouteField = null; + _fromLatLng = null; + _routeDestination = null; + }); } IconData _iconForResult(MapSearchResult result) { @@ -1387,6 +1723,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { final isDark = Theme.of(context).brightness == Brightness.dark; + final isRouting = _showRouteFields || _hasRoute || _routeFailed; final detailText = result.source == MapSearchSource.building ? result.address @@ -1400,11 +1737,13 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; if (detailText.isNotEmpty) contentEst += 60; + if (_hasRoute && _routeManeuvers.isNotEmpty) contentEst += _routeManeuvers.length * 52.0; final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); final initialSize = - contentFraction < 0.30 ? contentFraction : 0.30; - final maxSize = - contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; + _hasRoute ? 0.35 : (contentFraction < 0.30 ? contentFraction : 0.30); + final maxSize = _hasRoute + ? 0.80 + : (contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80); // Snap sizes must be strictly increasing and within [min, max] final snaps = [0.15]; if (initialSize > 0.15 + 0.01) snaps.add(initialSize); @@ -1420,7 +1759,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { return _buildSlideOverContent( context: context, scrollController: scrollController, - headerTitle: result.name, + headerTitle: isRouting + ? 'Directions to ${result.name}' + : result.name, headerIcon: _iconForResult(result), onClose: _closeDetail, sliverBody: [ @@ -1430,17 +1771,18 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Category label - Text( - categoryLabel, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.grey[400] : Colors.grey[600], + // Category label (hidden in routing mode) + if (!isRouting) + Text( + categoryLabel, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), ), - ), - // Detail text - if (detailText.isNotEmpty) ...[ + // Detail text (hidden in routing mode) + if (!isRouting && detailText.isNotEmpty) ...[ const SizedBox(height: 8), Text( detailText, @@ -1453,56 +1795,272 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { overflow: TextOverflow.ellipsis, ), ], - const SizedBox(height: 16), + if (!isRouting) const SizedBox(height: 16), // Action buttons - Row( - children: [ - if (result.websiteUrl != null) ...[ - Expanded( - child: OutlinedButton.icon( - onPressed: () => - _launchWebsite(result.websiteUrl!), - icon: const Icon(Icons.language, size: 18), - label: const Text('View website'), - style: OutlinedButton.styleFrom( - foregroundColor: isDark - ? const Color(0xFFFFCD00) - : null, - side: isDark - ? const BorderSide( - color: Color(0xFFFFCD00)) - : null, + if (_routeFailed) ...[ + Text( + 'No route available from your current location.', + style: TextStyle( + fontSize: 14, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _launchWebsite( + 'https://www.google.com/maps/dir/?api=1' + '&destination=${_routeDestination?.latitude ?? result.latitude},${_routeDestination?.longitude ?? result.longitude}' + '&travelmode=walking' + '${_fromLatLng != null ? '&origin=${_fromLatLng!.$1},${_fromLatLng!.$2}' : ''}', + ), + icon: const Icon(Icons.map_outlined, size: 18), + label: const Text('Navigate in Google Maps'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ] else if (_hasRoute) ...[ + // Travel time + Row( + children: [ + Icon( + Icons.schedule, + size: 18, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + const SizedBox(width: 6), + Text( + _routeTravelTimeMinutes < 1 + ? '< 1 min' + : '${_routeTravelTimeMinutes.ceil()} min', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Travel mode toggle chips + Row( + children: [ + for (final mode in ['Walking', 'Accessible']) ...[ + if (mode != 'Walking') const SizedBox(width: 8), + GestureDetector( + onTap: _isRouting || mode == _travelMode + ? null + : () => _solveRoute(result, travelMode: mode, originLatLng: _fromLatLng), + child: Container( padding: const EdgeInsets.symmetric( - vertical: 12), + horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: mode == _travelMode + ? (isDark + ? Colors.white + : Colors.grey[900]) + : (isDark + ? Colors.grey[800] + : Colors.grey[100]), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + mode == 'Walking' + ? Icons.directions_walk + : Icons.accessible, + size: 16, + color: mode == _travelMode + ? (isDark + ? Colors.grey[900] + : Colors.white) + : (isDark + ? Colors.white70 + : Colors.grey[700]), + ), + const SizedBox(width: 6), + Text( + mode, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: mode == _travelMode + ? (isDark + ? Colors.grey[900] + : Colors.white) + : (isDark + ? Colors.white70 + : Colors.grey[700]), + ), + ), + ], + ), + ), + ), + ], + ], + ), + const SizedBox(height: 12), + + // Navigate in Google Maps button + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _launchWebsite( + 'https://www.google.com/maps/dir/?api=1' + '&destination=${_routeDestination?.latitude ?? result.latitude},${_routeDestination?.longitude ?? result.longitude}' + '&travelmode=walking' + '${_fromLatLng != null ? '&origin=${_fromLatLng!.$1},${_fromLatLng!.$2}' : ''}', + ), + icon: const Icon(Icons.map_outlined, size: 18), + label: const Text('Navigate in Google Maps'), + style: OutlinedButton.styleFrom( + foregroundColor: isDark + ? const Color(0xFFFFCD00) + : null, + side: isDark + ? const BorderSide( + color: Color(0xFFFFCD00)) + : null, + padding: + const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ] else + Row( + children: [ + if (result.websiteUrl != null) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _launchWebsite(result.websiteUrl!), + icon: const Icon(Icons.language, size: 18), + label: const Text('View website'), + style: OutlinedButton.styleFrom( + foregroundColor: isDark + ? const Color(0xFFFFCD00) + : null, + side: isDark + ? const BorderSide( + color: Color(0xFFFFCD00)) + : null, + padding: const EdgeInsets.symmetric( + vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: FilledButton.icon( + onPressed: _isRouting + ? null + : () { + final gps = _getUserLatLng(); + _fromController.text = + gps != null ? 'My Location' : ''; + _toController.text = result.name; + _routeDestination = result; + _graphicsOverlay.graphics.clear(); + setState(() { + _showRouteFields = true; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _solveRoute(result); + }, + icon: _isRouting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.directions, size: 18), + label: Text( + _isRouting ? 'Routing...' : 'Get Directions'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), - const SizedBox(width: 12), ], - Expanded( - child: FilledButton.icon( - onPressed: () => _launchDirections(result), - icon: const Icon(Icons.directions, size: 18), - label: const Text('Get Directions'), - style: FilledButton.styleFrom( - padding: - const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), + ), + ], + ), + ), + ), + // Direction maneuver steps (when route is active) + if (_hasRoute && _routeManeuvers.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final step = _routeManeuvers[index]; + final distMeters = step.length; + final distLabel = distMeters < 1000 + ? '${distMeters.round()} m' + : '${(distMeters / 1000).toStringAsFixed(1)} km'; + return Column( + children: [ + if (index == 0) const Divider(height: 1), + ListTile( + leading: Icon( + Icons.subdirectory_arrow_right, + size: 20, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + title: Text( + step.directionText, + style: const TextStyle(fontSize: 13), ), + trailing: distMeters > 0 + ? Text( + distLabel, + style: TextStyle( + fontSize: 12, + color: isDark + ? Colors.grey[500] + : Colors.grey[500], + ), + ) + : null, + dense: true, ), + if (index < _routeManeuvers.length - 1) + const Divider(height: 1), ], - ), - ], + ); + }, + childCount: _routeManeuvers.length, ), ), - ), ], ); }, @@ -1544,90 +2102,271 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { right: 12, child: Column( children: [ - // Search bar - Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: isDark ? Colors.grey[850] : Colors.white, - child: Row( - children: [ - Padding( - padding: EdgeInsets.only(left: 12), - child: Icon( - Icons.search, - color: isDark ? Colors.white70 : Colors.grey[600], - ), + // Search bar / route fields + if (_showRouteFields) + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left dot column + Padding( + padding: const EdgeInsets.only(left: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle_outlined, + size: 12, + color: Colors.blue), + Container( + width: 1.5, + height: 24, + color: isDark + ? Colors.grey[600] + : Colors.grey[300], + ), + Icon(Icons.circle, + size: 12, + color: Colors.red), + ], + ), + ), + // Text fields + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _fromController, + focusNode: _fromFocusNode, + style: const TextStyle(fontSize: 15), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: + EdgeInsets.symmetric( + horizontal: 10, + vertical: 10), + hintText: 'From', + isDense: true, + ), + onTap: () { + setState(() => + _activeRouteField = 'from'); + }, + onChanged: (text) { + _fromLatLng = null; + if (text.length >= 3) { + _performSearch(text); + } else if (text.isEmpty) { + setState(() { + _showResults = false; + _showSuggestions = true; + }); + } else { + setState(() { + _showResults = false; + _showSuggestions = false; + }); + } + }, + ), + ), + if (_fromController.text.isNotEmpty) + SizedBox( + width: 28, + height: 28, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.close, + size: 16, + color: isDark + ? Colors.white70 + : Colors.grey[600]), + onPressed: () { + _fromController.clear(); + _fromLatLng = null; + setState(() => + _showSuggestions = true); + }, + ), + ), + ], + ), + Divider(height: 1, indent: 10, endIndent: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _toController, + focusNode: _toFocusNode, + style: const TextStyle(fontSize: 15), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: + EdgeInsets.symmetric( + horizontal: 10, + vertical: 10), + hintText: 'To', + isDense: true, + ), + onTap: () { + setState(() => + _activeRouteField = 'to'); + }, + onChanged: (text) { + if (text.length >= 3) { + _performSearch(text); + } else if (text.isEmpty) { + setState(() { + _showResults = false; + _showSuggestions = true; + }); + } else { + setState(() { + _showResults = false; + _showSuggestions = false; + }); + } + }, + ), + ), + if (_toController.text.isNotEmpty) + SizedBox( + width: 28, + height: 28, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.close, + size: 16, + color: isDark + ? Colors.white70 + : Colors.grey[600]), + onPressed: () { + _toController.clear(); + setState(() => + _showSuggestions = true); + }, + ), + ), + ], + ), + ], + ), + ), + // Swap button + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.swap_vert, + size: 20, + color: isDark + ? Colors.white70 + : Colors.grey[600]), + onPressed: () { + final tmp = _fromController.text; + _fromController.text = _toController.text; + _toController.text = tmp; + }, + ), + ), + const SizedBox(width: 4), + ], ), - Expanded( - child: TextField( - controller: _searchController, - focusNode: _focusNode, - textInputAction: TextInputAction.search, - onSubmitted: _performSearch, - onChanged: (text) { - if (text.isEmpty) { - setState(() { - _showResults = false; - _searchResults = []; - _matchingPoiClasses = []; - _showSuggestions = _focusNode.hasFocus; - }); - } else { - final q = text.toLowerCase(); - // Match against server-fetched classes - final matched = _allPoiClasses - .where((c) => c.toLowerCase().contains(q)) - .toList(); - - // Always include hardcoded category class values as a fallback - // so chips appear even if the server fetch is still in flight - for (final cat in _categories) { - if (cat.poiClassValue.toLowerCase().contains(q) && - !matched.contains(cat.poiClassValue)) { - matched.insert(0, cat.poiClassValue); + ), + ) + else + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Row( + children: [ + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + Icons.search, + color: isDark ? Colors.white70 : Colors.grey[600], + ), + ), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + textInputAction: TextInputAction.search, + onSubmitted: _performSearch, + onChanged: (text) { + if (text.isEmpty) { + setState(() { + _showResults = false; + _searchResults = []; + _matchingPoiClasses = []; + _showSuggestions = _focusNode.hasFocus; + }); + } else { + final q = text.toLowerCase(); + // Match against server-fetched classes + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + // Always include hardcoded category class values as a fallback + // so chips appear even if the server fetch is still in flight + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } } - } - setState(() { - _showSuggestions = false; - _matchingPoiClasses = matched; - }); - } - }, - onTap: () { - // Close detail panel when user taps search bar - if (_selectedResult != null) { - setState(() { - _selectedResult = null; - }); - } - // Show suggestions if text is empty - if (_searchController.text.isEmpty) { - setState(() { - _showSuggestions = true; - _showResults = false; - }); - } - }, - style: TextStyle(fontSize: 16), - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 14, + setState(() { + _showSuggestions = false; + _matchingPoiClasses = matched; + }); + } + }, + onTap: () { + // Close detail panel when user taps search bar + if (_selectedResult != null) { + setState(() { + _selectedResult = null; + }); + } + // Show suggestions if text is empty + if (_searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + }, + style: TextStyle(fontSize: 16), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + hintText: 'Search buildings, places...', ), - hintText: 'Search buildings, places...', ), ), - ), - if (_searchController.text.isNotEmpty) - IconButton( - icon: Icon(Icons.clear), - onPressed: _clearSearch, - ), - ], + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear), + onPressed: _clearSearch, + ), + ], + ), ), - ), SizedBox(height: 4), @@ -1788,6 +2527,16 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { onPressed: _reopenDetail, child: const Icon(Icons.info_outline), ), + if (_showRouteFields || _hasRoute) ...[ + FloatingActionButton.small( + heroTag: 'clearRouteBtn', + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + onPressed: _clearRoute, + child: const Icon(Icons.close), + ), + const SizedBox(height: 10), + ], if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) const SizedBox(height: 10), FloatingActionButton.small( diff --git a/lib/ui/esrimap/esrimap_models.dart b/lib/ui/esrimap/esrimap_models.dart new file mode 100644 index 000000000..6696836ce --- /dev/null +++ b/lib/ui/esrimap/esrimap_models.dart @@ -0,0 +1,1815 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:arcgis_maps/arcgis_maps.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// ----------------------------------------------------------------------------- +// Model +// ----------------------------------------------------------------------------- + +/// Unified search result model for both Buildings and POIs. +class MapSearchResult { + final String name; + final String subtitle; + final double latitude; + final double longitude; + final MapSearchSource source; + final String address; + final String description; + final String? websiteUrl; + + const MapSearchResult({ + required this.name, + required this.subtitle, + required this.latitude, + required this.longitude, + required this.source, + this.address = '', + this.description = '', + this.websiteUrl, + }); + + /// Serialize to JSON for SharedPreferences storage. + Map toJson() => { + 'name': name, + 'subtitle': subtitle, + 'latitude': latitude, + 'longitude': longitude, + 'source': source.index, + 'address': address, + 'description': description, + 'websiteUrl': websiteUrl, + }; + + /// Deserialize from JSON. + factory MapSearchResult.fromJson(Map json) { + return MapSearchResult( + name: json['name'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + source: MapSearchSource.values[json['source'] as int? ?? 0], + address: json['address'] as String? ?? '', + description: json['description'] as String? ?? '', + websiteUrl: json['websiteUrl'] as String?, + ); + } +} + +enum MapSearchSource { building, poi } + +// ----------------------------------------------------------------------------- +// Category definitions +// ----------------------------------------------------------------------------- + +class _SearchCategory { + final String label; + final IconData icon; + final String poiClassValue; // maps to POI "Class" field value + + const _SearchCategory({ + required this.label, + required this.icon, + required this.poiClassValue, + }); +} + +const _categories = [ + _SearchCategory( + label: 'Parking', + icon: Icons.local_parking, + poiClassValue: 'Parking', + ), + _SearchCategory( + label: 'Dining', + icon: Icons.restaurant, + poiClassValue: 'Dining and Beverage', + ), + _SearchCategory( + label: 'Recreation', + icon: Icons.fitness_center, + poiClassValue: 'Athletic Facilities', + ), + _SearchCategory( + label: 'Transit', + icon: Icons.directions_bus, + poiClassValue: 'Transit', + ), +]; + +// ----------------------------------------------------------------------------- +// Slide-over header delegate +// ----------------------------------------------------------------------------- + +class _SlideOverHeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + final double height; + final Color backgroundColor; + + _SlideOverHeaderDelegate({ + required this.child, + required this.height, + required this.backgroundColor, + }); + + @override + double get minExtent => height; + @override + double get maxExtent => height; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + height: height, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: shrinkOffset == 0 + ? const BorderRadius.vertical(top: Radius.circular(16)) + : null, + ), + child: child, + ); + } + + @override + bool shouldRebuild(covariant _SlideOverHeaderDelegate oldDelegate) { + return child != oldDelegate.child || + height != oldDelegate.height || + backgroundColor != oldDelegate.backgroundColor; + } +} + +// ----------------------------------------------------------------------------- +// Widget +// ----------------------------------------------------------------------------- + +class EsriMap extends StatefulWidget { + const EsriMap({Key? key}) : super(key: key); + + @override + State createState() => _EsriMapState(); +} + +class _EsriMapState extends State with AutomaticKeepAliveClientMixin { + final _mapViewController = ArcGISMapView.createController(); + late final ArcGISMap _map; + final _graphicsOverlay = GraphicsOverlay(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + + // Slide-over controllers + final _categorySheetController = DraggableScrollableController(); + final _detailSheetController = DraggableScrollableController(); + + // Service endpoints + static const _buildingsQueryUrl = + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'AdministrationServices/Buildings_Public/MapServer/0/query'; + static const _poiQueryUrl = + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Points_Of_Interest/FeatureServer/0/query'; + + // SharedPreferences key for recent searches + static const _recentSearchesKey = 'esri_map_recent_searches'; + static const _maxRecentSearches = 5; + + // Search state + List _searchResults = []; + List _allPoiClasses = []; // all distinct Class values from the server + List _matchingPoiClasses = []; // live-filtered subset while typing + bool _showResults = false; + bool _isSearching = false; + bool _showSuggestions = false; + + // Recent searches + List _recentSearches = []; + + // Detail slide-over state + MapSearchResult? _selectedResult; + MapSearchResult? _lastSelectedResult; + + // Results currently plotted as pins on the map (from category search) + List _mappedResults = []; + + // Full unfiltered result set for the active category (used by "See All") + List _allCategoryResults = []; + + // Whether to show the "See All" pill button + bool _showSeeAll = false; + + // Whether to show the full category list panel + bool _showCategoryList = false; + + // The active category (for display in the "See All" label) + _SearchCategory? _activeCategory; + + // Location display + final _locationDataSource = SystemLocationDataSource(); + bool _locationStarted = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _initMap(); + _loadRecentSearches(); + _fetchAllPoiClasses(); + // Listen for focus changes to show/hide suggestions + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + _categorySheetController.dispose(); + _detailSheetController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _initMap() { + final hillshadeLayer = ArcGISTiledLayer.withUri( + Uri.parse( + 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', + ), + ); + + final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( + Uri.parse( + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', + ), + ); + + final basemap = Basemap(); + basemap.baseLayers.add(hillshadeLayer); + basemap.baseLayers.add(campusVectorTileLayer); + + _map = ArcGISMap.withBasemap(basemap); + _map.initialViewpoint = Viewpoint.fromCenter( + ArcGISPoint( + x: -117.2340, + y: 32.8801, + spatialReference: SpatialReference.wgs84, + ), + scale: 24000, + ); + } + + void _onMapViewReady() { + _mapViewController.arcGISMap = _map; + _mapViewController.interactionOptions.rotateEnabled = false; + _mapViewController.graphicsOverlays.add(_graphicsOverlay); + + // Wire up location display — blue dot, no auto-pan on start + _mapViewController.locationDisplay.dataSource = _locationDataSource; + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + + _startLocationDisplay(); + } + + Future _startLocationDisplay() async { + try { + await _locationDataSource.start(); + setState(() => _locationStarted = true); + } on ArcGISException catch (e) { + debugPrint('Location error: ${e.message}'); + } catch (e) { + debugPrint('Location error: $e'); + } + } + + void _onFocusChanged() { + if (_focusNode.hasFocus && _searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Recent searches persistence + // --------------------------------------------------------------------------- + + Future _loadRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_recentSearchesKey); + if (jsonStr != null) { + try { + final list = jsonDecode(jsonStr) as List; + setState(() { + _recentSearches = list + .map((e) => + MapSearchResult.fromJson(e as Map)) + .toList(); + }); + } catch (_) { + // ignore corrupted data + } + } + } + + Future _saveRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = + jsonEncode(_recentSearches.map((r) => r.toJson()).toList()); + await prefs.setString(_recentSearchesKey, jsonStr); + } + + void _addToRecentSearches(MapSearchResult result) { + // Remove duplicate if exists (match by name + source) + _recentSearches.removeWhere( + (r) => r.name == result.name && r.source == result.source); + // Insert at front + _recentSearches.insert(0, result); + // Trim to max + if (_recentSearches.length > _maxRecentSearches) { + _recentSearches = _recentSearches.sublist(0, _maxRecentSearches); + } + _saveRecentSearches(); + } + + void _removeFromRecentSearches(int index) { + setState(() { + _recentSearches.removeAt(index); + }); + _saveRecentSearches(); + } + + // --------------------------------------------------------------------------- + // Query helpers + // --------------------------------------------------------------------------- + + String _escSql(String input) => input.replaceAll("'", "''"); + + /// Query the Buildings (AGE) MapServer + Future> _queryBuildings(String query) async { + await dotenv.load(fileName: ".env"); + final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; + final escaped = _escSql(query); + final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " + "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; + + final uri = Uri.parse(_buildingsQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '8', + 'f': 'json', + if (token.isNotEmpty) 'token': token, + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final name = + (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; + final alias = (attrs['BuildingAliases'] as String?) ?? ''; + final street = (attrs['StreetAddress'] as String?) ?? ''; + final city = (attrs['City'] as String?) ?? ''; + final zip = (attrs['Zipcode'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final addressParts = [ + if (street.isNotEmpty) street, + if (city.isNotEmpty) city, + if (zip.isNotEmpty) zip, + ]; + final fullAddress = addressParts.join(', '); + final subtitle = alias.isNotEmpty ? alias : 'Building'; + + return MapSearchResult( + name: name, + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.building, + address: fullAddress, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + /// Fetches all distinct POI Class values from the FeatureServer and caches them. + Future _fetchAllPoiClasses() async { + try { + final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { + 'where': '1=1', + 'outFields': 'Class', + 'returnDistinctValues': 'true', + 'orderByFields': 'Class', + 'returnGeometry': 'false', + 'resultRecordCount': '200', + 'f': 'json', + }); + final response = await http.get(uri); + if (response.statusCode != 200) return; + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + final classes = features + .map((f) => (f['attributes']['Class'] as String?) ?? '') + .where((c) => c.isNotEmpty) + .toList() + ..sort(); + setState(() => _allPoiClasses = classes); + print(_allPoiClasses); + } catch (e) { + debugPrint('Failed to fetch POI classes: $e'); + } + } + + /// Query the POIs (AGO) FeatureServer by text search. + Future> _queryPOIs(String query) async { + final escaped = _escSql(query); + final where = "UpdatedName LIKE '%$escaped%' " + "OR C3DName LIKE '%$escaped%' " + "OR C3DKeywords LIKE '%$escaped%'"; + return _executePOIQuery(where); + } + + /// Query POIs filtered by a specific Class value (for category taps). + Future> _queryPOIsByClass(String classValue) async { + final escaped = _escSql(classValue); + final where = "Class = '$escaped'"; + return _executePOIQuery(where, maxResults: 100); + } + + /// Shared POI query execution. + Future> _executePOIQuery( + String where, { + int maxResults = 8, + }) async { + final uri = Uri.parse(_poiQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '$maxResults', + 'f': 'json', + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final updatedName = (attrs['UpdatedName'] as String?) ?? ''; + final c3dName = (attrs['C3DName'] as String?) ?? ''; + final name = updatedName.isNotEmpty ? updatedName : c3dName; + final poiClass = (attrs['Class'] as String?) ?? ''; + final subclass = (attrs['Subclass'] as String?) ?? ''; + final description = (attrs['C3DDescription'] as String?) ?? ''; + final url = (attrs['URL'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final subtitle = + subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; + + return MapSearchResult( + name: name.isNotEmpty ? name : 'Unknown POI', + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.poi, + description: description, + websiteUrl: url.isNotEmpty ? url : null, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + // --------------------------------------------------------------------------- + // Search actions + // --------------------------------------------------------------------------- + + /// Text search: query both buildings and POIs in parallel. + Future _performSearch(String query) async { + if (query.trim().isEmpty) return; + + // Recompute category matches on submit + final q = query.toLowerCase(); + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _isSearching = true; + _showResults = true; + _showSuggestions = false; + _selectedResult = null; + _matchingPoiClasses = matched; + }); + + try { + final results = await Future.wait([ + _queryBuildings(query), + _queryPOIs(query), + ]); + final merged = [...results[0], ...results[1]]; + setState(() { + _searchResults = merged; + _isSearching = false; + }); + } catch (e) { + print('Search error: $e'); + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + + /// Returns the current visible map extent projected to WGS84. + Envelope? _getViewportEnvelopeWGS84() { + final vp = _mapViewController.getCurrentViewpoint( + ViewpointType.boundingGeometry, + ); + if (vp == null) return null; + final geom = vp.targetGeometry; + if (geom == null) return null; + final projected = GeometryEngine.project( + geom, + outputSpatialReference: SpatialReference.wgs84, + ); + return projected as Envelope?; + } + + /// Filters a result list to only those within the current map viewport. + /// Returns the full list unchanged if the envelope can't be determined. + List _filterToViewport(List results) { + final env = _getViewportEnvelopeWGS84(); + if (env == null) return results; + return results.where((r) { + return r.latitude >= env.yMin && + r.latitude <= env.yMax && + r.longitude >= env.xMin && + r.longitude <= env.xMax; + }).toList(); + } + + /// distance in meters between two WGS84 points + double _distanceMeters( + double lat1, double lng1, double lat2, double lng2) { + const r = 6371000.0; + final dLat = (lat2 - lat1) * math.pi / 180; + final dLng = (lng2 - lng1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + /// Returns the user's current WGS84 lat/lng, or null if unavailable. + (double lat, double lng)? _getUserLatLng() { + final pos = _mapViewController.locationDisplay.location?.position; + if (pos == null) return null; + final wgs = GeometryEngine.project( + pos, + outputSpatialReference: SpatialReference.wgs84, + ) as ArcGISPoint?; + if (wgs == null) return null; + return (wgs.y, wgs.x); + } + + /// Plot a list of results as pins on the map. + void _plotResultsOnMap(List results) { + _graphicsOverlay.graphics.clear(); + _mappedResults = results; + for (int i = 0; i < results.length; i++) { + final result = results[i]; + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.red, + size: 14, + ), + ); + // Store the list index so we can retrieve the result on tap + graphic.attributes['resultIndex'] = i; + _graphicsOverlay.graphics.add(graphic); + } + } + + /// Zoom the map to fit all mapped results, with padding. + void _zoomToResults(List results) { + if (results.isEmpty) return; + + if (results.length == 1) { + final r = results.first; + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter( + ArcGISPoint( + x: r.longitude, + y: r.latitude, + spatialReference: SpatialReference.wgs84, + ), + scale: 5000, + ), + ); + return; + } + + double minLat = results.map((r) => r.latitude).reduce((a, b) => a < b ? a : b); + double maxLat = results.map((r) => r.latitude).reduce((a, b) => a > b ? a : b); + double minLng = results.map((r) => r.longitude).reduce((a, b) => a < b ? a : b); + double maxLng = results.map((r) => r.longitude).reduce((a, b) => a > b ? a : b); + + const padding = 0.005; + final envelope = Envelope.fromXY( + xMin: minLng - padding, + yMin: minLat - padding, + xMax: maxLng + padding, + yMax: maxLat + padding, + spatialReference: SpatialReference.wgs84, + ); + _mapViewController.setViewpointAnimated( + Viewpoint.fromTargetExtent(envelope), + ); + } + + /// Category search: query POIs filtered by Class. + /// Category search: query POIs filtered by Class and plot all as map pins. + Future _performCategorySearch(_SearchCategory category) async { + setState(() { + _isSearching = true; + _showResults = false; + _showSuggestions = false; + _selectedResult = null; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = category; + _searchController.text = category.label; + }); + _focusNode.unfocus(); + + try { + final allResults = await _queryPOIsByClass(category.poiClassValue); + + // Always plot ALL pins — no zoom, no viewport filtering. + // The user pans/zooms freely to discover them. + _plotResultsOnMap(allResults); + + setState(() { + _allCategoryResults = allResults; + // Show "Zoom to all" pill whenever there are results + _showSeeAll = allResults.isNotEmpty; + _isSearching = false; + }); + } catch (e) { + print('Category search error: $e'); + setState(() { + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _isSearching = false; + }); + } + } + + /// Triggers a category-style search from a raw POI class string. + Future _performClassSearch(String classValue) async { + final category = _SearchCategory( + label: _labelForClass(classValue), + icon: _iconForClass(classValue), + poiClassValue: classValue, + ); + await _performCategorySearch(category); + } + + /// Plots all results for the active category + void _seeAllCategoryResults() { + if (_allCategoryResults.isEmpty) return; + _zoomToResults(_allCategoryResults); + setState(() => _showSeeAll = false); + } + + void _selectResult(MapSearchResult result) { + _graphicsOverlay.graphics.clear(); + + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: result.source == MapSearchSource.building + ? Colors.blue + : Colors.red, + size: 14, + ), + ); + _graphicsOverlay.graphics.add(graphic); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _showResults = false; + _showSuggestions = false; + _searchController.text = result.name; + _selectedResult = result; + _lastSelectedResult = result; + }); + _focusNode.unfocus(); + + // Save to recent searches + _addToRecentSearches(result); + } + + /// Called when the ArcGISMapView is tapped + /// Identifies any graphic at the tap location and shows its detail panel + Future _onMapTap(Offset screenPoint) async { + // Dismiss any open dropdowns first + if (_showSuggestions || _showResults) { + setState(() { + _showSuggestions = false; + _showResults = false; + }); + _focusNode.unfocus(); + return; + } + + // Only attempt identify when pins are on the map + if (_mappedResults.isEmpty) { + if (_selectedResult != null) _closeDetail(); + return; + } + + try { + final identifyResult = await _mapViewController.identifyGraphicsOverlay( + _graphicsOverlay, + screenPoint: screenPoint, + tolerance: 12.0, + maximumResults: 1, + ); + + if (identifyResult.graphics.isNotEmpty) { + final tappedGraphic = identifyResult.graphics.first; + final index = tappedGraphic.attributes['resultIndex'] as int?; + if (index != null && index >= 0 && index < _mappedResults.length) { + _selectResultFromPin(_mappedResults[index]); + } + } else { + // Tapped empty space — close detail if open + if (_selectedResult != null) _closeDetail(); + } + } catch (e) { + print('Identify error: $e'); + } + } + + /// Select a result from a map pin tap + /// keeps all existing category pins visible + void _selectResultFromPin(MapSearchResult result) { + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _selectedResult = result; + _lastSelectedResult = result; + _searchController.text = result.name; + _showResults = false; + _showSuggestions = false; + _showCategoryList = false; + }); + _focusNode.unfocus(); + _addToRecentSearches(result); + } + + void _closeDetail() { + setState(() { + _selectedResult = null; + // If a category search is active, reopen the list view + if (_allCategoryResults.isNotEmpty) { + _showCategoryList = true; + } + }); + } + + void _reopenDetail() { + if (_lastSelectedResult != null) { + setState(() { + _selectedResult = _lastSelectedResult; + }); + } + } + + void _recenterOnUser() { + if (!_locationStarted) { + _startLocationDisplay(); + return; + } + final loc = _mapViewController.locationDisplay.location; + if (loc == null) return; + + // Briefly snap to recenter mode, then release back to free pan + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.recenter; + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + } + }); + } + + void _clearSearch() { + _searchController.clear(); + _graphicsOverlay.graphics.clear(); + setState(() { + _searchResults = []; + _matchingPoiClasses = []; + _mappedResults = []; + _allCategoryResults = []; + _showResults = false; + _showSuggestions = false; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + _selectedResult = null; + _lastSelectedResult = null; + }); + } + + Future _launchDirections(MapSearchResult result) async { + final uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&destination=${result.latitude},${result.longitude}', + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + Future _launchWebsite(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + IconData _iconForResult(MapSearchResult result) { + return result.source == MapSearchSource.building + ? Icons.business + : Icons.place; + } + + /// Returns the icon for a POI class — uses the hardcoded category icon if one + /// exists, otherwise falls back to a generic place icon. + IconData _iconForClass(String classValue) { + return _categories + .firstWhere( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase(), + orElse: () => const _SearchCategory( + label: '', icon: Icons.place, poiClassValue: ''), + ) + .icon; + } + + /// Converts a raw class value like "Food and Beverage" into a display label. + /// Uses the hardcoded category label if available, otherwise returns as-is. + String _labelForClass(String classValue) { + final match = _categories.where( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase()); + return match.isNotEmpty ? match.first.label : classValue; + } + + // --------------------------------------------------------------------------- + // Suggestions panel (categories + recent searches) + // --------------------------------------------------------------------------- + + Widget _buildSuggestionsPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[850] : Colors.white; + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: bgColor, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category section header + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Suggested', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 12), + + // Category icons row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _categories + .map((cat) => _buildCategoryChip(context, cat)) + .toList(), + ), + + // Recent searches section + if (_recentSearches.isNotEmpty) ...[ + SizedBox(height: 16), + Divider(height: 1), + SizedBox(height: 8), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Recently viewed', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 4), + ...List.generate(_recentSearches.length, (index) { + final recent = _recentSearches[index]; + return ListTile( + dense: true, + leading: Icon( + Icons.history, + size: 20, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + title: Text( + recent.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: GestureDetector( + onTap: () => _removeFromRecentSearches(index), + child: Icon( + Icons.close, + size: 16, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + ), + onTap: () => _selectResult(recent), + ); + }), + ], + ], + ), + ), + ); + } + + Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _performCategorySearch(category), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + category.icon, + size: 24, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + SizedBox(height: 6), + Text( + category.label, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ); + } + + Widget _buildSeeAllButton(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final total = _allCategoryResults.length; + + return GestureDetector( + onTap: _seeAllCategoryResults, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: isDark ? Colors.grey[850] : Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.layers_outlined, + size: 16, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 6), + Text( + 'See all $total results', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Map interaction → collapse slideover + // --------------------------------------------------------------------------- + + void _onMapPointerDown(PointerDownEvent _) { + // Collapse whichever sheet is active to the minimum snap + if (_showCategoryList && _selectedResult == null) { + if (_categorySheetController.isAttached) { + _categorySheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + if (_selectedResult != null) { + if (_detailSheetController.isAttached) { + _detailSheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + } + + // --------------------------------------------------------------------------- + // Shared slide-over template + // --------------------------------------------------------------------------- + + /// Builds a consistent DraggableScrollableSheet with a pinned header. + /// [headerTitle] and [headerIcon] are shown in the header row. + /// [onClose] is called when the X button is tapped. + /// [trailing] is an optional widget shown before the close button (e.g. "Search here"). + /// [sliverBody] is the scrollable content below the header. + static const _slideOverHeaderHeight = 80.0; + + Widget _buildSlideOverContent({ + required BuildContext context, + required ScrollController scrollController, + required String headerTitle, + IconData? headerIcon, + required VoidCallback onClose, + Widget? trailing, + required List sliverBody, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[900]! : Colors.white; + + return Material( + elevation: 8, + color: bgColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SlideOverHeaderDelegate( + height: _slideOverHeaderHeight, + backgroundColor: bgColor, + child: Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.grey[700] : Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 8), + child: Row( + children: [ + if (headerIcon != null) ...[ + Icon( + headerIcon, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + headerTitle, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ), + if (trailing != null) trailing!, + GestureDetector( + onTap: onClose, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + isDark ? Colors.grey[800] : Colors.grey[200], + ), + child: Icon( + Icons.close, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + // Divider below header + SliverToBoxAdapter( + child: Divider(height: 1), + ), + ...sliverBody, + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // List view + // --------------------------------------------------------------------------- + Widget _buildCategoryListPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final label = _activeCategory?.label ?? 'Results'; + + final viewport = _filterToViewport(_allCategoryResults); + final userPos = _getUserLatLng(); + final results = List.from(viewport); + if (userPos != null) { + results.sort((a, b) => + _distanceMeters(userPos.$1, userPos.$2, a.latitude, a.longitude) + .compareTo( + _distanceMeters( + userPos.$1, userPos.$2, b.latitude, b.longitude))); + } + + // Content-aware sizing: estimate content height as fraction of screen + final screenHeight = MediaQuery.of(context).size.height; + // Each list tile ~56px + header ~80px + divider + final contentHeight = + _slideOverHeaderHeight + (results.length * 56.0).clamp(56.0, 600.0); + final contentFraction = (contentHeight / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.45 ? contentFraction : 0.45; + final maxSize = contentFraction < 0.80 ? contentFraction.clamp(0.45, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _categorySheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + final headerTitle = results.isEmpty + ? '$label - None in view' + : '$label - ${results.length} in view'; + + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: headerTitle, + headerIcon: _activeCategory?.icon ?? Icons.place, + onClose: () => setState(() => _showCategoryList = false), + trailing: TextButton.icon( + icon: Icon( + Icons.refresh, + size: 16, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + label: Text( + 'Search here', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + ), + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: () => setState(() {}), + ), + sliverBody: results.isEmpty + ? [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_off, + size: 32, + color: isDark + ? Colors.grey[600] + : Colors.grey[400]), + const SizedBox(height: 8), + Text( + 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: isDark + ? Colors.grey[500] + : Colors.grey[600], + ), + ), + ], + ), + ), + ), + ] + : [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final r = results[index]; + final dist = userPos != null + ? _distanceMeters(userPos.$1, userPos.$2, + r.latitude, r.longitude) + : null; + final distLabel = dist == null + ? null + : dist < 1000 + ? '${dist.round()} m away' + : '${(dist / 1000).toStringAsFixed(1)} km away'; + + return Column( + children: [ + ListTile( + splashColor: Colors.transparent, + leading: Icon( + _iconForResult(r), + size: 20, + color: isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + title: Text( + r.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + subtitle: distLabel != null + ? Text(distLabel, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + )) + : (r.subtitle.isNotEmpty + ? Text(r.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 12)) + : null), + dense: true, + onTap: () => _selectResultFromPin(r), + ), + if (index < results.length - 1) + const Divider(height: 1), + ], + ); + }, + childCount: results.length, + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Detail slide-over + // --------------------------------------------------------------------------- + + Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final detailText = result.source == MapSearchSource.building + ? result.address + : result.description; + final categoryLabel = result.source == MapSearchSource.building + ? 'Building' + : result.subtitle; + + // Content-aware sizing: estimate how tall the detail content is + final screenHeight = MediaQuery.of(context).size.height; + // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 + double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; + if (detailText.isNotEmpty) contentEst += 60; + final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.30 ? contentFraction : 0.30; + final maxSize = + contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _detailSheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: result.name, + headerIcon: _iconForResult(result), + onClose: _closeDetail, + sliverBody: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category label + Text( + categoryLabel, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + + // Detail text + if (detailText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + detailText, + style: TextStyle( + fontSize: 14, + color: + isDark ? Colors.grey[400] : Colors.grey[600], + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 16), + + // Action buttons + Row( + children: [ + if (result.websiteUrl != null) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _launchWebsite(result.websiteUrl!), + icon: const Icon(Icons.language, size: 18), + label: const Text('View website'), + style: OutlinedButton.styleFrom( + foregroundColor: isDark + ? const Color(0xFFFFCD00) + : null, + side: isDark + ? const BorderSide( + color: Color(0xFFFFCD00)) + : null, + padding: const EdgeInsets.symmetric( + vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: FilledButton.icon( + onPressed: () => _launchDirections(result), + icon: const Icon(Icons.directions, size: 18), + label: const Text('Get Directions'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + super.build(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + body: Stack( + children: [ + // Map — Listener detects pointer-down to collapse slideovers + Column( + children: [ + Expanded( + child: Listener( + onPointerDown: _onMapPointerDown, + child: ArcGISMapView( + controllerProvider: () => _mapViewController, + onMapViewReady: _onMapViewReady, + onTap: _onMapTap, + ), + ), + ), + ], + ), + + // Floating search bar + dropdown + Positioned( + top: 8, + left: 12, + right: 12, + child: Column( + children: [ + // Search bar + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Row( + children: [ + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + Icons.search, + color: isDark ? Colors.white70 : Colors.grey[600], + ), + ), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + textInputAction: TextInputAction.search, + onSubmitted: _performSearch, + onChanged: (text) { + if (text.isEmpty) { + setState(() { + _showResults = false; + _searchResults = []; + _matchingPoiClasses = []; + _showSuggestions = _focusNode.hasFocus; + }); + } else { + final q = text.toLowerCase(); + // Match against server-fetched classes + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + // Always include hardcoded category class values as a fallback + // so chips appear even if the server fetch is still in flight + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _showSuggestions = false; + _matchingPoiClasses = matched; + }); + } + }, + onTap: () { + // Close detail panel when user taps search bar + if (_selectedResult != null) { + setState(() { + _selectedResult = null; + }); + } + // Show suggestions if text is empty + if (_searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + }, + style: TextStyle(fontSize: 16), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + hintText: 'Search buildings, places...', + ), + ), + ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear), + onPressed: _clearSearch, + ), + ], + ), + ), + + SizedBox(height: 4), + + // Suggestions panel (categories + recents) + if (_showSuggestions && !_showResults) + _buildSuggestionsPanel(context), + + // Search results dropdown + if (_showResults || (_matchingPoiClasses.isNotEmpty && _searchController.text.isNotEmpty && !_showSuggestions)) + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Category matches (always at top) ────────────────────────── + if (_matchingPoiClasses.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Row( + children: [ + Icon(Icons.category_outlined, + size: 13, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + const SizedBox(width: 6), + Text( + 'CATEGORIES', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: isDark ? Colors.grey[500] : Colors.grey[500], + ), + ), + ], + ), + ), + ..._matchingPoiClasses.map( + (classValue) => ListTile( + leading: Icon( + _iconForClass(classValue), + size: 20, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + title: Text(_labelForClass(classValue)), + subtitle: Text( + classValue, // show raw class value as subtitle so user knows what they're getting + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + ), + dense: true, + onTap: () => _performClassSearch(classValue), + ), + ), + if (_showResults && (_isSearching || _searchResults.isNotEmpty)) + const Divider(height: 1), + ], + + // ── POI / building results ───────────────────────────────────── + if (_showResults) + _isSearching + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : _searchResults.isEmpty + // Only show "no results" if there are also no category matches + ? (_matchingPoiClasses.isEmpty + ? const Padding( + padding: EdgeInsets.all(16), + child: Text('No results found'), + ) + : const SizedBox.shrink()) + : ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _searchResults.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final result = _searchResults[index]; + return ListTile( + splashColor: Colors.transparent, + leading: Icon(_iconForResult(result)), + title: Text(result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: result.subtitle.isNotEmpty + ? Text(result.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis) + : null, + dense: true, + onTap: () => _selectResult(result), + ); + }, + ), + ), + + ], + ), + ), + ], + ), + ), + + + // "See All" pill — shown when category results are filtered to viewport + if (_showSeeAll && _selectedResult == null) + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: _buildSeeAllButton(context), + ), + ), + // Bottom-right FAB cluster: list view button + info button + if (_selectedResult == null) + Positioned( + right: 16, + bottom: 32, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // List view button — only when a category search is active + if (_allCategoryResults.isNotEmpty) ...[ + FloatingActionButton.small( + heroTag: 'listBtn', + onPressed: () { + setState(() { + _showCategoryList = !_showCategoryList; + }); + }, + backgroundColor: _showCategoryList + ? Theme.of(context).colorScheme.primary + : (isDark ? Colors.grey[800] : null), + foregroundColor: _showCategoryList + ? Colors.white + : (isDark ? Colors.white : null), + child: Icon( + _showCategoryList ? Icons.map_outlined : Icons.list, + ), + ), + const SizedBox(height: 10), + ], + if (_lastSelectedResult != null) + FloatingActionButton.small( + heroTag: 'infoBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _reopenDetail, + child: const Icon(Icons.info_outline), + ), + if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'locateBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _recenterOnUser, + child: const Icon(Icons.my_location), + ), + ], + ), + ), + + // Category list panel — shown when list button is toggled + if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) + _buildCategoryListPanel(context), + + // Detail slide-over + if (_selectedResult != null) + _buildDetailSlideOver(context, _selectedResult!), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_service.dart b/lib/ui/esrimap/esrimap_service.dart new file mode 100644 index 000000000..6696836ce --- /dev/null +++ b/lib/ui/esrimap/esrimap_service.dart @@ -0,0 +1,1815 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:arcgis_maps/arcgis_maps.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +// ----------------------------------------------------------------------------- +// Model +// ----------------------------------------------------------------------------- + +/// Unified search result model for both Buildings and POIs. +class MapSearchResult { + final String name; + final String subtitle; + final double latitude; + final double longitude; + final MapSearchSource source; + final String address; + final String description; + final String? websiteUrl; + + const MapSearchResult({ + required this.name, + required this.subtitle, + required this.latitude, + required this.longitude, + required this.source, + this.address = '', + this.description = '', + this.websiteUrl, + }); + + /// Serialize to JSON for SharedPreferences storage. + Map toJson() => { + 'name': name, + 'subtitle': subtitle, + 'latitude': latitude, + 'longitude': longitude, + 'source': source.index, + 'address': address, + 'description': description, + 'websiteUrl': websiteUrl, + }; + + /// Deserialize from JSON. + factory MapSearchResult.fromJson(Map json) { + return MapSearchResult( + name: json['name'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + source: MapSearchSource.values[json['source'] as int? ?? 0], + address: json['address'] as String? ?? '', + description: json['description'] as String? ?? '', + websiteUrl: json['websiteUrl'] as String?, + ); + } +} + +enum MapSearchSource { building, poi } + +// ----------------------------------------------------------------------------- +// Category definitions +// ----------------------------------------------------------------------------- + +class _SearchCategory { + final String label; + final IconData icon; + final String poiClassValue; // maps to POI "Class" field value + + const _SearchCategory({ + required this.label, + required this.icon, + required this.poiClassValue, + }); +} + +const _categories = [ + _SearchCategory( + label: 'Parking', + icon: Icons.local_parking, + poiClassValue: 'Parking', + ), + _SearchCategory( + label: 'Dining', + icon: Icons.restaurant, + poiClassValue: 'Dining and Beverage', + ), + _SearchCategory( + label: 'Recreation', + icon: Icons.fitness_center, + poiClassValue: 'Athletic Facilities', + ), + _SearchCategory( + label: 'Transit', + icon: Icons.directions_bus, + poiClassValue: 'Transit', + ), +]; + +// ----------------------------------------------------------------------------- +// Slide-over header delegate +// ----------------------------------------------------------------------------- + +class _SlideOverHeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + final double height; + final Color backgroundColor; + + _SlideOverHeaderDelegate({ + required this.child, + required this.height, + required this.backgroundColor, + }); + + @override + double get minExtent => height; + @override + double get maxExtent => height; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + height: height, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: shrinkOffset == 0 + ? const BorderRadius.vertical(top: Radius.circular(16)) + : null, + ), + child: child, + ); + } + + @override + bool shouldRebuild(covariant _SlideOverHeaderDelegate oldDelegate) { + return child != oldDelegate.child || + height != oldDelegate.height || + backgroundColor != oldDelegate.backgroundColor; + } +} + +// ----------------------------------------------------------------------------- +// Widget +// ----------------------------------------------------------------------------- + +class EsriMap extends StatefulWidget { + const EsriMap({Key? key}) : super(key: key); + + @override + State createState() => _EsriMapState(); +} + +class _EsriMapState extends State with AutomaticKeepAliveClientMixin { + final _mapViewController = ArcGISMapView.createController(); + late final ArcGISMap _map; + final _graphicsOverlay = GraphicsOverlay(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + + // Slide-over controllers + final _categorySheetController = DraggableScrollableController(); + final _detailSheetController = DraggableScrollableController(); + + // Service endpoints + static const _buildingsQueryUrl = + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'AdministrationServices/Buildings_Public/MapServer/0/query'; + static const _poiQueryUrl = + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Points_Of_Interest/FeatureServer/0/query'; + + // SharedPreferences key for recent searches + static const _recentSearchesKey = 'esri_map_recent_searches'; + static const _maxRecentSearches = 5; + + // Search state + List _searchResults = []; + List _allPoiClasses = []; // all distinct Class values from the server + List _matchingPoiClasses = []; // live-filtered subset while typing + bool _showResults = false; + bool _isSearching = false; + bool _showSuggestions = false; + + // Recent searches + List _recentSearches = []; + + // Detail slide-over state + MapSearchResult? _selectedResult; + MapSearchResult? _lastSelectedResult; + + // Results currently plotted as pins on the map (from category search) + List _mappedResults = []; + + // Full unfiltered result set for the active category (used by "See All") + List _allCategoryResults = []; + + // Whether to show the "See All" pill button + bool _showSeeAll = false; + + // Whether to show the full category list panel + bool _showCategoryList = false; + + // The active category (for display in the "See All" label) + _SearchCategory? _activeCategory; + + // Location display + final _locationDataSource = SystemLocationDataSource(); + bool _locationStarted = false; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _initMap(); + _loadRecentSearches(); + _fetchAllPoiClasses(); + // Listen for focus changes to show/hide suggestions + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + _categorySheetController.dispose(); + _detailSheetController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _initMap() { + final hillshadeLayer = ArcGISTiledLayer.withUri( + Uri.parse( + 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', + ), + ); + + final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( + Uri.parse( + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', + ), + ); + + final basemap = Basemap(); + basemap.baseLayers.add(hillshadeLayer); + basemap.baseLayers.add(campusVectorTileLayer); + + _map = ArcGISMap.withBasemap(basemap); + _map.initialViewpoint = Viewpoint.fromCenter( + ArcGISPoint( + x: -117.2340, + y: 32.8801, + spatialReference: SpatialReference.wgs84, + ), + scale: 24000, + ); + } + + void _onMapViewReady() { + _mapViewController.arcGISMap = _map; + _mapViewController.interactionOptions.rotateEnabled = false; + _mapViewController.graphicsOverlays.add(_graphicsOverlay); + + // Wire up location display — blue dot, no auto-pan on start + _mapViewController.locationDisplay.dataSource = _locationDataSource; + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + + _startLocationDisplay(); + } + + Future _startLocationDisplay() async { + try { + await _locationDataSource.start(); + setState(() => _locationStarted = true); + } on ArcGISException catch (e) { + debugPrint('Location error: ${e.message}'); + } catch (e) { + debugPrint('Location error: $e'); + } + } + + void _onFocusChanged() { + if (_focusNode.hasFocus && _searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Recent searches persistence + // --------------------------------------------------------------------------- + + Future _loadRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_recentSearchesKey); + if (jsonStr != null) { + try { + final list = jsonDecode(jsonStr) as List; + setState(() { + _recentSearches = list + .map((e) => + MapSearchResult.fromJson(e as Map)) + .toList(); + }); + } catch (_) { + // ignore corrupted data + } + } + } + + Future _saveRecentSearches() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = + jsonEncode(_recentSearches.map((r) => r.toJson()).toList()); + await prefs.setString(_recentSearchesKey, jsonStr); + } + + void _addToRecentSearches(MapSearchResult result) { + // Remove duplicate if exists (match by name + source) + _recentSearches.removeWhere( + (r) => r.name == result.name && r.source == result.source); + // Insert at front + _recentSearches.insert(0, result); + // Trim to max + if (_recentSearches.length > _maxRecentSearches) { + _recentSearches = _recentSearches.sublist(0, _maxRecentSearches); + } + _saveRecentSearches(); + } + + void _removeFromRecentSearches(int index) { + setState(() { + _recentSearches.removeAt(index); + }); + _saveRecentSearches(); + } + + // --------------------------------------------------------------------------- + // Query helpers + // --------------------------------------------------------------------------- + + String _escSql(String input) => input.replaceAll("'", "''"); + + /// Query the Buildings (AGE) MapServer + Future> _queryBuildings(String query) async { + await dotenv.load(fileName: ".env"); + final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; + final escaped = _escSql(query); + final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " + "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; + + final uri = Uri.parse(_buildingsQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '8', + 'f': 'json', + if (token.isNotEmpty) 'token': token, + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final name = + (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; + final alias = (attrs['BuildingAliases'] as String?) ?? ''; + final street = (attrs['StreetAddress'] as String?) ?? ''; + final city = (attrs['City'] as String?) ?? ''; + final zip = (attrs['Zipcode'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final addressParts = [ + if (street.isNotEmpty) street, + if (city.isNotEmpty) city, + if (zip.isNotEmpty) zip, + ]; + final fullAddress = addressParts.join(', '); + final subtitle = alias.isNotEmpty ? alias : 'Building'; + + return MapSearchResult( + name: name, + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.building, + address: fullAddress, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + /// Fetches all distinct POI Class values from the FeatureServer and caches them. + Future _fetchAllPoiClasses() async { + try { + final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { + 'where': '1=1', + 'outFields': 'Class', + 'returnDistinctValues': 'true', + 'orderByFields': 'Class', + 'returnGeometry': 'false', + 'resultRecordCount': '200', + 'f': 'json', + }); + final response = await http.get(uri); + if (response.statusCode != 200) return; + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + final classes = features + .map((f) => (f['attributes']['Class'] as String?) ?? '') + .where((c) => c.isNotEmpty) + .toList() + ..sort(); + setState(() => _allPoiClasses = classes); + print(_allPoiClasses); + } catch (e) { + debugPrint('Failed to fetch POI classes: $e'); + } + } + + /// Query the POIs (AGO) FeatureServer by text search. + Future> _queryPOIs(String query) async { + final escaped = _escSql(query); + final where = "UpdatedName LIKE '%$escaped%' " + "OR C3DName LIKE '%$escaped%' " + "OR C3DKeywords LIKE '%$escaped%'"; + return _executePOIQuery(where); + } + + /// Query POIs filtered by a specific Class value (for category taps). + Future> _queryPOIsByClass(String classValue) async { + final escaped = _escSql(classValue); + final where = "Class = '$escaped'"; + return _executePOIQuery(where, maxResults: 100); + } + + /// Shared POI query execution. + Future> _executePOIQuery( + String where, { + int maxResults = 8, + }) async { + final uri = Uri.parse(_poiQueryUrl).replace( + queryParameters: { + 'where': where, + 'outFields': + 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', + 'returnGeometry': 'false', + 'resultRecordCount': '$maxResults', + 'f': 'json', + }, + ); + + final response = await http.get(uri); + if (response.statusCode != 200) return []; + + final json = jsonDecode(response.body); + final features = json['features'] as List? ?? []; + + return features.map((f) { + final attrs = f['attributes'] as Map; + final updatedName = (attrs['UpdatedName'] as String?) ?? ''; + final c3dName = (attrs['C3DName'] as String?) ?? ''; + final name = updatedName.isNotEmpty ? updatedName : c3dName; + final poiClass = (attrs['Class'] as String?) ?? ''; + final subclass = (attrs['Subclass'] as String?) ?? ''; + final description = (attrs['C3DDescription'] as String?) ?? ''; + final url = (attrs['URL'] as String?) ?? ''; + final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; + final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; + + final subtitle = + subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; + + return MapSearchResult( + name: name.isNotEmpty ? name : 'Unknown POI', + subtitle: subtitle, + latitude: lat, + longitude: lng, + source: MapSearchSource.poi, + description: description, + websiteUrl: url.isNotEmpty ? url : null, + ); + }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); + } + + // --------------------------------------------------------------------------- + // Search actions + // --------------------------------------------------------------------------- + + /// Text search: query both buildings and POIs in parallel. + Future _performSearch(String query) async { + if (query.trim().isEmpty) return; + + // Recompute category matches on submit + final q = query.toLowerCase(); + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _isSearching = true; + _showResults = true; + _showSuggestions = false; + _selectedResult = null; + _matchingPoiClasses = matched; + }); + + try { + final results = await Future.wait([ + _queryBuildings(query), + _queryPOIs(query), + ]); + final merged = [...results[0], ...results[1]]; + setState(() { + _searchResults = merged; + _isSearching = false; + }); + } catch (e) { + print('Search error: $e'); + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + + /// Returns the current visible map extent projected to WGS84. + Envelope? _getViewportEnvelopeWGS84() { + final vp = _mapViewController.getCurrentViewpoint( + ViewpointType.boundingGeometry, + ); + if (vp == null) return null; + final geom = vp.targetGeometry; + if (geom == null) return null; + final projected = GeometryEngine.project( + geom, + outputSpatialReference: SpatialReference.wgs84, + ); + return projected as Envelope?; + } + + /// Filters a result list to only those within the current map viewport. + /// Returns the full list unchanged if the envelope can't be determined. + List _filterToViewport(List results) { + final env = _getViewportEnvelopeWGS84(); + if (env == null) return results; + return results.where((r) { + return r.latitude >= env.yMin && + r.latitude <= env.yMax && + r.longitude >= env.xMin && + r.longitude <= env.xMax; + }).toList(); + } + + /// distance in meters between two WGS84 points + double _distanceMeters( + double lat1, double lng1, double lat2, double lng2) { + const r = 6371000.0; + final dLat = (lat2 - lat1) * math.pi / 180; + final dLng = (lng2 - lng1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + /// Returns the user's current WGS84 lat/lng, or null if unavailable. + (double lat, double lng)? _getUserLatLng() { + final pos = _mapViewController.locationDisplay.location?.position; + if (pos == null) return null; + final wgs = GeometryEngine.project( + pos, + outputSpatialReference: SpatialReference.wgs84, + ) as ArcGISPoint?; + if (wgs == null) return null; + return (wgs.y, wgs.x); + } + + /// Plot a list of results as pins on the map. + void _plotResultsOnMap(List results) { + _graphicsOverlay.graphics.clear(); + _mappedResults = results; + for (int i = 0; i < results.length; i++) { + final result = results[i]; + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.red, + size: 14, + ), + ); + // Store the list index so we can retrieve the result on tap + graphic.attributes['resultIndex'] = i; + _graphicsOverlay.graphics.add(graphic); + } + } + + /// Zoom the map to fit all mapped results, with padding. + void _zoomToResults(List results) { + if (results.isEmpty) return; + + if (results.length == 1) { + final r = results.first; + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter( + ArcGISPoint( + x: r.longitude, + y: r.latitude, + spatialReference: SpatialReference.wgs84, + ), + scale: 5000, + ), + ); + return; + } + + double minLat = results.map((r) => r.latitude).reduce((a, b) => a < b ? a : b); + double maxLat = results.map((r) => r.latitude).reduce((a, b) => a > b ? a : b); + double minLng = results.map((r) => r.longitude).reduce((a, b) => a < b ? a : b); + double maxLng = results.map((r) => r.longitude).reduce((a, b) => a > b ? a : b); + + const padding = 0.005; + final envelope = Envelope.fromXY( + xMin: minLng - padding, + yMin: minLat - padding, + xMax: maxLng + padding, + yMax: maxLat + padding, + spatialReference: SpatialReference.wgs84, + ); + _mapViewController.setViewpointAnimated( + Viewpoint.fromTargetExtent(envelope), + ); + } + + /// Category search: query POIs filtered by Class. + /// Category search: query POIs filtered by Class and plot all as map pins. + Future _performCategorySearch(_SearchCategory category) async { + setState(() { + _isSearching = true; + _showResults = false; + _showSuggestions = false; + _selectedResult = null; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = category; + _searchController.text = category.label; + }); + _focusNode.unfocus(); + + try { + final allResults = await _queryPOIsByClass(category.poiClassValue); + + // Always plot ALL pins — no zoom, no viewport filtering. + // The user pans/zooms freely to discover them. + _plotResultsOnMap(allResults); + + setState(() { + _allCategoryResults = allResults; + // Show "Zoom to all" pill whenever there are results + _showSeeAll = allResults.isNotEmpty; + _isSearching = false; + }); + } catch (e) { + print('Category search error: $e'); + setState(() { + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _isSearching = false; + }); + } + } + + /// Triggers a category-style search from a raw POI class string. + Future _performClassSearch(String classValue) async { + final category = _SearchCategory( + label: _labelForClass(classValue), + icon: _iconForClass(classValue), + poiClassValue: classValue, + ); + await _performCategorySearch(category); + } + + /// Plots all results for the active category + void _seeAllCategoryResults() { + if (_allCategoryResults.isEmpty) return; + _zoomToResults(_allCategoryResults); + setState(() => _showSeeAll = false); + } + + void _selectResult(MapSearchResult result) { + _graphicsOverlay.graphics.clear(); + + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + final graphic = Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: result.source == MapSearchSource.building + ? Colors.blue + : Colors.red, + size: 14, + ), + ); + _graphicsOverlay.graphics.add(graphic); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _showResults = false; + _showSuggestions = false; + _searchController.text = result.name; + _selectedResult = result; + _lastSelectedResult = result; + }); + _focusNode.unfocus(); + + // Save to recent searches + _addToRecentSearches(result); + } + + /// Called when the ArcGISMapView is tapped + /// Identifies any graphic at the tap location and shows its detail panel + Future _onMapTap(Offset screenPoint) async { + // Dismiss any open dropdowns first + if (_showSuggestions || _showResults) { + setState(() { + _showSuggestions = false; + _showResults = false; + }); + _focusNode.unfocus(); + return; + } + + // Only attempt identify when pins are on the map + if (_mappedResults.isEmpty) { + if (_selectedResult != null) _closeDetail(); + return; + } + + try { + final identifyResult = await _mapViewController.identifyGraphicsOverlay( + _graphicsOverlay, + screenPoint: screenPoint, + tolerance: 12.0, + maximumResults: 1, + ); + + if (identifyResult.graphics.isNotEmpty) { + final tappedGraphic = identifyResult.graphics.first; + final index = tappedGraphic.attributes['resultIndex'] as int?; + if (index != null && index >= 0 && index < _mappedResults.length) { + _selectResultFromPin(_mappedResults[index]); + } + } else { + // Tapped empty space — close detail if open + if (_selectedResult != null) _closeDetail(); + } + } catch (e) { + print('Identify error: $e'); + } + } + + /// Select a result from a map pin tap + /// keeps all existing category pins visible + void _selectResultFromPin(MapSearchResult result) { + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + + setState(() { + _selectedResult = result; + _lastSelectedResult = result; + _searchController.text = result.name; + _showResults = false; + _showSuggestions = false; + _showCategoryList = false; + }); + _focusNode.unfocus(); + _addToRecentSearches(result); + } + + void _closeDetail() { + setState(() { + _selectedResult = null; + // If a category search is active, reopen the list view + if (_allCategoryResults.isNotEmpty) { + _showCategoryList = true; + } + }); + } + + void _reopenDetail() { + if (_lastSelectedResult != null) { + setState(() { + _selectedResult = _lastSelectedResult; + }); + } + } + + void _recenterOnUser() { + if (!_locationStarted) { + _startLocationDisplay(); + return; + } + final loc = _mapViewController.locationDisplay.location; + if (loc == null) return; + + // Briefly snap to recenter mode, then release back to free pan + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.recenter; + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _mapViewController.locationDisplay.autoPanMode = + LocationDisplayAutoPanMode.off; + } + }); + } + + void _clearSearch() { + _searchController.clear(); + _graphicsOverlay.graphics.clear(); + setState(() { + _searchResults = []; + _matchingPoiClasses = []; + _mappedResults = []; + _allCategoryResults = []; + _showResults = false; + _showSuggestions = false; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + _selectedResult = null; + _lastSelectedResult = null; + }); + } + + Future _launchDirections(MapSearchResult result) async { + final uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&destination=${result.latitude},${result.longitude}', + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + Future _launchWebsite(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + IconData _iconForResult(MapSearchResult result) { + return result.source == MapSearchSource.building + ? Icons.business + : Icons.place; + } + + /// Returns the icon for a POI class — uses the hardcoded category icon if one + /// exists, otherwise falls back to a generic place icon. + IconData _iconForClass(String classValue) { + return _categories + .firstWhere( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase(), + orElse: () => const _SearchCategory( + label: '', icon: Icons.place, poiClassValue: ''), + ) + .icon; + } + + /// Converts a raw class value like "Food and Beverage" into a display label. + /// Uses the hardcoded category label if available, otherwise returns as-is. + String _labelForClass(String classValue) { + final match = _categories.where( + (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase()); + return match.isNotEmpty ? match.first.label : classValue; + } + + // --------------------------------------------------------------------------- + // Suggestions panel (categories + recent searches) + // --------------------------------------------------------------------------- + + Widget _buildSuggestionsPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[850] : Colors.white; + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: bgColor, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category section header + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Suggested', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 12), + + // Category icons row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _categories + .map((cat) => _buildCategoryChip(context, cat)) + .toList(), + ), + + // Recent searches section + if (_recentSearches.isNotEmpty) ...[ + SizedBox(height: 16), + Divider(height: 1), + SizedBox(height: 8), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Recently viewed', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + SizedBox(height: 4), + ...List.generate(_recentSearches.length, (index) { + final recent = _recentSearches[index]; + return ListTile( + dense: true, + leading: Icon( + Icons.history, + size: 20, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + title: Text( + recent.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: GestureDetector( + onTap: () => _removeFromRecentSearches(index), + child: Icon( + Icons.close, + size: 16, + color: isDark ? Colors.grey[500] : Colors.grey[400], + ), + ), + onTap: () => _selectResult(recent), + ); + }), + ], + ], + ), + ), + ); + } + + Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return GestureDetector( + onTap: () => _performCategorySearch(category), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + category.icon, + size: 24, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + SizedBox(height: 6), + Text( + category.label, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ], + ), + ); + } + + Widget _buildSeeAllButton(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final total = _allCategoryResults.length; + + return GestureDetector( + onTap: _seeAllCategoryResults, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: isDark ? Colors.grey[850] : Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.layers_outlined, + size: 16, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 6), + Text( + 'See all $total results', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Map interaction → collapse slideover + // --------------------------------------------------------------------------- + + void _onMapPointerDown(PointerDownEvent _) { + // Collapse whichever sheet is active to the minimum snap + if (_showCategoryList && _selectedResult == null) { + if (_categorySheetController.isAttached) { + _categorySheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + if (_selectedResult != null) { + if (_detailSheetController.isAttached) { + _detailSheetController.animateTo( + 0.15, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + } + + // --------------------------------------------------------------------------- + // Shared slide-over template + // --------------------------------------------------------------------------- + + /// Builds a consistent DraggableScrollableSheet with a pinned header. + /// [headerTitle] and [headerIcon] are shown in the header row. + /// [onClose] is called when the X button is tapped. + /// [trailing] is an optional widget shown before the close button (e.g. "Search here"). + /// [sliverBody] is the scrollable content below the header. + static const _slideOverHeaderHeight = 80.0; + + Widget _buildSlideOverContent({ + required BuildContext context, + required ScrollController scrollController, + required String headerTitle, + IconData? headerIcon, + required VoidCallback onClose, + Widget? trailing, + required List sliverBody, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[900]! : Colors.white; + + return Material( + elevation: 8, + color: bgColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + clipBehavior: Clip.antiAlias, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SlideOverHeaderDelegate( + height: _slideOverHeaderHeight, + backgroundColor: bgColor, + child: Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.grey[700] : Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 8), + child: Row( + children: [ + if (headerIcon != null) ...[ + Icon( + headerIcon, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + headerTitle, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.grey[900], + ), + ), + ), + if (trailing != null) trailing!, + GestureDetector( + onTap: onClose, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + isDark ? Colors.grey[800] : Colors.grey[200], + ), + child: Icon( + Icons.close, + size: 18, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + // Divider below header + SliverToBoxAdapter( + child: Divider(height: 1), + ), + ...sliverBody, + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // List view + // --------------------------------------------------------------------------- + Widget _buildCategoryListPanel(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final label = _activeCategory?.label ?? 'Results'; + + final viewport = _filterToViewport(_allCategoryResults); + final userPos = _getUserLatLng(); + final results = List.from(viewport); + if (userPos != null) { + results.sort((a, b) => + _distanceMeters(userPos.$1, userPos.$2, a.latitude, a.longitude) + .compareTo( + _distanceMeters( + userPos.$1, userPos.$2, b.latitude, b.longitude))); + } + + // Content-aware sizing: estimate content height as fraction of screen + final screenHeight = MediaQuery.of(context).size.height; + // Each list tile ~56px + header ~80px + divider + final contentHeight = + _slideOverHeaderHeight + (results.length * 56.0).clamp(56.0, 600.0); + final contentFraction = (contentHeight / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.45 ? contentFraction : 0.45; + final maxSize = contentFraction < 0.80 ? contentFraction.clamp(0.45, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _categorySheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + final headerTitle = results.isEmpty + ? '$label - None in view' + : '$label - ${results.length} in view'; + + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: headerTitle, + headerIcon: _activeCategory?.icon ?? Icons.place, + onClose: () => setState(() => _showCategoryList = false), + trailing: TextButton.icon( + icon: Icon( + Icons.refresh, + size: 16, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + label: Text( + 'Search here', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + ), + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: () => setState(() {}), + ), + sliverBody: results.isEmpty + ? [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_off, + size: 32, + color: isDark + ? Colors.grey[600] + : Colors.grey[400]), + const SizedBox(height: 8), + Text( + 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: isDark + ? Colors.grey[500] + : Colors.grey[600], + ), + ), + ], + ), + ), + ), + ] + : [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final r = results[index]; + final dist = userPos != null + ? _distanceMeters(userPos.$1, userPos.$2, + r.latitude, r.longitude) + : null; + final distLabel = dist == null + ? null + : dist < 1000 + ? '${dist.round()} m away' + : '${(dist / 1000).toStringAsFixed(1)} km away'; + + return Column( + children: [ + ListTile( + splashColor: Colors.transparent, + leading: Icon( + _iconForResult(r), + size: 20, + color: isDark + ? Colors.grey[400] + : Colors.grey[600], + ), + title: Text( + r.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + subtitle: distLabel != null + ? Text(distLabel, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + )) + : (r.subtitle.isNotEmpty + ? Text(r.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 12)) + : null), + dense: true, + onTap: () => _selectResultFromPin(r), + ), + if (index < results.length - 1) + const Divider(height: 1), + ], + ); + }, + childCount: results.length, + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Detail slide-over + // --------------------------------------------------------------------------- + + Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final detailText = result.source == MapSearchSource.building + ? result.address + : result.description; + final categoryLabel = result.source == MapSearchSource.building + ? 'Building' + : result.subtitle; + + // Content-aware sizing: estimate how tall the detail content is + final screenHeight = MediaQuery.of(context).size.height; + // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 + double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; + if (detailText.isNotEmpty) contentEst += 60; + final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); + final initialSize = + contentFraction < 0.30 ? contentFraction : 0.30; + final maxSize = + contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; + // Snap sizes must be strictly increasing and within [min, max] + final snaps = [0.15]; + if (initialSize > 0.15 + 0.01) snaps.add(initialSize); + + return DraggableScrollableSheet( + controller: _detailSheetController, + initialChildSize: initialSize, + minChildSize: 0.15, + maxChildSize: maxSize, + snap: true, + snapSizes: snaps, + builder: (context, scrollController) { + return _buildSlideOverContent( + context: context, + scrollController: scrollController, + headerTitle: result.name, + headerIcon: _iconForResult(result), + onClose: _closeDetail, + sliverBody: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category label + Text( + categoryLabel, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + + // Detail text + if (detailText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + detailText, + style: TextStyle( + fontSize: 14, + color: + isDark ? Colors.grey[400] : Colors.grey[600], + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 16), + + // Action buttons + Row( + children: [ + if (result.websiteUrl != null) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _launchWebsite(result.websiteUrl!), + icon: const Icon(Icons.language, size: 18), + label: const Text('View website'), + style: OutlinedButton.styleFrom( + foregroundColor: isDark + ? const Color(0xFFFFCD00) + : null, + side: isDark + ? const BorderSide( + color: Color(0xFFFFCD00)) + : null, + padding: const EdgeInsets.symmetric( + vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: FilledButton.icon( + onPressed: () => _launchDirections(result), + icon: const Icon(Icons.directions, size: 18), + label: const Text('Get Directions'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + super.build(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + body: Stack( + children: [ + // Map — Listener detects pointer-down to collapse slideovers + Column( + children: [ + Expanded( + child: Listener( + onPointerDown: _onMapPointerDown, + child: ArcGISMapView( + controllerProvider: () => _mapViewController, + onMapViewReady: _onMapViewReady, + onTap: _onMapTap, + ), + ), + ), + ], + ), + + // Floating search bar + dropdown + Positioned( + top: 8, + left: 12, + right: 12, + child: Column( + children: [ + // Search bar + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Row( + children: [ + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + Icons.search, + color: isDark ? Colors.white70 : Colors.grey[600], + ), + ), + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + textInputAction: TextInputAction.search, + onSubmitted: _performSearch, + onChanged: (text) { + if (text.isEmpty) { + setState(() { + _showResults = false; + _searchResults = []; + _matchingPoiClasses = []; + _showSuggestions = _focusNode.hasFocus; + }); + } else { + final q = text.toLowerCase(); + // Match against server-fetched classes + final matched = _allPoiClasses + .where((c) => c.toLowerCase().contains(q)) + .toList(); + + // Always include hardcoded category class values as a fallback + // so chips appear even if the server fetch is still in flight + for (final cat in _categories) { + if (cat.poiClassValue.toLowerCase().contains(q) && + !matched.contains(cat.poiClassValue)) { + matched.insert(0, cat.poiClassValue); + } + } + + setState(() { + _showSuggestions = false; + _matchingPoiClasses = matched; + }); + } + }, + onTap: () { + // Close detail panel when user taps search bar + if (_selectedResult != null) { + setState(() { + _selectedResult = null; + }); + } + // Show suggestions if text is empty + if (_searchController.text.isEmpty) { + setState(() { + _showSuggestions = true; + _showResults = false; + }); + } + }, + style: TextStyle(fontSize: 16), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + hintText: 'Search buildings, places...', + ), + ), + ), + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear), + onPressed: _clearSearch, + ), + ], + ), + ), + + SizedBox(height: 4), + + // Suggestions panel (categories + recents) + if (_showSuggestions && !_showResults) + _buildSuggestionsPanel(context), + + // Search results dropdown + if (_showResults || (_matchingPoiClasses.isNotEmpty && _searchController.text.isNotEmpty && !_showSuggestions)) + Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: isDark ? Colors.grey[850] : Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Category matches (always at top) ────────────────────────── + if (_matchingPoiClasses.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), + child: Row( + children: [ + Icon(Icons.category_outlined, + size: 13, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + const SizedBox(width: 6), + Text( + 'CATEGORIES', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: isDark ? Colors.grey[500] : Colors.grey[500], + ), + ), + ], + ), + ), + ..._matchingPoiClasses.map( + (classValue) => ListTile( + leading: Icon( + _iconForClass(classValue), + size: 20, + color: isDark ? Colors.white70 : Colors.grey[700], + ), + title: Text(_labelForClass(classValue)), + subtitle: Text( + classValue, // show raw class value as subtitle so user knows what they're getting + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[500]), + ), + dense: true, + onTap: () => _performClassSearch(classValue), + ), + ), + if (_showResults && (_isSearching || _searchResults.isNotEmpty)) + const Divider(height: 1), + ], + + // ── POI / building results ───────────────────────────────────── + if (_showResults) + _isSearching + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : _searchResults.isEmpty + // Only show "no results" if there are also no category matches + ? (_matchingPoiClasses.isEmpty + ? const Padding( + padding: EdgeInsets.all(16), + child: Text('No results found'), + ) + : const SizedBox.shrink()) + : ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _searchResults.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final result = _searchResults[index]; + return ListTile( + splashColor: Colors.transparent, + leading: Icon(_iconForResult(result)), + title: Text(result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: result.subtitle.isNotEmpty + ? Text(result.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis) + : null, + dense: true, + onTap: () => _selectResult(result), + ); + }, + ), + ), + + ], + ), + ), + ], + ), + ), + + + // "See All" pill — shown when category results are filtered to viewport + if (_showSeeAll && _selectedResult == null) + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: _buildSeeAllButton(context), + ), + ), + // Bottom-right FAB cluster: list view button + info button + if (_selectedResult == null) + Positioned( + right: 16, + bottom: 32, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // List view button — only when a category search is active + if (_allCategoryResults.isNotEmpty) ...[ + FloatingActionButton.small( + heroTag: 'listBtn', + onPressed: () { + setState(() { + _showCategoryList = !_showCategoryList; + }); + }, + backgroundColor: _showCategoryList + ? Theme.of(context).colorScheme.primary + : (isDark ? Colors.grey[800] : null), + foregroundColor: _showCategoryList + ? Colors.white + : (isDark ? Colors.white : null), + child: Icon( + _showCategoryList ? Icons.map_outlined : Icons.list, + ), + ), + const SizedBox(height: 10), + ], + if (_lastSelectedResult != null) + FloatingActionButton.small( + heroTag: 'infoBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _reopenDetail, + child: const Icon(Icons.info_outline), + ), + if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'locateBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _recenterOnUser, + child: const Icon(Icons.my_location), + ), + ], + ), + ), + + // Category list panel — shown when list button is toggled + if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) + _buildCategoryListPanel(context), + + // Detail slide-over + if (_selectedResult != null) + _buildDetailSlideOver(context, _selectedResult!), + ], + ), + ); + } +} \ No newline at end of file From 9be909c760fac402d8972911529235f4617e95d5 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:09:11 -0700 Subject: [PATCH 03/20] basemap options --- ios/Runner.xcodeproj/project.pbxproj | 118 ++++++++++++------------- lib/ui/esrimap/esrimap.dart | 126 +++++++++++++++++++++++---- lib/ui/esrimap/esrimap_basemaps.dart | 90 +++++++++++++++++++ pubspec.lock | 4 +- 4 files changed, 261 insertions(+), 77 deletions(-) create mode 100644 lib/ui/esrimap/esrimap_basemaps.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c6fd98ceb..853dc4da3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - F98DB421A362DEC1AA6FF298 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1731BD34CEF0DCB3E993F02 /* Pods_Runner.framework */; }; + D8CA97443716260D5654DC3A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D592C4668B7B9255F41E36 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -32,17 +32,17 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0E940A07290D20B33171D413 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.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 = ""; }; + 2F5012E47A1F3F57C687EABE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 346B293D23F32D2500DD909D /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 52F88FE7DDB502D18A26B7EA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 43D592C4668B7B9255F41E36 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 74BC29D11E57AC63D5E725D0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 774267092416F93F00AE2519 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 8D760C497D5B2DA31FF10CAF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -50,7 +50,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; explicitFileType = text.plist.info; path = Info.plist; sourceTree = ""; }; - E1731BD34CEF0DCB3E993F02 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EA15149A7ECBEEAFCB060618 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,7 +58,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F98DB421A362DEC1AA6FF298 /* Pods_Runner.framework in Frameworks */, + D8CA97443716260D5654DC3A /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -83,7 +83,7 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, FB73A6E03F3079B1691E9055 /* Pods */, - C522BDF92E7051EA82B83309 /* Frameworks */, + A0E05FAA68B331E57CDF099A /* Frameworks */, ); sourceTree = ""; }; @@ -120,10 +120,10 @@ name = "Supporting Files"; sourceTree = ""; }; - C522BDF92E7051EA82B83309 /* Frameworks */ = { + A0E05FAA68B331E57CDF099A /* Frameworks */ = { isa = PBXGroup; children = ( - E1731BD34CEF0DCB3E993F02 /* Pods_Runner.framework */, + 43D592C4668B7B9255F41E36 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -131,9 +131,9 @@ FB73A6E03F3079B1691E9055 /* Pods */ = { isa = PBXGroup; children = ( - 0E940A07290D20B33171D413 /* Pods-Runner.debug.xcconfig */, - 52F88FE7DDB502D18A26B7EA /* Pods-Runner.release.xcconfig */, - 8D760C497D5B2DA31FF10CAF /* Pods-Runner.profile.xcconfig */, + 74BC29D11E57AC63D5E725D0 /* Pods-Runner.debug.xcconfig */, + EA15149A7ECBEEAFCB060618 /* Pods-Runner.release.xcconfig */, + 2F5012E47A1F3F57C687EABE /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -145,7 +145,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - DFEBA31F248FFF58128BD6EE /* [CP] Check Pods Manifest.lock */, + 3A232C58A713A3B0E30B33C2 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -153,8 +153,8 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 348EE9E2258A901800712F2E /* ShellScript */, - B19D24E1170B60724162B13B /* [CP] Embed Pods Frameworks */, - BAA8B7F5B962BB7F0187FF6A /* [CP] Copy Pods Resources */, + 4207515FC91A96EADBE8A0AD /* [CP] Embed Pods Frameworks */, + 2C1278E8790F8AB292D138B8 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -216,6 +216,30 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 2C1278E8790F8AB292D138B8 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/firebase_messaging/firebase_messaging_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/google_maps_flutter_ios/google_maps_flutter_ios_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/permission_handler_apple/permission_handler_apple_privacy.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/firebase_messaging_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/google_maps_flutter_ios_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/permission_handler_apple_privacy.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 348EE9E2258A901800712F2E /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -233,38 +257,45 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/FirebaseCrashlytics/run\n"; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 3A232C58A713A3B0E30B33C2 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); - name = "Run Script"; + name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - B19D24E1170B60724162B13B /* [CP] Embed Pods Frameworks */ = { + 4207515FC91A96EADBE8A0AD /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -342,51 +373,20 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - BAA8B7F5B962BB7F0187FF6A /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/GoogleMaps/GoogleMapsResources.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/firebase_messaging/firebase_messaging_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/google_maps_flutter_ios/google_maps_flutter_ios_privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/permission_handler_apple/permission_handler_apple_privacy.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMapsResources.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/firebase_messaging_Privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/google_maps_flutter_ios_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/permission_handler_apple_privacy.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - DFEBA31F248FFF58128BD6EE /* [CP] Check Pods Manifest.lock */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Run Script"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index f28fcbe3b..bf15e3442 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -7,6 +7,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'esrimap_basemaps.dart'; + // ----------------------------------------------------------------------------- // Model // ----------------------------------------------------------------------------- @@ -229,6 +231,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { (double, double)? _fromLatLng; MapSearchResult? _routeDestination; + // Basemap switcher + BasemapType _currentBasemapType = BasemapType.defaultMap; + final Map _basemaps = {}; + bool _showBasemapMenu = false; + @override bool get wantKeepAlive => true; @@ -258,23 +265,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _initMap() { - final hillshadeLayer = ArcGISTiledLayer.withUri( - Uri.parse( - 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', - ), - ); - - final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( - Uri.parse( - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', - ), - ); - - final basemap = Basemap(); - basemap.baseLayers.add(hillshadeLayer); - basemap.baseLayers.add(campusVectorTileLayer); + // Build all three basemaps up front. The default is applied immediately; + // the other two are preloaded in the background by + // _preloadAlternateBasemaps() once the MapView is ready. + for (final type in BasemapType.values) { + _basemaps[type] = buildBasemap(type); + } - _map = ArcGISMap.withBasemap(basemap); + _map = ArcGISMap.withBasemap(_basemaps[_currentBasemapType]!); _map.initialViewpoint = Viewpoint.fromCenter( ArcGISPoint( x: -117.2340, @@ -297,6 +295,33 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { LocationDisplayAutoPanMode.off; _startLocationDisplay(); + _preloadAlternateBasemaps(); + } + + ///Loads metadata and style sheets; tile content still fetches lazily once the basemap is applied to the MapView. + void _preloadAlternateBasemaps() { + for (final entry in _basemaps.entries) { + if (entry.key == _currentBasemapType) continue; + entry.value.load().catchError((Object e) { + debugPrint('Failed to preload basemap ${entry.key}: $e'); + }); + } + } + + void _switchBasemap(BasemapType newType) { + if (newType == _currentBasemapType) { + setState(() => _showBasemapMenu = false); + return; + } + final newBasemap = _basemaps[newType]; + if (newBasemap == null) return; + + // Setting Map.basemap swaps the underlying layers without changing the current Viewpoint, so the user's view frame is preserved. + setState(() { + _map.basemap = newBasemap; + _currentBasemapType = newType; + _showBasemapMenu = false; + }); } Future _startLocationDisplay() async { @@ -1557,6 +1582,55 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ); } + Widget _buildBasemapMenu(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[850] : Colors.white; + final textColor = isDark ? Colors.white : Colors.grey[900]; + final accent = + isDark ? Colors.lightBlue[300]! : Theme.of(context).colorScheme.primary; + + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: bgColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: BasemapType.values.map((type) { + final opt = basemapOptions[type]!; + final selected = type == _currentBasemapType; + return InkWell( + onTap: () => _switchBasemap(type), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + opt.label, + style: TextStyle( + fontSize: 14, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w500, + color: textColor, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 16, + child: selected + ? Icon(Icons.check, size: 16, color: accent) + : null, + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } + // --------------------------------------------------------------------------- // List view // --------------------------------------------------------------------------- @@ -2498,6 +2572,26 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ + // Basemap picker menu (shown above the layers FAB when open) + if (_showBasemapMenu) ...[ + _buildBasemapMenu(context), + const SizedBox(height: 10), + ], + // Layers FAB — toggles the basemap menu + FloatingActionButton.small( + heroTag: 'layersBtn', + backgroundColor: _showBasemapMenu + ? Theme.of(context).colorScheme.primary + : (isDark ? Colors.grey[800] : null), + foregroundColor: _showBasemapMenu + ? Colors.white + : (isDark ? Colors.white : null), + onPressed: () { + setState(() => _showBasemapMenu = !_showBasemapMenu); + }, + child: const Icon(Icons.layers_outlined), + ), + const SizedBox(height: 10), // List view button — only when a category search is active if (_allCategoryResults.isNotEmpty) ...[ FloatingActionButton.small( diff --git a/lib/ui/esrimap/esrimap_basemaps.dart b/lib/ui/esrimap/esrimap_basemaps.dart new file mode 100644 index 000000000..b2431d88f --- /dev/null +++ b/lib/ui/esrimap/esrimap_basemaps.dart @@ -0,0 +1,90 @@ +/* Basemap config for campus map + +There are three basemaps with three layers each. +1. World Hillshade shows terrain relief and adds depth to non campus areas +2. Esri world context fills off campus areas +3. UCSD Campus Vector Tile layer loaded from AGE + */ + +import 'package:arcgis_maps/arcgis_maps.dart'; + +enum BasemapType { defaultMap, light, dark } + +class BasemapOption { + final String label; + final String itemId; + const BasemapOption({ + required this.label, + required this.itemId, + }); +} + +const basemapOptions = { + BasemapType.defaultMap: BasemapOption( + label: 'Default', + itemId: 'e19f33d2c1f44967aef673306c483913', + ), + BasemapType.light: BasemapOption( + label: 'Light', + itemId: '6643ee62af494f5bafe7dfdb8eb3f857', + ), + BasemapType.dark: BasemapOption( + label: 'Dark', + itemId: '09d7b3934b6c4c2cad8380c04e08c1b1', + ), +}; + +// Esri world tile service URIs (raster). +const _hillshadeUri = + 'https://services.arcgisonline.com/arcgis/rest/services/' + 'Elevation/World_Hillshade/MapServer'; + +const _worldTopoUri = + 'https://services.arcgisonline.com/arcgis/rest/services/' + 'World_Topo_Map/MapServer'; + +const _lightGrayBaseUri = + 'https://services.arcgisonline.com/arcgis/rest/services/' + 'Canvas/World_Light_Gray_Base/MapServer'; + +const _darkGrayBaseUri = + 'https://services.arcgisonline.com/arcgis/rest/services/' + 'Canvas/World_Dark_Gray_Base/MapServer'; + +final _agePortal = Portal( + Uri.parse('https://admin-enterprise-gis.ucsd.edu/portal'), +); + +/// Build a basemap of the given type. Safe to call multiple times; each +/// call returns an independent Basemap instance with freshly constructed +/// layers. +Basemap buildBasemap(BasemapType type) { + final hillshade = ArcGISTiledLayer.withUri(Uri.parse(_hillshadeUri)); + final esriContext = _esriContextLayer(type); + final ucsdLayer = _ucsdCampusLayer(type); + + final basemap = Basemap(); + basemap.baseLayers.add(hillshade); + basemap.baseLayers.add(esriContext); + basemap.baseLayers.add(ucsdLayer); + return basemap; +} + +ArcGISTiledLayer _esriContextLayer(BasemapType type) { + switch (type) { + case BasemapType.defaultMap: + return ArcGISTiledLayer.withUri(Uri.parse(_worldTopoUri)); + case BasemapType.light: + return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); + case BasemapType.dark: + return ArcGISTiledLayer.withUri(Uri.parse(_darkGrayBaseUri)); + } +} + +ArcGISVectorTiledLayer _ucsdCampusLayer(BasemapType type) { + final portalItem = PortalItem.withPortalAndItemId( + portal: _agePortal, + itemId: basemapOptions[type]!.itemId, + ); + return ArcGISVectorTiledLayer.withItem(portalItem); +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2027b9fa2..685e33c14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1133,10 +1133,10 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "3.9.0" pool: dependency: transitive description: From 8596cb1e14a6dcbb2c516b8074eb666e855701eb Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:34:26 -0700 Subject: [PATCH 04/20] recenter and colors --- lib/ui/esrimap/esrimap.dart | 100 +++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index bf15e3442..99bb2011f 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -218,6 +218,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final _routeGraphicsOverlay = GraphicsOverlay(); bool _isRouting = false; bool _hasRoute = false; + Envelope? _routePaddedExtent; bool _routeFailed = false; String _travelMode = 'Walking'; double _routeTravelTimeMinutes = 0; @@ -1034,6 +1035,39 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); } + void _recenterOnView() { + if (_selectedResult != null) { + // Center on active location, same viewpoint as when it was first selected + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter( + ArcGISPoint( + x: _selectedResult!.longitude, + y: _selectedResult!.latitude, + spatialReference: SpatialReference.wgs84, + ), + scale: 5000, + ), + ); + } else if (_hasRoute && _routePaddedExtent != null) { + // Re-fit the route extent (same padded envelope computed at solve time) + _mapViewController.setViewpointAnimated( + Viewpoint.fromTargetExtent(_routePaddedExtent!), + ); + } else { + // Default campus view + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter( + ArcGISPoint( + x: -117.2340, + y: 32.8801, + spatialReference: SpatialReference.wgs84, + ), + scale: 24000, + ), + ); + } + } + void _clearSearch() { _searchController.clear(); _fromController.clear(); @@ -1216,6 +1250,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { yMax: extent.yMax + dyTop, spatialReference: extent.spatialReference, ); + _routePaddedExtent = paddedExtent; _mapViewController.setViewpointAnimated( Viewpoint.fromTargetExtent(paddedExtent), ); @@ -1244,6 +1279,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _routeGraphicsOverlay.graphics.clear(); _fromController.clear(); _toController.clear(); + _routePaddedExtent = null; setState(() { _hasRoute = false; _routeFailed = false; @@ -1316,11 +1352,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { SizedBox(height: 12), // Category icons row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: _categories - .map((cat) => _buildCategoryChip(context, cat)) - .toList(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _categories + .map((cat) => _buildCategoryChip(context, cat)) + .toList(), + ), ), // Recent searches section @@ -1343,6 +1382,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ...List.generate(_recentSearches.length, (index) { final recent = _recentSearches[index]; return ListTile( + splashColor: Colors.transparent, dense: true, leading: Icon( Icons.history, @@ -1375,6 +1415,19 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { final isDark = Theme.of(context).brightness == Brightness.dark; + final colors = { + 'Parking': Color(0xFFCCF4F7), + 'Dining and Beverage': Color(0xFFF9DDEF), + 'Athletic Facilities': Color(0xFFFCF9CC), + 'Transit': Color(0xFFFFEDD1), + }; + final iconColors = { + 'Parking': Colors.black, + 'Dining and Beverage': Colors.black, + 'Athletic Facilities': Colors.black, + 'Transit': Colors.black, + }; + return GestureDetector( onTap: () => _performCategorySearch(category), child: Column( @@ -1384,13 +1437,13 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { width: 56, height: 56, decoration: BoxDecoration( - color: isDark ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(16), + color: colors[category.poiClassValue] ?? (isDark ? Colors.grey[800]! : Colors.grey[100]!), + shape: BoxShape.circle, ), child: Icon( category.icon, size: 24, - color: isDark ? Colors.white70 : Colors.grey[700], + color: iconColors[category.poiClassValue] ?? (isDark ? Colors.white70 : Colors.grey[700]), ), ), SizedBox(height: 6), @@ -2481,6 +2534,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ), ..._matchingPoiClasses.map( (classValue) => ListTile( + splashColor: Colors.transparent, leading: Icon( _iconForClass(classValue), size: 20, @@ -2580,12 +2634,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // Layers FAB — toggles the basemap menu FloatingActionButton.small( heroTag: 'layersBtn', - backgroundColor: _showBasemapMenu - ? Theme.of(context).colorScheme.primary - : (isDark ? Colors.grey[800] : null), - foregroundColor: _showBasemapMenu - ? Colors.white - : (isDark ? Colors.white : null), + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, onPressed: () { setState(() => _showBasemapMenu = !_showBasemapMenu); }, @@ -2601,15 +2651,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _showCategoryList = !_showCategoryList; }); }, - backgroundColor: _showCategoryList - ? Theme.of(context).colorScheme.primary - : (isDark ? Colors.grey[800] : null), - foregroundColor: _showCategoryList - ? Colors.white - : (isDark ? Colors.white : null), - child: Icon( - _showCategoryList ? Icons.map_outlined : Icons.list, - ), + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + child: const Icon(Icons.list), ), const SizedBox(height: 10), ], @@ -2633,6 +2677,16 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ], if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) const SizedBox(height: 10), + + FloatingActionButton.small( + heroTag: 'recenterBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: _recenterOnView, + child: const Icon(Icons.center_focus_strong), + ), + + const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'locateBtn', backgroundColor: isDark ? Colors.grey[800] : null, From 8eed5b468111513a00e2b6ad2f80a3ceca1314f9 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:52:46 -0700 Subject: [PATCH 05/20] better centering, route bug fix, and ui fix --- lib/ui/esrimap/esrimap.dart | 67 ++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 99bb2011f..3108d2b54 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -1037,7 +1037,6 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { void _recenterOnView() { if (_selectedResult != null) { - // Center on active location, same viewpoint as when it was first selected _mapViewController.setViewpointAnimated( Viewpoint.fromCenter( ArcGISPoint( @@ -1048,13 +1047,22 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { scale: 5000, ), ); - } else if (_hasRoute && _routePaddedExtent != null) { - // Re-fit the route extent (same padded envelope computed at solve time) + } else if (_lastSelectedResult != null && _mappedResults.isEmpty) { + // Single pin on map, slide-over closed _mapViewController.setViewpointAnimated( - Viewpoint.fromTargetExtent(_routePaddedExtent!), + Viewpoint.fromCenter( + ArcGISPoint( + x: _lastSelectedResult!.longitude, + y: _lastSelectedResult!.latitude, + spatialReference: SpatialReference.wgs84, + ), + scale: 5000, + ), ); + } else if (_mappedResults.isNotEmpty) { + // Category pins on map + _zoomToResults(_mappedResults); } else { - // Default campus view _mapViewController.setViewpointAnimated( Viewpoint.fromCenter( ArcGISPoint( @@ -1280,6 +1288,31 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _fromController.clear(); _toController.clear(); _routePaddedExtent = null; + + // Re-add destination pin and center on it if a result is still selected + final destination = _selectedResult; + if (destination != null) { + final point = ArcGISPoint( + x: destination.longitude, + y: destination.latitude, + spatialReference: SpatialReference.wgs84, + ); + _graphicsOverlay.graphics.clear(); + _graphicsOverlay.graphics.add(Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: destination.source == MapSearchSource.building + ? Colors.blue + : Colors.red, + size: 14, + ), + )); + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + } + setState(() { _hasRoute = false; _routeFailed = false; @@ -1890,7 +1923,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ? 'Directions to ${result.name}' : result.name, headerIcon: _iconForResult(result), - onClose: _closeDetail, + onClose: isRouting ? _clearRoute : _closeDetail, sliverBody: [ SliverToBoxAdapter( child: Padding( @@ -2399,9 +2432,27 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ? Colors.white70 : Colors.grey[600]), onPressed: () { - final tmp = _fromController.text; + final tmpText = _fromController.text; + final tmpLatLng = _fromLatLng; _fromController.text = _toController.text; - _toController.text = tmp; + _toController.text = tmpText; + setState(() { + _fromLatLng = _routeDestination != null + ? (_routeDestination!.latitude, _routeDestination!.longitude) + : null; + _routeDestination = tmpLatLng != null + ? MapSearchResult( + name: tmpText, + subtitle: '', + latitude: tmpLatLng.$1, + longitude: tmpLatLng.$2, + source: MapSearchSource.building, + ) + : null; + }); + if (_routeDestination != null && _fromLatLng != null) { + _solveRoute(_routeDestination!, originLatLng: _fromLatLng); + } }, ), ), From 6bbf438bef9678b04076e4a7d7ec91a48d019fc9 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:45:48 -0700 Subject: [PATCH 06/20] assembly area layer swapped for district layer --- lib/ui/esrimap/esrimap.dart | 119 +++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 3108d2b54..270fc3910 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -236,6 +236,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { BasemapType _currentBasemapType = BasemapType.defaultMap; final Map _basemaps = {}; bool _showBasemapMenu = false; + bool _showCampusDistricts = false; + ArcGISMapImageLayer? _campusDistrictsLayer; @override bool get wantKeepAlive => true; @@ -325,6 +327,28 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); } + void _toggleCampusDistricts() { + if (_showCampusDistricts) { + if (_campusDistrictsLayer != null) { + _map.operationalLayers.remove(_campusDistrictsLayer!); + } + setState(() => _showCampusDistricts = false); + } else { + if (_campusDistrictsLayer == null) { + _campusDistrictsLayer = ArcGISMapImageLayer.withUri(Uri.parse( + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'AdministrationServices/Areas_and_Boundaries/MapServer', + )); + // Show only sublayer 4 (Campus Districts) + for (final sublayer in _campusDistrictsLayer!.mapImageSublayers) { + sublayer.isVisible = sublayer.id == 4; + } + } + _map.operationalLayers.add(_campusDistrictsLayer!); + setState(() => _showCampusDistricts = true); + } + } + Future _startLocationDisplay() async { try { await _locationDataSource.start(); @@ -1672,47 +1696,70 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final isDark = Theme.of(context).brightness == Brightness.dark; final bgColor = isDark ? Colors.grey[850] : Colors.white; final textColor = isDark ? Colors.white : Colors.grey[900]; + final labelColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; final accent = isDark ? Colors.lightBlue[300]! : Theme.of(context).colorScheme.primary; - return Material( - elevation: 6, - borderRadius: BorderRadius.circular(12), - color: bgColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: BasemapType.values.map((type) { - final opt = basemapOptions[type]!; - final selected = type == _currentBasemapType; - return InkWell( - onTap: () => _switchBasemap(type), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - opt.label, - style: TextStyle( - fontSize: 14, - fontWeight: - selected ? FontWeight.w600 : FontWeight.w500, - color: textColor, - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 16, - child: selected - ? Icon(Icons.check, size: 16, color: accent) - : null, + Widget sectionLabel(String text) => Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: labelColor, + ), + ), + ); + + Widget menuRow(String label, bool selected, VoidCallback onTap) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: textColor, ), - ], - ), + ), + SizedBox( + width: 16, + child: selected + ? Icon(Icons.check, size: 16, color: accent) + : null, + ), + ], ), - ); - }).toList(), + ), + ); + + return SizedBox( + width: 160, + child: Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: bgColor, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + sectionLabel('BASEMAP'), + ...BasemapType.values.map((type) => menuRow( + basemapOptions[type]!.label, + type == _currentBasemapType, + () => _switchBasemap(type), + )), + const Divider(height: 1), + sectionLabel('LAYERS'), + menuRow('Campus Districts', _showCampusDistricts, _toggleCampusDistricts), + ], + ), ), ); } From 93fdea391f7ea58b118a84b8b0958dd06ff208ef Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:03:21 -0700 Subject: [PATCH 07/20] satellite view and swap world context for default --- lib/ui/esrimap/esrimap_basemaps.dart | 36 +++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/ui/esrimap/esrimap_basemaps.dart b/lib/ui/esrimap/esrimap_basemaps.dart index b2431d88f..05ad76d72 100644 --- a/lib/ui/esrimap/esrimap_basemaps.dart +++ b/lib/ui/esrimap/esrimap_basemaps.dart @@ -8,7 +8,7 @@ There are three basemaps with three layers each. import 'package:arcgis_maps/arcgis_maps.dart'; -enum BasemapType { defaultMap, light, dark } +enum BasemapType { defaultMap, light, dark, satellite } class BasemapOption { final String label; @@ -32,6 +32,10 @@ const basemapOptions = { label: 'Dark', itemId: '09d7b3934b6c4c2cad8380c04e08c1b1', ), + BasemapType.satellite: BasemapOption( + label: 'Satellite', + itemId: '6643ee62af494f5bafe7dfdb8eb3f857', + ), }; // Esri world tile service URIs (raster). @@ -51,6 +55,12 @@ const _darkGrayBaseUri = 'https://services.arcgisonline.com/arcgis/rest/services/' 'Canvas/World_Dark_Gray_Base/MapServer'; +// Nearmap tile service +const _nearmapUri = + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Campus_Imagery_Nearmap_9in_2025_0423_0514/MapServer'; + +// AGE portal for campus vector tiles final _agePortal = Portal( Uri.parse('https://admin-enterprise-gis.ucsd.edu/portal'), ); @@ -59,25 +69,33 @@ final _agePortal = Portal( /// call returns an independent Basemap instance with freshly constructed /// layers. Basemap buildBasemap(BasemapType type) { - final hillshade = ArcGISTiledLayer.withUri(Uri.parse(_hillshadeUri)); - final esriContext = _esriContextLayer(type); - final ucsdLayer = _ucsdCampusLayer(type); - final basemap = Basemap(); - basemap.baseLayers.add(hillshade); - basemap.baseLayers.add(esriContext); - basemap.baseLayers.add(ucsdLayer); + + if (type == BasemapType.satellite) { + // Light gray fallback + Nearmap imagery + campus vector + basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri))); + basemap.baseLayers.add(ArcGISMapImageLayer.withUri(Uri.parse(_nearmapUri))); + } else { + basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(_hillshadeUri))); + basemap.baseLayers.add(_esriContextLayer(type)); + } + + if (type != BasemapType.satellite) { + basemap.baseLayers.add(_ucsdCampusLayer(type)); + } return basemap; } ArcGISTiledLayer _esriContextLayer(BasemapType type) { switch (type) { case BasemapType.defaultMap: - return ArcGISTiledLayer.withUri(Uri.parse(_worldTopoUri)); + return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); case BasemapType.light: return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); case BasemapType.dark: return ArcGISTiledLayer.withUri(Uri.parse(_darkGrayBaseUri)); + case BasemapType.satellite: + return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); } } From 246453f3ae703e6fcec0346958262be2cb601c15 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:00:53 -0700 Subject: [PATCH 08/20] fix: regenerate Podfile.lock to resolve Firebase version conflict --- ios/Podfile.lock | 139 +++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 66 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 106768598..e550a2977 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -10,83 +10,83 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter - - Firebase/CoreOnly (12.9.0): - - FirebaseCore (~> 12.9.0) - - Firebase/Crashlytics (12.9.0): + - Firebase/CoreOnly (12.6.0): + - FirebaseCore (~> 12.6.0) + - Firebase/Crashlytics (12.6.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.9.0) - - Firebase/Messaging (12.9.0): + - FirebaseCrashlytics (~> 12.6.0) + - Firebase/Messaging (12.6.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.9.0) - - firebase_analytics (12.1.3): + - FirebaseMessaging (~> 12.6.0) + - firebase_analytics (12.1.0): - firebase_core - - FirebaseAnalytics (= 12.9.0) + - FirebaseAnalytics (= 12.6.0) - Flutter - - firebase_core (4.5.0): - - Firebase/CoreOnly (= 12.9.0) + - firebase_core (4.3.0): + - Firebase/CoreOnly (= 12.6.0) - Flutter - - firebase_crashlytics (5.0.8): - - Firebase/Crashlytics (= 12.9.0) + - firebase_crashlytics (5.0.6): + - Firebase/Crashlytics (= 12.6.0) - firebase_core - Flutter - - firebase_messaging (16.1.2): - - Firebase/Messaging (= 12.9.0) + - firebase_messaging (16.1.0): + - Firebase/Messaging (= 12.6.0) - firebase_core - Flutter - - FirebaseAnalytics (12.9.0): - - FirebaseAnalytics/Default (= 12.9.0) - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) + - FirebaseAnalytics (12.6.0): + - FirebaseAnalytics/Default (= 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - GoogleAppMeasurement/Default (= 12.9.0) + - FirebaseAnalytics/Default (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleAppMeasurement/Default (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (12.9.0): - - FirebaseCoreInternal (~> 12.9.0) + - FirebaseCore (12.6.0): + - FirebaseCoreInternal (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreInternal (12.9.0): + - FirebaseCoreExtension (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreInternal (12.6.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) - - FirebaseRemoteConfigInterop (~> 12.9.0) - - FirebaseSessions (~> 12.9.0) + - FirebaseCrashlytics (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (~> 12.6.0) + - FirebaseSessions (~> 12.6.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.9.0): - - FirebaseCore (~> 12.9.0) + - FirebaseInstallations (12.6.0): + - FirebaseCore (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) + - FirebaseMessaging (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.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.9.0) - - FirebaseSessions (12.9.0): - - FirebaseCore (~> 12.9.0) - - FirebaseCoreExtension (~> 12.9.0) - - FirebaseInstallations (~> 12.9.0) + - FirebaseRemoteConfigInterop (12.6.0) + - FirebaseSessions (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) @@ -113,23 +113,23 @@ PODS: - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.9.0): + - GoogleAppMeasurement/Core (12.6.0): - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.9.0): + - GoogleAppMeasurement/Default (12.6.0): - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.9.0) - - GoogleAppMeasurement/IdentitySupport (= 12.9.0) + - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.9.0): - - GoogleAppMeasurement/Core (= 12.9.0) + - GoogleAppMeasurement/IdentitySupport (12.6.0): + - GoogleAppMeasurement/Core (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -177,6 +177,9 @@ PODS: - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) @@ -237,6 +240,7 @@ DEPENDENCIES: - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - Runtimecore (from `../arcgis_maps_core/ios/Runtimecore.podspec`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -303,6 +307,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/haptic_feedback/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" Runtimecore: @@ -324,20 +330,20 @@ SPEC CHECKSUMS: arcgis_maps_ffi: b1418b0e72ad292ab9314100134da9f175de99f0 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - Firebase: 065f2bb395062046623036d8e6dc857bc2521d56 - firebase_analytics: 6903a46192a92993abde88152a14ce1df1c0de2f - firebase_core: afac1aac13c931e0401c7e74ed1276112030efab - firebase_crashlytics: a316d8dddba772359d93dc38d303ed964579b7a6 - firebase_messaging: 7cb2727feb789751fc6936bcc8e08408970e2820 - FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352 - FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8 - FirebaseCoreExtension: e911052d59cd0da237a45d706fc0f81654f035c1 - FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72 - FirebaseCrashlytics: 43913d587ef07beaf5db703baa61eacf9554658c - FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa - FirebaseMessaging: 7d6cdbff969127c4151c824fe432f0e301210f15 - FirebaseRemoteConfigInterop: 765ee19cd2bfa8e54937c8dae901eb634ad6787d - FirebaseSessions: a2d06fd980431fda934c7a543901aca05fc4edcc + Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 + firebase_analytics: 4f9cca09e65f6c2944a862c6dc86f6bed9fb769c + firebase_core: ba00a168e719694f38960502ceb560285603d073 + firebase_crashlytics: 13f4b77e9ce2a84b1f8ea07f293db5b6213ce1cf + firebase_messaging: bf0e29321927edc02a563c984dbfa5b063864b15 + FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 + FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 + FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f + FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e + FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e + FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad + FirebaseMessaging: a61bc42dcab3f7a346d94bbb54dab2c9435b18b2 + FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 + FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 @@ -346,22 +352,23 @@ SPEC CHECKSUMS: Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96 google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264 GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c + GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 Runtimecore: 7a7850ba03155bcf66f96ff15834319b7865ed89 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 wifi_connection: a73e16eb2fe4e40ca32a6d69c8609130603bb62d PODFILE CHECKSUM: 327a920f85ee4988ff213b8c0b4a413604d472f9 From de6afd07733f7b954bebdbbb43d65c9082db6906 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:57:20 -0700 Subject: [PATCH 09/20] sdk upgrade to 300.0 most recent sdk version from esri --- .../jniLibs/arm64-v8a/libarcgis_maps_ffi.so | 1 - .../main/jniLibs/arm64-v8a/libruntimecore.so | 1 - .../main/jniLibs/x86_64/libarcgis_maps_ffi.so | 1 - .../src/main/jniLibs/x86_64/libruntimecore.so | 1 - arcgis_maps_core | 2 +- ios/Podfile.lock | 205 ++++----- ios/Runner.xcodeproj/project.pbxproj | 8 +- pubspec.lock | 424 +++++++++++------- pubspec.yaml | 13 +- 9 files changed, 350 insertions(+), 306 deletions(-) delete mode 120000 android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so delete mode 120000 android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so delete mode 120000 android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so delete mode 120000 android/app/src/main/jniLibs/x86_64/libruntimecore.so diff --git a/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so b/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so deleted file mode 120000 index 1e21672b9..000000000 --- a/android/app/src/main/jniLibs/arm64-v8a/libarcgis_maps_ffi.so +++ /dev/null @@ -1 +0,0 @@ -../../../../../../arcgis_maps_core/android/arm64-v8a/libarcgis_maps_ffi.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so b/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so deleted file mode 120000 index ccf507a89..000000000 --- a/android/app/src/main/jniLibs/arm64-v8a/libruntimecore.so +++ /dev/null @@ -1 +0,0 @@ -../../../../../../arcgis_maps_core/android/arm64-v8a/libruntimecore.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so b/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so deleted file mode 120000 index 0c4280a61..000000000 --- a/android/app/src/main/jniLibs/x86_64/libarcgis_maps_ffi.so +++ /dev/null @@ -1 +0,0 @@ -../../../../../../arcgis_maps_core/android/x86_64/libarcgis_maps_ffi.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libruntimecore.so b/android/app/src/main/jniLibs/x86_64/libruntimecore.so deleted file mode 120000 index f685544a6..000000000 --- a/android/app/src/main/jniLibs/x86_64/libruntimecore.so +++ /dev/null @@ -1 +0,0 @@ -../../../../../../arcgis_maps_core/android/x86_64/libruntimecore.so \ No newline at end of file diff --git a/arcgis_maps_core b/arcgis_maps_core index 18242e155..50b594453 120000 --- a/arcgis_maps_core +++ b/arcgis_maps_core @@ -1 +1 @@ -/Users/alessioyu/.pub-cache/hosted/pub.dev/arcgis_maps-200.7.0+4560/arcgis_maps_core \ No newline at end of file +/Users/alessioyu/.pub-cache/hosted/pub.dev/arcgis_maps-300.0.0+4935/arcgis_maps_core \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e550a2977..9f51f6269 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,92 +1,92 @@ PODS: - app_links (0.0.1): - Flutter - - arcgis_maps (200.7.0.4560): + - arcgis_maps (300.0.0.4935): - arcgis_maps_ffi - Flutter - Runtimecore - - arcgis_maps_ffi (200.7.0.4560) + - arcgis_maps_ffi (300.0.0.4935) - connectivity_plus (0.0.1): - Flutter - device_info_plus (0.0.1): - Flutter - - Firebase/CoreOnly (12.6.0): - - FirebaseCore (~> 12.6.0) - - Firebase/Crashlytics (12.6.0): + - Firebase/CoreOnly (12.12.0): + - FirebaseCore (~> 12.12.0) + - Firebase/Crashlytics (12.12.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.6.0) - - Firebase/Messaging (12.6.0): + - FirebaseCrashlytics (~> 12.12.0) + - Firebase/Messaging (12.12.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.6.0) - - firebase_analytics (12.1.0): + - FirebaseMessaging (~> 12.12.0) + - firebase_analytics (12.3.0): - firebase_core - - FirebaseAnalytics (= 12.6.0) + - FirebaseAnalytics (= 12.12.0) - Flutter - - firebase_core (4.3.0): - - Firebase/CoreOnly (= 12.6.0) + - firebase_core (4.7.0): + - Firebase/CoreOnly (= 12.12.0) - Flutter - - firebase_crashlytics (5.0.6): - - Firebase/Crashlytics (= 12.6.0) + - firebase_crashlytics (5.2.0): + - Firebase/Crashlytics (= 12.12.0) - firebase_core - Flutter - - firebase_messaging (16.1.0): - - Firebase/Messaging (= 12.6.0) + - firebase_messaging (16.2.0): + - Firebase/Messaging (= 12.12.0) - firebase_core - Flutter - - FirebaseAnalytics (12.6.0): - - FirebaseAnalytics/Default (= 12.6.0) - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseAnalytics (12.12.0): + - FirebaseAnalytics/Default (= 12.12.0) + - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (~> 12.12.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) - - GoogleAppMeasurement/Default (= 12.6.0) + - FirebaseAnalytics/Default (12.12.0): + - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (~> 12.12.0) + - GoogleAppMeasurement/Default (= 12.12.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (12.6.0): - - FirebaseCoreInternal (~> 12.6.0) + - FirebaseCore (12.12.1): + - FirebaseCoreInternal (~> 12.12.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreInternal (12.6.0): + - FirebaseCoreExtension (12.12.0): + - FirebaseCore (~> 12.12.0) + - FirebaseCoreInternal (12.12.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) - - FirebaseRemoteConfigInterop (~> 12.6.0) - - FirebaseSessions (~> 12.6.0) + - FirebaseCrashlytics (12.12.1): + - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (~> 12.12.0) + - FirebaseRemoteConfigInterop (~> 12.12.0) + - FirebaseSessions (~> 12.12.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.6.0): - - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (12.12.0): + - FirebaseCore (~> 12.12.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseMessaging (12.12.0): + - FirebaseCore (~> 12.12.0) + - FirebaseInstallations (~> 12.12.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.6.0) - - FirebaseSessions (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreExtension (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (12.12.0) + - FirebaseSessions (12.12.0): + - FirebaseCore (~> 12.12.0) + - FirebaseCoreExtension (~> 12.12.0) + - FirebaseInstallations (~> 12.12.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) @@ -95,9 +95,10 @@ PODS: - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter - - flutter_secure_storage (6.0.0): + - flutter_secure_storage_darwin (10.0.0): - Flutter - - flutter_web_auth_2 (3.0.0): + - FlutterMacOS + - flutter_web_auth_2 (5.0.0): - Flutter - geolocator_apple (1.2.0): - Flutter @@ -108,28 +109,28 @@ PODS: - Flutter - Google-Maps-iOS-Utils (< 7.0, >= 5.0) - GoogleMaps (< 10.0, >= 8.4) - - GoogleAdsOnDeviceConversion (3.2.0): + - GoogleAdsOnDeviceConversion (3.4.2): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.6.0): + - GoogleAppMeasurement/Core (12.12.0): - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.6.0): - - GoogleAdsOnDeviceConversion (~> 3.2.0) - - GoogleAppMeasurement/Core (= 12.6.0) - - GoogleAppMeasurement/IdentitySupport (= 12.6.0) + - GoogleAppMeasurement/Default (12.12.0): + - GoogleAdsOnDeviceConversion (~> 3.4.0) + - GoogleAppMeasurement/Core (= 12.12.0) + - GoogleAppMeasurement/IdentitySupport (= 12.12.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.6.0): - - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (12.12.0): + - GoogleAppMeasurement/Core (= 12.12.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -177,43 +178,15 @@ PODS: - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) - - Runtimecore (200.7.0.4560) + - Runtimecore (300.0.0.4935) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.52.0): - - sqlite3/common (= 3.52.0) - - sqlite3/common (3.52.0) - - sqlite3/dbstatvtab (3.52.0): - - sqlite3/common - - sqlite3/fts5 (3.52.0): - - sqlite3/common - - sqlite3/math (3.52.0): - - sqlite3/common - - sqlite3/perf-threadsafe (3.52.0): - - sqlite3/common - - sqlite3/rtree (3.52.0): - - sqlite3/common - - sqlite3/session (3.52.0): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.52.0) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - sqlite3/session - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -234,17 +207,15 @@ DEPENDENCIES: - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - Runtimecore (from `../arcgis_maps_core/ios/Runtimecore.podspec`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - wifi_connection (from `.symlinks/plugins/wifi_connection/ios`) @@ -270,7 +241,6 @@ SPEC REPOS: - nanopb - PromisesObjC - PromisesSwift - - sqlite3 EXTERNAL SOURCES: app_links: @@ -295,8 +265,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" - flutter_secure_storage: - :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" geolocator_apple: @@ -307,16 +277,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/haptic_feedback/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" Runtimecore: :podspec: "../arcgis_maps_core/ios/Runtimecore.podspec" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqlite3_flutter_libs: - :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: @@ -326,49 +292,46 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: c5161ac5ab5383ad046884568b4b91cb52df5d91 - arcgis_maps: 08507a3e132cdc12a7d7358183aa971d62647ee4 - arcgis_maps_ffi: b1418b0e72ad292ab9314100134da9f175de99f0 + arcgis_maps: 6751f496cf10a6ab40be99650db808fba3c79479 + arcgis_maps_ffi: 0db05f5860c64735cd0bfacd40ea0dc32108dd0d connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 - firebase_analytics: 4f9cca09e65f6c2944a862c6dc86f6bed9fb769c - firebase_core: ba00a168e719694f38960502ceb560285603d073 - firebase_crashlytics: 13f4b77e9ce2a84b1f8ea07f293db5b6213ce1cf - firebase_messaging: bf0e29321927edc02a563c984dbfa5b063864b15 - FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 - FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 - FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f - FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e - FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e - FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad - FirebaseMessaging: a61bc42dcab3f7a346d94bbb54dab2c9435b18b2 - FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 - FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d + Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33 + firebase_analytics: e5f4faccf1f7040d3788e835b7eb7b57dc84a911 + firebase_core: 9156a152117c843440b0b990c785aa0259bc5447 + firebase_crashlytics: e24acd48861c5edf6e0f6c134d6a0b28593c76d7 + firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269 + FirebaseAnalytics: db0dca7e35a503ddf6869e457f4926dc35afe216 + FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 + FirebaseCoreExtension: ff6fd42eb5287e71d3e160450de6509733d9ead7 + FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 + FirebaseCrashlytics: 03f4e20d0c9b7fd6338cb9066f4bfb69d3f42fd0 + FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e + FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 + FirebaseRemoteConfigInterop: 23996ab7397494722df4fdd1fd398024389d5da8 + FirebaseSessions: 804bd321f2d2f2ddafe74ef7856062aa19f179c2 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96 google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264 - GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f - GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee + GoogleAdsOnDeviceConversion: 02fc8fe599acd867e328321effb0d9b2d023a38a + GoogleAppMeasurement: 3b4687de50ab25ee2d4d541849f10ca8df862a12 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 + haptic_feedback: 1f22d7ad3abb43bb814aa42fdf548dc6d9e8245a nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - Runtimecore: 7a7850ba03155bcf66f96ff15834319b7865ed89 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + Runtimecore: 58f0dbf24f3ed9e38aaf552c7723ecb64f78cc1d + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d wifi_connection: a73e16eb2fe4e40ca32a6d69c8609130603bb62d PODFILE CHECKSUM: 327a920f85ee4988ff213b8c0b4a413604d472f9 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 853dc4da3..d3a0320ba 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -319,15 +319,13 @@ "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", - "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", + "${BUILT_PRODUCTS_DIR}/flutter_secure_storage_darwin/flutter_secure_storage_darwin.framework", "${BUILT_PRODUCTS_DIR}/flutter_web_auth_2/flutter_web_auth_2.framework", "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", "${BUILT_PRODUCTS_DIR}/haptic_feedback/haptic_feedback.framework", "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", "${BUILT_PRODUCTS_DIR}/webview_flutter_wkwebview/webview_flutter_wkwebview.framework", "${BUILT_PRODUCTS_DIR}/wifi_connection/wifi_connection.framework", @@ -353,15 +351,13 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_web_auth_2.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/haptic_feedback.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webview_flutter_wkwebview.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wifi_connection.framework", diff --git a/pubspec.lock b/pubspec.lock index 685e33c14..5276b22dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: e4a1b612fd2955908e26116075b3a4baf10c353418ca645b4deae231c82bf144 + sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad url: "https://pub.dev" source: hosted - version: "1.3.65" + version: "1.3.69" analyzer: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: "direct main" description: name: arcgis_maps - sha256: "6f8d34e8dc484b297fe70e729fa774df8b8cf85191933ddc63d1398e63e7be7a" + sha256: "6eda8e19a5580b40c58c39134edfa6e90b649d97298460b31f006457f6fbeaf5" url: "https://pub.dev" source: hosted - version: "200.7.0+4560" + version: "300.0.0+4935" args: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" barcode: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.1.1" build_resolvers: dependency: transitive description: @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: built_value - sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" url: "https://pub.dev" source: hosted - version: "8.12.0" + version: "8.12.5" characters: dependency: transitive description: @@ -177,14 +177,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" collection: dependency: transitive description: @@ -205,10 +213,10 @@ packages: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: @@ -221,10 +229,10 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -245,10 +253,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" desktop_webview_window: dependency: transitive description: @@ -261,10 +269,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -277,34 +285,26 @@ packages: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.2" dio_cache_interceptor: dependency: transitive description: name: dio_cache_interceptor - sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d" - url: "https://pub.dev" - source: hosted - version: "3.5.1" - dio_cache_interceptor_db_store: - dependency: transitive - description: - name: dio_cache_interceptor_db_store - sha256: "2c17538d7c00f62ee74941e497b31187d91d256b63507106ad79f8fcd797a775" + sha256: "3644ce3e0c9ba21885cbb0578b3d2ffe022c8badc6f6f4042cb56ecdbe90a7ad" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "4.0.6" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" dots_indicator: dependency: "direct main" description: @@ -317,10 +317,10 @@ packages: dependency: transitive description: name: drift - sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5" url: "https://pub.dev" source: hosted - version: "2.31.0" + version: "2.32.1" encrypt: dependency: "direct main" description: @@ -341,10 +341,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -357,90 +357,90 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: "8ca4832c7a6d145ce987fd07d6dfbb8c91d9058178342f20de6305fb77b1b40d" + sha256: "6993e54441e96b4de1dd85a159b236bbcd2c2487215c9d02b12c1685ddfded73" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "12.3.0" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: d00234716f415f89eb5c2cefb1238d7fd2f3120275d71414b84ae434dcdb7a19 + sha256: ac2a17484daf380b2247b9874e675ff9e4ac611d839a10b91b7445f764756760 url: "https://pub.dev" source: hosted - version: "5.0.5" + version: "5.1.1" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: e42b294e51aedb4bd4b761a886c8d6b473c44b44aa4c0b47cab06b2c66ac3fba + sha256: "167a3115e71501ba6706235f00e370072920ee8d26b44fbfb9dc8e63c98a8d9b" url: "https://pub.dev" source: hosted - version: "0.6.1+1" + version: "0.6.1+5" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "29cfa93c771d8105484acac340b5ea0835be371672c91405a300303986f4eba9" + sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.7.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.3" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: a631bbfbfa26963d68046aed949df80b228964020e9155b086eff94f462bbf1f + sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.6.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "8d52022ee6fdd224e92c042f297d1fd0ec277195c49f39fa61b8cc500a639f00" + sha256: "43a311b280d9391389a690d10e1ac0d458b965154a57de5be2f0857225aa2016" url: "https://pub.dev" source: hosted - version: "5.0.6" + version: "5.2.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "97c6a97b35e3d3dafe38fb053a65086a1efb125022d292161405848527cc25a4" + sha256: "1b6a921ad6f0d08203ecc1310437a88cec357bc3cad27e1138f1e2c16dd71db9" url: "https://pub.dev" source: hosted - version: "3.8.16" + version: "3.8.20" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "1ad663fbb6758acec09d7e84a2e6478265f0a517f40ef77c573efd5e0089f400" + sha256: e5c93e8e7a9b0513f94bb684d2cf100e32e7dcdf2949574386b1955fc9a9b96a url: "https://pub.dev" source: hosted - version: "16.1.0" + version: "16.2.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: ea620e841fbcec62a96984295fc628f53ef5a8da4f53238159719ed0af7db834 + sha256: "8cbb7d842e5071bba836452aff262f7db4b14bb3a0d00c1896cf176df886d65a" url: "https://pub.dev" source: hosted - version: "4.7.5" + version: "4.7.9" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "7d0fb6256202515bba8489a3d69c6bc9d52d69a4999bad789053b486c8e7323e" + sha256: "8750bacf50573c0383535fc3f9c58c6a2f9dff5320a16a82c30631b9dad894f1" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.5" fixnum: dependency: transitive description: @@ -515,58 +515,58 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" url: "https://pub.dev" source: hosted - version: "2.0.31" + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.4" - flutter_secure_storage_linux: + version: "10.0.0" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: + version: "0.2.0" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_sticky_header: dependency: "direct main" description: @@ -579,10 +579,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -592,18 +592,18 @@ packages: dependency: transitive description: name: flutter_web_auth_2 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.2" flutter_web_auth_2_platform_interface: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -617,22 +617,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" geolocator: dependency: "direct main" description: name: geolocator - sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" url: "https://pub.dev" source: hosted - version: "13.0.4" + version: "14.0.2" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "5.0.2" geolocator_apple: dependency: transitive description: @@ -641,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -693,18 +709,18 @@ packages: dependency: "direct main" description: name: google_maps_flutter - sha256: "819985697596a42e1054b5feb2f407ba1ac92262e02844a40168e742b9f36dca" + sha256: fc714bf8072e2c121d4277cb6dca23bbfae954b6c7b5d6dd73f1bc8d09762921 url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "2.17.0" google_maps_flutter_android: dependency: transitive description: name: google_maps_flutter_android - sha256: "7c7ff5b883b27bfdd0d52d91d89faf00858a6c1b33aeca0dc80faca64f389983" + sha256: f1eb5ffa34ba41f8591e53ce439f78af179a506e8386a1297d0ecd202e05c734 url: "https://pub.dev" source: hosted - version: "2.18.3" + version: "2.19.8" google_maps_flutter_ios: dependency: "direct overridden" description: @@ -717,18 +733,18 @@ packages: dependency: transitive description: name: google_maps_flutter_platform_interface - sha256: f4b9b44f7b12a1f6707ffc79d082738e0b7e194bf728ee61d2b3cdf5fdf16081 + sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "2.15.0" google_maps_flutter_web: dependency: transitive description: name: google_maps_flutter_web - sha256: "53e5dbf73ff04153acc55a038248706967c21d5b6ef6657a57fce2be73c2895a" + sha256: "6cefe4ef4cc61dc0dfba4c413dec4bd105cb6b9461bfbe1465ddd09f80af377d" url: "https://pub.dev" source: hosted - version: "0.5.14+2" + version: "0.6.2" graphs: dependency: transitive description: @@ -737,6 +753,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" gtk: dependency: transitive description: @@ -749,10 +773,10 @@ packages: dependency: transitive description: name: haptic_feedback - sha256: "3422bf2a55c541c9e3f029197b9a94d262d2dda19191a022eb310e5dd922e9e0" + sha256: dcc2494994c41428823f8f2082fd17a4e89e372bd142e07681420cbfaf99dcad url: "https://pub.dev" source: hosted - version: "0.5.1+2" + version: "0.6.4+3" hive: dependency: "direct main" description: @@ -777,6 +801,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" html: dependency: transitive description: @@ -789,10 +821,26 @@ packages: dependency: transitive description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" + http_cache_core: + dependency: transitive + description: + name: http_cache_core + sha256: ff0b6e6c3766d774d59b806f928b39e6a48f7b2c47ae1fe27410bfd792bee511 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + http_cache_drift_store: + dependency: transitive + description: + name: http_cache_drift_store + sha256: "369497975bb21b62410da601907e94bdf748bf9d0f189e14449ebf0592748ddb" + url: "https://pub.dev" + source: hosted + version: "7.0.0" http_multi_server: dependency: transitive description: @@ -825,22 +873,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -901,10 +965,10 @@ packages: dependency: transitive description: name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.3.1" matcher: dependency: transitive description: @@ -945,6 +1009,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mutex: + dependency: transitive + description: + name: mutex + sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nested: dependency: transitive description: @@ -961,6 +1041,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -973,10 +1061,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1013,18 +1101,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.19" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1109,10 +1197,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -1133,10 +1221,10 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "4.0.0" pool: dependency: transitive description: @@ -1177,6 +1265,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" sanitize_html: dependency: transitive description: @@ -1189,26 +1285,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.13" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1221,10 +1317,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1282,26 +1378,18 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqlite3: dependency: transitive description: name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" url: "https://pub.dev" source: hosted - version: "0.5.42" + version: "3.3.1" stack_trace: dependency: transitive description: @@ -1386,34 +1474,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.dev" source: hosted - version: "6.3.20" + version: "6.3.29" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1426,26 +1514,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" value_layout_builder: dependency: "direct overridden" description: @@ -1458,10 +1546,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.21" vector_graphics_codec: dependency: transitive description: @@ -1474,10 +1562,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -1498,18 +1586,18 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.1.0" watcher: dependency: transitive description: name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.1" web: dependency: transitive description: @@ -1538,34 +1626,34 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 url: "https://pub.dev" source: hosted - version: "4.13.0" + version: "4.13.1" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: "9a25f6b4313978ba1c2cda03a242eea17848174912cfb4d2d8ee84a556f248e3" + sha256: f560f57d0f529c1dcdaf4edc3a3217b099560622f9f4a10b6bdbb566553c61ea url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "2.15.1" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + sha256: a68868ac4828a5f012bf81e8bd25d879f3cec5bd5301575466caafbf9a320a65 url: "https://pub.dev" source: hosted - version: "3.23.0" + version: "3.24.5" wifi_connection: dependency: "direct main" description: @@ -1624,5 +1712,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.32.0" + dart: ">=3.11.1 <4.0.0" + flutter: ">=3.41.3" diff --git a/pubspec.yaml b/pubspec.yaml index 22606facc..f44275eb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: ref: master flutter_linkify: 6.0.0 flutter_local_notifications: ^19.5.0 - flutter_secure_storage: ^9.2.4 + flutter_secure_storage: ^10.0.0 flutter_sticky_header: ^0.8.0 get: 4.7.2 google_maps_flutter: ^2.14.0 @@ -31,13 +31,13 @@ dependencies: intl: ^0.20.2 liquid_progress_indicator_v2: 0.5.0 measured_size: 1.0.0 - geolocator: ^13.0.4 - package_info_plus: ^8.3.1 + geolocator: ^14.0.0 + package_info_plus: ^9.0.0 flutter_markdown: ^0.6.18 flutter_svg: ^2.2.0 percent_indicator: ^4.2.5 permission_handler: ^12.0.1 - pointycastle: ^3.9.0 + pointycastle: ^4.0.0 provider: ^6.1.5+1 shared_preferences: ^2.5.3 app_links: ^3.0.0 @@ -48,9 +48,10 @@ dependencies: git: url: https://github.com/UCSD/wifi_connection.git ref: e0fb416f81daedeabb1eedef22041e67f30f792b - device_info_plus: ^11.5.0 - arcgis_maps: ^200.7.0+4560 + device_info_plus: ^12.1.0 + arcgis_maps: ^300.0.0 dependency_overrides: + pointycastle: ^4.0.0 google_maps_flutter_ios: 2.15.7 value_layout_builder: ^0.5.0 # Fix Flutter 3.32.0 compatibility dev_dependencies: From e04b9aeb70691a3391b4cf4e13b40c5fae0cb5ae Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:49:38 -0700 Subject: [PATCH 10/20] 3d building extrusions temp: building layer is from AGE because I'm waiting for AGO key permissions to be updated. Also, still figuring out why geisel is floating in the air. --- lib/ui/esrimap/esrimap.dart | 77 ++++++++++++++++--- lib/ui/esrimap/esrimap_scene.dart | 119 ++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 lib/ui/esrimap/esrimap_scene.dart diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 270fc3910..9a0d4b733 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'esrimap_basemaps.dart'; +import 'esrimap_scene.dart'; // ----------------------------------------------------------------------------- // Model @@ -239,6 +240,10 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { bool _showCampusDistricts = false; ArcGISMapImageLayer? _campusDistrictsLayer; + // 3D scene toggle + bool _show3D = false; + EsriSceneWidget? _sceneWidget; + @override bool get wantKeepAlive => true; @@ -289,8 +294,12 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { void _onMapViewReady() { _mapViewController.arcGISMap = _map; _mapViewController.interactionOptions.rotateEnabled = false; - _mapViewController.graphicsOverlays.add(_graphicsOverlay); - _mapViewController.graphicsOverlays.add(_routeGraphicsOverlay); + if (!_mapViewController.graphicsOverlays.contains(_graphicsOverlay)) { + _mapViewController.graphicsOverlays.add(_graphicsOverlay); + } + if (!_mapViewController.graphicsOverlays.contains(_routeGraphicsOverlay)) { + _mapViewController.graphicsOverlays.add(_routeGraphicsOverlay); + } // Wire up location display — blue dot, no auto-pan on start _mapViewController.locationDisplay.dataSource = _locationDataSource; @@ -327,6 +336,33 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); } + void _toggle3D() { + double lat = 32.8801; + double lng = -117.2340; + + final center = _mapViewController.visibleArea?.extent.center; + if (center != null) { + final wgs = GeometryEngine.project( + center, + outputSpatialReference: SpatialReference.wgs84, + ) as ArcGISPoint?; + if (wgs != null) { + lat = wgs.y; + lng = wgs.x; + } + } + + setState(() { + if (_sceneWidget == null) { + _sceneWidget = EsriSceneWidget( + initialLatitude: lat, + initialLongitude: lng, + ); + } + _show3D = !_show3D; + }); + } + void _toggleCampusDistricts() { if (_showCampusDistricts) { if (_campusDistrictsLayer != null) { @@ -2286,17 +2322,23 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { return Scaffold( body: Stack( children: [ - // Map — Listener detects pointer-down to collapse slideovers + // Map — swaps between 2D ArcGISMapView and 3D ArcGISSceneView Column( children: [ Expanded( - child: Listener( - onPointerDown: _onMapPointerDown, - child: ArcGISMapView( - controllerProvider: () => _mapViewController, - onMapViewReady: _onMapViewReady, - onTap: _onMapTap, - ), + child: IndexedStack( + index: _show3D ? 1 : 0, + children: [ + Listener( + onPointerDown: _onMapPointerDown, + child: ArcGISMapView( + controllerProvider: () => _mapViewController, + onMapViewReady: _onMapViewReady, + onTap: _onMapTap, + ), + ), + _sceneWidget ?? const SizedBox.shrink(), + ], ), ), ], @@ -2724,6 +2766,21 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ + // 3D/2D toggle FAB + FloatingActionButton.small( + heroTag: 'toggle3DBtn', + backgroundColor: _show3D + ? Theme.of(context).colorScheme.primary + : (isDark ? Colors.grey[800] : null), + foregroundColor: _show3D + ? Colors.white + : (isDark ? Colors.white : null), + onPressed: _toggle3D, + child: Icon( + _show3D ? Icons.map_outlined : Icons.view_in_ar_outlined, + ), + ), + const SizedBox(height: 10), // Basemap picker menu (shown above the layers FAB when open) if (_showBasemapMenu) ...[ _buildBasemapMenu(context), diff --git a/lib/ui/esrimap/esrimap_scene.dart b/lib/ui/esrimap/esrimap_scene.dart new file mode 100644 index 000000000..9f6c1a92f --- /dev/null +++ b/lib/ui/esrimap/esrimap_scene.dart @@ -0,0 +1,119 @@ +import 'package:arcgis_maps/arcgis_maps.dart'; +import 'package:flutter/material.dart'; + +import 'esrimap_basemaps.dart'; + +class EsriSceneWidget extends StatefulWidget { + final double initialLatitude; + final double initialLongitude; + + const EsriSceneWidget({ + super.key, + required this.initialLatitude, + required this.initialLongitude, + }); + + @override + State createState() => _EsriSceneWidgetState(); +} + +class _EsriSceneWidgetState extends State { + late final ArcGISSceneViewController _sceneViewController; + + @override + void initState() { + super.initState(); + _sceneViewController = ArcGISSceneView.createController(); + _sceneViewController.interactionOptions.rotateEnabled = false; + } + + SimpleRenderer _makeExtrusionRenderer(String heightField) { + final symbol = SimpleFillSymbol( + style: SimpleFillSymbolStyle.solid, + color: const Color(0xFFD3D3D3), + ); + final renderer = SimpleRenderer(symbol: symbol); + renderer.sceneProperties = RendererSceneProperties.withExtrusionProperties( + extrusionExpression: '[$heightField] * 0.3048', + extrusionMode: ExtrusionMode.maximum, + ); + return renderer; + } + + void _onSceneViewReady() { + final scene = ArcGISScene.withBasemap(buildBasemap(BasemapType.defaultMap)); + + final elevationSource = ArcGISTiledElevationSource.withUri( + Uri.parse('https://elevation3d.arcgis.com/arcgis/rest/services/' + 'WorldElevation3D/Terrain3D/ImageServer'), + ); + scene.baseSurface.elevationSources.add(elevationSource); + scene.baseSurface.isEnabled = true; + + final layerDefs = [ + ( + url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Hosted/LRT/FeatureServer/0', + heightField: 'Height', + placement: SurfacePlacement.relative, + ), + ( + url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Hosted/GeiselLibrary/FeatureServer/0', + heightField: 'Height', + placement: SurfacePlacement.absolute, + ), + ( + url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Hosted/Building_Extrusions/FeatureServer/0', + heightField: 'bldght', + placement: SurfacePlacement.relative, + ), + ( + url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Hosted/Building_Extrusions/FeatureServer/1', + heightField: 'bldght', + placement: SurfacePlacement.relative, + ), + ( + url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'Hosted/Building_Extrusions/FeatureServer/2', + heightField: 'bldght', + placement: SurfacePlacement.relative, + ), + ]; + + for (final def in layerDefs) { + final table = ServiceFeatureTable.withUri(Uri.parse(def.url)); + final layer = FeatureLayer.withFeatureTable(table); + layer.renderer = _makeExtrusionRenderer(def.heightField); + layer.sceneProperties.surfacePlacement = def.placement; + scene.operationalLayers.add(layer); + } + + final camera = Camera.withLatLong( + latitude: widget.initialLatitude, + longitude: widget.initialLongitude, + altitude: 600.0, + heading: 0.0, + pitch: 65.0, + roll: 0.0, + ); + scene.initialViewpoint = Viewpoint.withLatLongScaleCamera( + latitude: widget.initialLatitude, + longitude: widget.initialLongitude, + scale: 10000.0, + camera: camera, + ); + + _sceneViewController.arcGISScene = scene; + } + + @override + Widget build(BuildContext context) { + return ArcGISSceneView( + controllerProvider: () => _sceneViewController, + onSceneViewReady: _onSceneViewReady, + ); + } +} From f34bf50c4bf2100f8a02e9e5e5867236584f796a Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 4 May 2026 11:21:18 -0700 Subject: [PATCH 11/20] api calls via lambda --- lib/ui/esrimap/esrimap.dart | 235 +++++++++++---------------- lib/ui/esrimap/esrimap_basemaps.dart | 6 +- 2 files changed, 100 insertions(+), 141 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 9a0d4b733..e5905f335 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:math' as math; +import 'dart:async'; import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -72,7 +73,7 @@ enum MapSearchSource { building, poi } class _SearchCategory { final String label; final IconData icon; - final String poiClassValue; // maps to POI "Class" field value + final String poiClassValue; const _SearchCategory({ required this.label, @@ -170,12 +171,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final _detailSheetController = DraggableScrollableController(); // Service endpoints - static const _buildingsQueryUrl = - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'AdministrationServices/Buildings_Public/MapServer/0/query'; - static const _poiQueryUrl = - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Points_Of_Interest/FeatureServer/0/query'; + static const _lambdaUrl = "https://i0slpyw2gb.execute-api.us-west-2.amazonaws.com/default/ArcGIS-Map"; // SharedPreferences key for recent searches static const _recentSearchesKey = 'esri_map_recent_searches'; @@ -239,6 +235,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { bool _showBasemapMenu = false; bool _showCampusDistricts = false; ArcGISMapImageLayer? _campusDistrictsLayer; + final _mapReadyCompleter = Completer(); // 3D scene toggle bool _show3D = false; @@ -253,10 +250,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _initMap(); _loadRecentSearches(); _fetchAllPoiClasses(); - // Listen for focus changes to show/hide suggestions _focusNode.addListener(_onFocusChanged); - _fromFocusNode.addListener(_onFromFocusChanged); - _toFocusNode.addListener(_onToFocusChanged); } @override @@ -272,10 +266,13 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { super.dispose(); } + void _setupAgeAuthChallengeHandler() { + ArcGISEnvironment + .authenticationManager + .arcGISAuthenticationChallengeHandler = _AgeAuthChallengeHandler(_callLambda); + } + void _initMap() { - // Build all three basemaps up front. The default is applied immediately; - // the other two are preloaded in the background by - // _preloadAlternateBasemaps() once the MapView is ready. for (final type in BasemapType.values) { _basemaps[type] = buildBasemap(type); } @@ -289,9 +286,12 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ), scale: 24000, ); + _mapReadyCompleter.complete(); } - void _onMapViewReady() { + void _onMapViewReady() async { + await _mapReadyCompleter.future; + _setupAgeAuthChallengeHandler(); _mapViewController.arcGISMap = _map; _mapViewController.interactionOptions.rotateEnabled = false; if (!_mapViewController.graphicsOverlays.contains(_graphicsOverlay)) { @@ -496,153 +496,69 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // Query helpers // --------------------------------------------------------------------------- - String _escSql(String input) => input.replaceAll("'", "''"); - - /// Query the Buildings (AGE) MapServer - Future> _queryBuildings(String query) async { - await dotenv.load(fileName: ".env"); - final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; - final escaped = _escSql(query); - final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " - "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; - - final uri = Uri.parse(_buildingsQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '8', - 'f': 'json', - if (token.isNotEmpty) 'token': token, - }, + /// POST to the Lambda map handler. + Future> _callLambda(Map payload) async { + final response = await http.post( + Uri.parse(_lambdaUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), ); + if (response.statusCode != 200) { + throw Exception('Lambda error ${response.statusCode}: ${response.body}'); + } + return jsonDecode(response.body) as Map; + } - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final name = - (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; - final alias = (attrs['BuildingAliases'] as String?) ?? ''; - final street = (attrs['StreetAddress'] as String?) ?? ''; - final city = (attrs['City'] as String?) ?? ''; - final zip = (attrs['Zipcode'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final addressParts = [ - if (street.isNotEmpty) street, - if (city.isNotEmpty) city, - if (zip.isNotEmpty) zip, - ]; - final fullAddress = addressParts.join(', '); - final subtitle = alias.isNotEmpty ? alias : 'Building'; - + Future> _queryBuildings(String query) async { + final data = await _callLambda({'action': 'searchBuildings', 'query': query}); + return (data['results'] as List? ?? []).map((r) { return MapSearchResult( - name: name, - subtitle: subtitle, - latitude: lat, - longitude: lng, + name: r['name'] as String? ?? 'Unknown Building', + subtitle: r['subtitle'] as String? ?? 'Building', + latitude: (r['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (r['longitude'] as num?)?.toDouble() ?? 0.0, source: MapSearchSource.building, - address: fullAddress, + address: r['address'] as String? ?? '', ); }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); } - /// Fetches all distinct POI Class values from the FeatureServer and caches them. Future _fetchAllPoiClasses() async { try { - final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { - 'where': '1=1', - 'outFields': 'Class', - 'returnDistinctValues': 'true', - 'orderByFields': 'Class', - 'returnGeometry': 'false', - 'resultRecordCount': '200', - 'f': 'json', - }); - final response = await http.get(uri); - if (response.statusCode != 200) return; - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - final classes = features - .map((f) => (f['attributes']['Class'] as String?) ?? '') - .where((c) => c.isNotEmpty) - .toList() - ..sort(); + final data = await _callLambda({'action': 'fetchAllPoiClasses'}); + final classes = (data['classes'] as List? ?? []) + .map((c) => c as String) + .toList(); setState(() => _allPoiClasses = classes); - print(_allPoiClasses); } catch (e) { debugPrint('Failed to fetch POI classes: $e'); } } - /// Query the POIs (AGO) FeatureServer by text search. Future> _queryPOIs(String query) async { - final escaped = _escSql(query); - final where = "UpdatedName LIKE '%$escaped%' " - "OR C3DName LIKE '%$escaped%' " - "OR C3DKeywords LIKE '%$escaped%'"; - return _executePOIQuery(where); + final data = await _callLambda({'action': 'searchPOI', 'query': query}); + return _parsePOIResults(data); } - /// Query POIs filtered by a specific Class value (for category taps). Future> _queryPOIsByClass(String classValue) async { - final escaped = _escSql(classValue); - final where = "Class = '$escaped'"; - return _executePOIQuery(where, maxResults: 100); + final data = await _callLambda({ + 'action': 'searchPOIByClass', + 'classValue': classValue, + 'maxResults': 100, + }); + return _parsePOIResults(data); } - /// Shared POI query execution. - Future> _executePOIQuery( - String where, { - int maxResults = 8, - }) async { - final uri = Uri.parse(_poiQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '$maxResults', - 'f': 'json', - }, - ); - - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final updatedName = (attrs['UpdatedName'] as String?) ?? ''; - final c3dName = (attrs['C3DName'] as String?) ?? ''; - final name = updatedName.isNotEmpty ? updatedName : c3dName; - final poiClass = (attrs['Class'] as String?) ?? ''; - final subclass = (attrs['Subclass'] as String?) ?? ''; - final description = (attrs['C3DDescription'] as String?) ?? ''; - final url = (attrs['URL'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final subtitle = - subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; - + List _parsePOIResults(Map data) { + return (data['results'] as List? ?? []).map((r) { return MapSearchResult( - name: name.isNotEmpty ? name : 'Unknown POI', - subtitle: subtitle, - latitude: lat, - longitude: lng, + name: r['name'] as String? ?? 'Unknown POI', + subtitle: r['subtitle'] as String? ?? '', + latitude: (r['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (r['longitude'] as num?)?.toDouble() ?? 0.0, source: MapSearchSource.poi, - description: description, - websiteUrl: url.isNotEmpty ? url : null, + description: r['description'] as String? ?? '', + websiteUrl: r['websiteUrl'] as String?, ); }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); } @@ -2864,4 +2780,49 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ), ); } +} +class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { + final Future> Function(Map) callLambda; + + _AgeAuthChallengeHandler(this.callLambda); + + @override + Future handleArcGISAuthenticationChallenge( + ArcGISAuthenticationChallenge challenge, + ) async { + try { + final data = await callLambda({'action': 'getTokens'}); + final token = data['age']?['token'] as String?; + final expiresIn = data['age']?['expires_in'] as int?; + + if (token == null) { + challenge.continueAndFail(); + return; + } + + final tokenInfo = TokenInfo.create( + accessToken: token, + expirationDate: DateTime.now().add( + Duration(seconds: expiresIn ?? 7200), + ), + isSslRequired: true, + ); + + if (tokenInfo == null) { + challenge.continueAndFail(); + return; + } + + final credential = PregeneratedTokenCredential( + uri: challenge.requestUri, + tokenInfo: tokenInfo, + referer: '', + ); + + challenge.continueWithCredential(credential); + } catch (e) { + debugPrint('AGE auth challenge failed: $e'); + challenge.continueAndFail(); + } + } } \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_basemaps.dart b/lib/ui/esrimap/esrimap_basemaps.dart index 05ad76d72..ff3afc855 100644 --- a/lib/ui/esrimap/esrimap_basemaps.dart +++ b/lib/ui/esrimap/esrimap_basemaps.dart @@ -65,14 +65,12 @@ final _agePortal = Portal( Uri.parse('https://admin-enterprise-gis.ucsd.edu/portal'), ); -/// Build a basemap of the given type. Safe to call multiple times; each -/// call returns an independent Basemap instance with freshly constructed -/// layers. + + Basemap buildBasemap(BasemapType type) { final basemap = Basemap(); if (type == BasemapType.satellite) { - // Light gray fallback + Nearmap imagery + campus vector basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri))); basemap.baseLayers.add(ArcGISMapImageLayer.withUri(Uri.parse(_nearmapUri))); } else { From 5a7b9996ebf0369090328640fc80020cb9344436 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 4 May 2026 11:26:23 -0700 Subject: [PATCH 12/20] age auth token caching --- lib/ui/esrimap/esrimap.dart | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index e5905f335..57e815deb 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -2781,44 +2781,52 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ); } } + class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { final Future> Function(Map) callLambda; + + String? _cachedToken; + DateTime? _tokenExpiry; _AgeAuthChallengeHandler(this.callLambda); + Future _getToken() async { + if (_cachedToken != null && + _tokenExpiry != null && + DateTime.now().isBefore(_tokenExpiry!.subtract(const Duration(minutes: 5)))) { + return _cachedToken; + } + final data = await callLambda({'action': 'getTokens'}); + _cachedToken = data['age']?['token'] as String?; + final expiresIn = data['age']?['expires_in'] as int? ?? 7200; + _tokenExpiry = DateTime.now().add(Duration(seconds: expiresIn)); + return _cachedToken; + } + @override Future handleArcGISAuthenticationChallenge( ArcGISAuthenticationChallenge challenge, ) async { try { - final data = await callLambda({'action': 'getTokens'}); - final token = data['age']?['token'] as String?; - final expiresIn = data['age']?['expires_in'] as int?; - + final token = await _getToken(); if (token == null) { challenge.continueAndFail(); return; } - final tokenInfo = TokenInfo.create( accessToken: token, - expirationDate: DateTime.now().add( - Duration(seconds: expiresIn ?? 7200), - ), + expirationDate: _tokenExpiry!, isSslRequired: true, ); - if (tokenInfo == null) { challenge.continueAndFail(); return; } - final credential = PregeneratedTokenCredential( uri: challenge.requestUri, tokenInfo: tokenInfo, referer: '', ); - challenge.continueWithCredential(credential); } catch (e) { debugPrint('AGE auth challenge failed: $e'); From 88b98289160589050ac22702a692c6e39b85a806 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 4 May 2026 13:54:50 -0700 Subject: [PATCH 13/20] separated files, android upgrade for support --- android/app/build.gradle | 2 +- android/settings.gradle | 2 +- lib/ui/esrimap/esrimap.dart | 330 +++++++++-------------- lib/ui/esrimap/esrimap_fab.dart | 97 +++++++ lib/ui/esrimap/esrimap_layers_panel.dart | 304 +++++++++++++++++++++ lib/ui/esrimap/esrimap_scene.dart | 96 +------ 6 files changed, 545 insertions(+), 286 deletions(-) create mode 100644 lib/ui/esrimap/esrimap_fab.dart create mode 100644 lib/ui/esrimap/esrimap_layers_panel.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 7e970a7de..41b1347bf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "edu.ucsd" compileSdk = 36 - ndkVersion = "27.0.12077973" + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/android/settings.gradle b/android/settings.gradle index a7ce4fe0c..868031436 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.9.1" apply false - id "org.jetbrains.kotlin.android" version "2.1.10" apply false + id "org.jetbrains.kotlin.android" version "2.3.0" apply false } include ":app" diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 57e815deb..d86ae9e56 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -9,6 +9,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'esrimap_basemaps.dart'; +import 'esrimap_fab.dart'; +import 'esrimap_layers_panel.dart'; import 'esrimap_scene.dart'; // ----------------------------------------------------------------------------- @@ -232,14 +234,24 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // Basemap switcher BasemapType _currentBasemapType = BasemapType.defaultMap; final Map _basemaps = {}; - bool _showBasemapMenu = false; + bool _showLayersPanel = false; + + // Operational layers bool _showCampusDistricts = false; ArcGISMapImageLayer? _campusDistrictsLayer; + bool _showConstruction = false; + ArcGISMapImageLayer? _constructionLayer; + bool _loadingConstruction = false; + bool _showAssemblyAreas = false; + ArcGISMapImageLayer? _assemblyAreasLayer; + bool _loadingAssemblyAreas = false; + final _mapReadyCompleter = Completer(); - // 3D scene toggle - bool _show3D = false; - EsriSceneWidget? _sceneWidget; + // Scene mode: 'Default' | '3D Building' | 'Drone View' + String _sceneMode = 'Default'; + EsriSceneWidget? _scene3DWidget; + EsriSceneWidget? _sceneDroneWidget; @override bool get wantKeepAlive => true; @@ -293,7 +305,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { await _mapReadyCompleter.future; _setupAgeAuthChallengeHandler(); _mapViewController.arcGISMap = _map; - _mapViewController.interactionOptions.rotateEnabled = false; + _mapViewController.interactionOptions.rotateEnabled = true; if (!_mapViewController.graphicsOverlays.contains(_graphicsOverlay)) { _mapViewController.graphicsOverlays.add(_graphicsOverlay); } @@ -321,10 +333,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _switchBasemap(BasemapType newType) { - if (newType == _currentBasemapType) { - setState(() => _showBasemapMenu = false); - return; - } + if (newType == _currentBasemapType) return; final newBasemap = _basemaps[newType]; if (newBasemap == null) return; @@ -332,34 +341,24 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { setState(() { _map.basemap = newBasemap; _currentBasemapType = newType; - _showBasemapMenu = false; }); } - void _toggle3D() { - double lat = 32.8801; - double lng = -117.2340; - - final center = _mapViewController.visibleArea?.extent.center; - if (center != null) { - final wgs = GeometryEngine.project( - center, - outputSpatialReference: SpatialReference.wgs84, - ) as ArcGISPoint?; - if (wgs != null) { - lat = wgs.y; - lng = wgs.x; - } - } - + void _setSceneMode(String mode) { + if (mode == _sceneMode) return; setState(() { - if (_sceneWidget == null) { - _sceneWidget = EsriSceneWidget( - initialLatitude: lat, - initialLongitude: lng, + _sceneMode = mode; + if (mode == '3D Building' && _scene3DWidget == null) { + _scene3DWidget = const EsriSceneWidget( + portalUri: 'https://ucsd-admin.maps.arcgis.com', + itemId: 'a0a255ad97534836aa9e159d4a546bfc', + ); + } else if (mode == 'Drone View' && _sceneDroneWidget == null) { + _sceneDroneWidget = const EsriSceneWidget( + portalUri: 'https://admin-enterprise-gis.ucsd.edu/portal', + itemId: '0ffe293479844ce49ff5c30ffc0a0b67', ); } - _show3D = !_show3D; }); } @@ -385,6 +384,62 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } } + void _toggleConstruction() async { + if (_showConstruction) { + if (_constructionLayer != null) { + _map.operationalLayers.remove(_constructionLayer!); + } + setState(() => _showConstruction = false); + } else { + setState(() => _loadingConstruction = true); + if (_constructionLayer == null) { + _constructionLayer = ArcGISMapImageLayer.withUri(Uri.parse( + // TODO: replace with the real AGE construction layer URL + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'CampusServices/Construction_Impacts/MapServer', + )); + } + _map.operationalLayers.add(_constructionLayer!); + try { + await _constructionLayer!.load(); + } catch (e) { + debugPrint('Construction layer load error: $e'); + } + setState(() { + _showConstruction = true; + _loadingConstruction = false; + }); + } + } + + void _toggleAssemblyAreas() async { + if (_showAssemblyAreas) { + if (_assemblyAreasLayer != null) { + _map.operationalLayers.remove(_assemblyAreasLayer!); + } + setState(() => _showAssemblyAreas = false); + } else { + setState(() => _loadingAssemblyAreas = true); + if (_assemblyAreasLayer == null) { + _assemblyAreasLayer = ArcGISMapImageLayer.withUri(Uri.parse( + // TODO: replace with the real AGE assembly areas layer URL + 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' + 'CampusServices/Assembly_Areas/MapServer', + )); + } + _map.operationalLayers.add(_assemblyAreasLayer!); + try { + await _assemblyAreasLayer!.load(); + } catch (e) { + debugPrint('Assembly areas layer load error: $e'); + } + setState(() { + _showAssemblyAreas = true; + _loadingAssemblyAreas = false; + }); + } + } + Future _startLocationDisplay() async { try { await _locationDataSource.start(); @@ -1515,6 +1570,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // --------------------------------------------------------------------------- void _onMapPointerDown(PointerDownEvent _) { + // Close layers panel when user touches the map + if (_showLayersPanel) { + setState(() => _showLayersPanel = false); + return; + } // Collapse whichever sheet is active to the minimum snap if (_showCategoryList && _selectedResult == null) { if (_categorySheetController.isAttached) { @@ -1644,78 +1704,6 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ); } - Widget _buildBasemapMenu(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bgColor = isDark ? Colors.grey[850] : Colors.white; - final textColor = isDark ? Colors.white : Colors.grey[900]; - final labelColor = isDark ? Colors.grey[500]! : Colors.grey[500]!; - final accent = - isDark ? Colors.lightBlue[300]! : Theme.of(context).colorScheme.primary; - - Widget sectionLabel(String text) => Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Text( - text, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: labelColor, - ), - ), - ); - - Widget menuRow(String label, bool selected, VoidCallback onTap) => InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - color: textColor, - ), - ), - SizedBox( - width: 16, - child: selected - ? Icon(Icons.check, size: 16, color: accent) - : null, - ), - ], - ), - ), - ); - - return SizedBox( - width: 160, - child: Material( - elevation: 6, - borderRadius: BorderRadius.circular(12), - color: bgColor, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - sectionLabel('BASEMAP'), - ...BasemapType.values.map((type) => menuRow( - basemapOptions[type]!.label, - type == _currentBasemapType, - () => _switchBasemap(type), - )), - const Divider(height: 1), - sectionLabel('LAYERS'), - menuRow('Campus Districts', _showCampusDistricts, _toggleCampusDistricts), - ], - ), - ), - ); - } - // --------------------------------------------------------------------------- // List view // --------------------------------------------------------------------------- @@ -2234,6 +2222,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { Widget build(BuildContext context) { super.build(context); final isDark = Theme.of(context).brightness == Brightness.dark; + final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; return Scaffold( body: Stack( @@ -2243,7 +2232,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { children: [ Expanded( child: IndexedStack( - index: _show3D ? 1 : 0, + index: _sceneMode == '3D Building' ? 1 + : _sceneMode == 'Drone View' ? 2 + : 0, children: [ Listener( onPointerDown: _onMapPointerDown, @@ -2253,14 +2244,16 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { onTap: _onMapTap, ), ), - _sceneWidget ?? const SizedBox.shrink(), + _scene3DWidget ?? const SizedBox.shrink(), + _sceneDroneWidget ?? const SizedBox.shrink(), ], ), ), ], ), - // Floating search bar + dropdown + // Floating search bar + dropdown — hidden in 3D/Drone View modes + if (_sceneMode == 'Default') Positioned( top: 8, left: 12, @@ -2673,99 +2666,24 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { child: _buildSeeAllButton(context), ), ), - // Bottom-right FAB cluster: list view button + info button - if (_selectedResult == null) + // Bottom-right FAB cluster — hidden when keyboard or layers panel is active + if (_selectedResult == null && !keyboardVisible && !_showLayersPanel) Positioned( right: 16, bottom: 32, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // 3D/2D toggle FAB - FloatingActionButton.small( - heroTag: 'toggle3DBtn', - backgroundColor: _show3D - ? Theme.of(context).colorScheme.primary - : (isDark ? Colors.grey[800] : null), - foregroundColor: _show3D - ? Colors.white - : (isDark ? Colors.white : null), - onPressed: _toggle3D, - child: Icon( - _show3D ? Icons.map_outlined : Icons.view_in_ar_outlined, - ), - ), - const SizedBox(height: 10), - // Basemap picker menu (shown above the layers FAB when open) - if (_showBasemapMenu) ...[ - _buildBasemapMenu(context), - const SizedBox(height: 10), - ], - // Layers FAB — toggles the basemap menu - FloatingActionButton.small( - heroTag: 'layersBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: () { - setState(() => _showBasemapMenu = !_showBasemapMenu); - }, - child: const Icon(Icons.layers_outlined), - ), - const SizedBox(height: 10), - // List view button — only when a category search is active - if (_allCategoryResults.isNotEmpty) ...[ - FloatingActionButton.small( - heroTag: 'listBtn', - onPressed: () { - setState(() { - _showCategoryList = !_showCategoryList; - }); - }, - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - child: const Icon(Icons.list), - ), - const SizedBox(height: 10), - ], - if (_lastSelectedResult != null) - FloatingActionButton.small( - heroTag: 'infoBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _reopenDetail, - child: const Icon(Icons.info_outline), - ), - if (_showRouteFields || _hasRoute) ...[ - FloatingActionButton.small( - heroTag: 'clearRouteBtn', - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - onPressed: _clearRoute, - child: const Icon(Icons.close), - ), - const SizedBox(height: 10), - ], - if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) - const SizedBox(height: 10), - - FloatingActionButton.small( - heroTag: 'recenterBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _recenterOnView, - child: const Icon(Icons.center_focus_strong), - ), - - const SizedBox(height: 10), - FloatingActionButton.small( - heroTag: 'locateBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _recenterOnUser, - child: const Icon(Icons.my_location), - ), - ], + child: EsriMapFabCluster( + isDark: isDark, + allCategoryResultsCount: _allCategoryResults.length, + showCategoryList: _showCategoryList, + hasLastSelectedResult: _lastSelectedResult != null, + showRouteFields: _showRouteFields, + hasRoute: _hasRoute, + onShowLayersPanel: () => setState(() => _showLayersPanel = true), + onToggleCategoryList: () => setState(() => _showCategoryList = !_showCategoryList), + onReopenDetail: _reopenDetail, + onClearRoute: _clearRoute, + onRecenterOnView: _recenterOnView, + onRecenterOnUser: _recenterOnUser, ), ), @@ -2773,9 +2691,23 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) _buildCategoryListPanel(context), - // Detail slide-over - if (_selectedResult != null) - _buildDetailSlideOver(context, _selectedResult!), + // Layers/display panel + if (_showLayersPanel) + EsriMapLayersPanel( + currentBasemapType: _currentBasemapType, + sceneMode: _sceneMode, + showCampusDistricts: _showCampusDistricts, + showConstruction: _showConstruction, + loadingConstruction: _loadingConstruction, + showAssemblyAreas: _showAssemblyAreas, + loadingAssemblyAreas: _loadingAssemblyAreas, + onSwitchBasemap: _switchBasemap, + onSetSceneMode: _setSceneMode, + onToggleCampusDistricts: _toggleCampusDistricts, + onToggleConstruction: _toggleConstruction, + onToggleAssemblyAreas: _toggleAssemblyAreas, + onClose: () => setState(() => _showLayersPanel = false), + ), ], ), ); diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart new file mode 100644 index 000000000..fe187abbd --- /dev/null +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +class EsriMapFabCluster extends StatelessWidget { + final bool isDark; + final int allCategoryResultsCount; + final bool showCategoryList; + final bool hasLastSelectedResult; + final bool showRouteFields; + final bool hasRoute; + final VoidCallback onShowLayersPanel; + final VoidCallback onToggleCategoryList; + final VoidCallback onReopenDetail; + final VoidCallback onClearRoute; + final VoidCallback onRecenterOnView; + final VoidCallback onRecenterOnUser; + + const EsriMapFabCluster({ + Key? key, + required this.isDark, + required this.allCategoryResultsCount, + required this.showCategoryList, + required this.hasLastSelectedResult, + required this.showRouteFields, + required this.hasRoute, + required this.onShowLayersPanel, + required this.onToggleCategoryList, + required this.onReopenDetail, + required this.onClearRoute, + required this.onRecenterOnView, + required this.onRecenterOnUser, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Layers/display panel toggle + FloatingActionButton.small( + heroTag: 'layersBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: onShowLayersPanel, + child: const Icon(Icons.layers_outlined), + ), + const SizedBox(height: 10), + // List view button — only when a category search is active + if (allCategoryResultsCount > 0) ...[ + FloatingActionButton.small( + heroTag: 'listBtn', + onPressed: onToggleCategoryList, + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + child: const Icon(Icons.list), + ), + const SizedBox(height: 10), + ], + if (hasLastSelectedResult) + FloatingActionButton.small( + heroTag: 'infoBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: onReopenDetail, + child: const Icon(Icons.info_outline), + ), + if (showRouteFields || hasRoute) ...[ + FloatingActionButton.small( + heroTag: 'clearRouteBtn', + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + onPressed: onClearRoute, + child: const Icon(Icons.close), + ), + const SizedBox(height: 10), + ], + if (allCategoryResultsCount > 0 || hasLastSelectedResult) + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'recenterBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: onRecenterOnView, + child: const Icon(Icons.center_focus_strong), + ), + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'locateBtn', + backgroundColor: isDark ? Colors.grey[800] : null, + foregroundColor: isDark ? Colors.white : null, + onPressed: onRecenterOnUser, + child: const Icon(Icons.my_location), + ), + ], + ); + } +} diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart new file mode 100644 index 000000000..3737c3fd7 --- /dev/null +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; + +import 'esrimap_basemaps.dart'; + +class EsriMapLayersPanel extends StatelessWidget { + final BasemapType currentBasemapType; + final String sceneMode; + final bool showCampusDistricts; + final bool showConstruction; + final bool loadingConstruction; + final bool showAssemblyAreas; + final bool loadingAssemblyAreas; + final void Function(BasemapType) onSwitchBasemap; + final void Function(String) onSetSceneMode; + final VoidCallback onToggleCampusDistricts; + final VoidCallback onToggleConstruction; + final VoidCallback onToggleAssemblyAreas; + final VoidCallback onClose; + + const EsriMapLayersPanel({ + Key? key, + required this.currentBasemapType, + required this.sceneMode, + required this.showCampusDistricts, + required this.showConstruction, + required this.loadingConstruction, + required this.showAssemblyAreas, + required this.loadingAssemblyAreas, + required this.onSwitchBasemap, + required this.onSetSceneMode, + required this.onToggleCampusDistricts, + required this.onToggleConstruction, + required this.onToggleAssemblyAreas, + required this.onClose, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[900]! : Colors.white; + final textColor = isDark ? Colors.white : Colors.grey[900]!; + final subtitleColor = isDark ? Colors.grey[400]! : Colors.grey[600]!; + final accent = isDark + ? Colors.lightBlue[300]! + : Theme.of(context).colorScheme.primary; + + Widget sectionLabel(String text) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: subtitleColor, + ), + ), + ); + + Widget imageTile({ + required String label, + required bool selected, + required VoidCallback onTap, + Widget? imageWidget, + bool loading = false, + }) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 72, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: selected + ? Border.all(color: accent, width: 2.5) + : Border.all( + color: isDark + ? Colors.grey[700]! + : Colors.grey[300]!, + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6.5), + child: loading + ? Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : (imageWidget ?? + Container( + color: isDark + ? Colors.grey[700] + : Colors.grey[300], + )), + ), + ), + const SizedBox(height: 5), + Text( + label, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w400, + color: selected ? accent : textColor, + ), + ), + ], + ), + ), + ); + } + + Widget sceneChip(String label, bool selected) => GestureDetector( + onTap: () { + onSetSceneMode(label); + // Keep panel open so the user sees the active chip update + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: selected + ? Border.all(color: accent, width: 2) + : Border.all( + color: isDark + ? Colors.grey[700]! + : Colors.grey[300]!, + width: 1, + ), + color: selected + ? accent.withOpacity(0.12) + : (isDark ? Colors.grey[850] : Colors.grey[100]), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w400, + color: selected ? accent : textColor, + ), + ), + ), + ); + + final bottomPad = MediaQuery.of(context).padding.bottom; + + return Positioned( + left: 12, + right: 12, + bottom: bottomPad + 12, + child: GestureDetector( + onVerticalDragEnd: (details) { + if (details.velocity.pixelsPerSecond.dy > 200) onClose(); + }, + child: Material( + elevation: 10, + color: bgColor, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Map Display', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, size: 20, color: subtitleColor), + onPressed: onClose, + ), + ], + ), + ), + + // Row 1: Basemaps + sectionLabel('BASEMAP'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final type in BasemapType.values) ...[ + imageTile( + label: basemapOptions[type]!.label, + // Basemap tiles are only "selected" in Default scene mode + selected: currentBasemapType == type && + sceneMode == 'Default', + onTap: () { + onSwitchBasemap(type); + if (sceneMode != 'Default') { + onSetSceneMode('Default'); + } + }, + // TODO: swap Container for + // Image.asset('assets/map/basemap_${type.name}.png', fit: BoxFit.cover) + imageWidget: Container( + color: { + BasemapType.defaultMap: + const Color(0xFFD6E4F0), + BasemapType.light: const Color(0xFFEEEEEE), + BasemapType.dark: const Color(0xFF444444), + BasemapType.satellite: + const Color(0xFF3A5A3A), + }[type], + ), + ), + const SizedBox(width: 8), + ], + ], + ), + ), + ), + + const Divider(height: 24, indent: 16, endIndent: 16), + + // Row 2: Operational layers + sectionLabel('LAYERS'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + imageTile( + label: 'Campus Districts', + selected: showCampusDistricts, + onTap: onToggleCampusDistricts, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFCCE5FF)), + ), + const SizedBox(width: 8), + imageTile( + label: 'Construction', + selected: showConstruction, + loading: loadingConstruction, + onTap: onToggleConstruction, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFFFE5CC)), + ), + const SizedBox(width: 8), + imageTile( + label: 'Assembly Areas', + selected: showAssemblyAreas, + loading: loadingAssemblyAreas, + onTap: onToggleAssemblyAreas, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFD4EDDA)), + ), + ], + ), + ), + ), + + const Divider(height: 24, indent: 16, endIndent: 16), + + // Row 3: Scene mode chips + sectionLabel('SCENE'), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), + child: Wrap( + spacing: 8, + children: [ + sceneChip('Default', sceneMode == 'Default'), + sceneChip('3D Building', sceneMode == '3D Building'), + sceneChip('Drone View', sceneMode == 'Drone View'), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/esrimap/esrimap_scene.dart b/lib/ui/esrimap/esrimap_scene.dart index 9f6c1a92f..d668d76cd 100644 --- a/lib/ui/esrimap/esrimap_scene.dart +++ b/lib/ui/esrimap/esrimap_scene.dart @@ -1,16 +1,14 @@ import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:flutter/material.dart'; -import 'esrimap_basemaps.dart'; - class EsriSceneWidget extends StatefulWidget { - final double initialLatitude; - final double initialLongitude; + final String portalUri; + final String itemId; const EsriSceneWidget({ super.key, - required this.initialLatitude, - required this.initialLongitude, + required this.portalUri, + required this.itemId, }); @override @@ -24,89 +22,17 @@ class _EsriSceneWidgetState extends State { void initState() { super.initState(); _sceneViewController = ArcGISSceneView.createController(); - _sceneViewController.interactionOptions.rotateEnabled = false; - } - - SimpleRenderer _makeExtrusionRenderer(String heightField) { - final symbol = SimpleFillSymbol( - style: SimpleFillSymbolStyle.solid, - color: const Color(0xFFD3D3D3), - ); - final renderer = SimpleRenderer(symbol: symbol); - renderer.sceneProperties = RendererSceneProperties.withExtrusionProperties( - extrusionExpression: '[$heightField] * 0.3048', - extrusionMode: ExtrusionMode.maximum, - ); - return renderer; + _sceneViewController.interactionOptions.rotateEnabled = true; + _sceneViewController.interactionOptions.panEnabled = true; } void _onSceneViewReady() { - final scene = ArcGISScene.withBasemap(buildBasemap(BasemapType.defaultMap)); - - final elevationSource = ArcGISTiledElevationSource.withUri( - Uri.parse('https://elevation3d.arcgis.com/arcgis/rest/services/' - 'WorldElevation3D/Terrain3D/ImageServer'), + final portal = Portal(Uri.parse(widget.portalUri)); + final portalItem = PortalItem.withPortalAndItemId( + portal: portal, + itemId: widget.itemId, ); - scene.baseSurface.elevationSources.add(elevationSource); - scene.baseSurface.isEnabled = true; - - final layerDefs = [ - ( - url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Hosted/LRT/FeatureServer/0', - heightField: 'Height', - placement: SurfacePlacement.relative, - ), - ( - url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Hosted/GeiselLibrary/FeatureServer/0', - heightField: 'Height', - placement: SurfacePlacement.absolute, - ), - ( - url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Hosted/Building_Extrusions/FeatureServer/0', - heightField: 'bldght', - placement: SurfacePlacement.relative, - ), - ( - url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Hosted/Building_Extrusions/FeatureServer/1', - heightField: 'bldght', - placement: SurfacePlacement.relative, - ), - ( - url: 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Hosted/Building_Extrusions/FeatureServer/2', - heightField: 'bldght', - placement: SurfacePlacement.relative, - ), - ]; - - for (final def in layerDefs) { - final table = ServiceFeatureTable.withUri(Uri.parse(def.url)); - final layer = FeatureLayer.withFeatureTable(table); - layer.renderer = _makeExtrusionRenderer(def.heightField); - layer.sceneProperties.surfacePlacement = def.placement; - scene.operationalLayers.add(layer); - } - - final camera = Camera.withLatLong( - latitude: widget.initialLatitude, - longitude: widget.initialLongitude, - altitude: 600.0, - heading: 0.0, - pitch: 65.0, - roll: 0.0, - ); - scene.initialViewpoint = Viewpoint.withLatLongScaleCamera( - latitude: widget.initialLatitude, - longitude: widget.initialLongitude, - scale: 10000.0, - camera: camera, - ); - - _sceneViewController.arcGISScene = scene; + _sceneViewController.arcGISScene = ArcGISScene.withItem(portalItem); } @override From a972d5285ea8dac4cd49cda1b02cf5150c7e72ec Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 4 May 2026 14:06:24 -0700 Subject: [PATCH 14/20] layer control updated --- lib/ui/esrimap/esrimap.dart | 1 + lib/ui/esrimap/esrimap_layers_panel.dart | 153 ++++++++++++----------- lib/ui/esrimap/esrimap_scene.dart | 25 +++- 3 files changed, 104 insertions(+), 75 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index d86ae9e56..8f4aaa0b9 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -348,6 +348,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { if (mode == _sceneMode) return; setState(() { _sceneMode = mode; + if (mode != 'Default') _showLayersPanel = false; if (mode == '3D Building' && _scene3DWidget == null) { _scene3DWidget = const EsriSceneWidget( portalUri: 'https://ucsd-admin.maps.arcgis.com', diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index 3737c3fd7..500f3dec0 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -197,91 +197,96 @@ class EsriMapLayersPanel extends StatelessWidget { ), ), - // Row 1: Basemaps - sectionLabel('BASEMAP'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( + // Basemap + Layers sections — grayed out when not in Default scene mode + Opacity( + opacity: sceneMode == 'Default' ? 1.0 : 0.35, + child: IgnorePointer( + ignoring: sceneMode != 'Default', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final type in BasemapType.values) ...[ - imageTile( - label: basemapOptions[type]!.label, - // Basemap tiles are only "selected" in Default scene mode - selected: currentBasemapType == type && - sceneMode == 'Default', - onTap: () { - onSwitchBasemap(type); - if (sceneMode != 'Default') { - onSetSceneMode('Default'); - } - }, - // TODO: swap Container for - // Image.asset('assets/map/basemap_${type.name}.png', fit: BoxFit.cover) - imageWidget: Container( - color: { - BasemapType.defaultMap: - const Color(0xFFD6E4F0), - BasemapType.light: const Color(0xFFEEEEEE), - BasemapType.dark: const Color(0xFF444444), - BasemapType.satellite: - const Color(0xFF3A5A3A), - }[type], + // Row 1: Basemaps + sectionLabel('BASEMAP'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final type in BasemapType.values) ...[ + imageTile( + label: basemapOptions[type]!.label, + selected: currentBasemapType == type, + onTap: () => onSwitchBasemap(type), + // TODO: swap Container for + // Image.asset('assets/map/basemap_${type.name}.png', fit: BoxFit.cover) + imageWidget: Container( + color: { + BasemapType.defaultMap: + const Color(0xFFD6E4F0), + BasemapType.light: const Color(0xFFEEEEEE), + BasemapType.dark: const Color(0xFF444444), + BasemapType.satellite: + const Color(0xFF3A5A3A), + }[type], + ), + ), + const SizedBox(width: 8), + ], + ], ), ), - const SizedBox(width: 8), - ], - ], - ), - ), - ), + ), - const Divider(height: 24, indent: 16, endIndent: 16), + const Divider(height: 24, indent: 16, endIndent: 16), - // Row 2: Operational layers - sectionLabel('LAYERS'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - imageTile( - label: 'Campus Districts', - selected: showCampusDistricts, - onTap: onToggleCampusDistricts, - // TODO: replace with thumbnail image - imageWidget: - Container(color: const Color(0xFFCCE5FF)), - ), - const SizedBox(width: 8), - imageTile( - label: 'Construction', - selected: showConstruction, - loading: loadingConstruction, - onTap: onToggleConstruction, - // TODO: replace with thumbnail image - imageWidget: - Container(color: const Color(0xFFFFE5CC)), - ), - const SizedBox(width: 8), - imageTile( - label: 'Assembly Areas', - selected: showAssemblyAreas, - loading: loadingAssemblyAreas, - onTap: onToggleAssemblyAreas, - // TODO: replace with thumbnail image - imageWidget: - Container(color: const Color(0xFFD4EDDA)), + // Row 2: Operational layers + sectionLabel('LAYERS'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + imageTile( + label: 'Campus Districts', + selected: showCampusDistricts, + onTap: onToggleCampusDistricts, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFCCE5FF)), + ), + const SizedBox(width: 8), + imageTile( + label: 'Construction', + selected: showConstruction, + loading: loadingConstruction, + onTap: onToggleConstruction, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFFFE5CC)), + ), + const SizedBox(width: 8), + imageTile( + label: 'Assembly Areas', + selected: showAssemblyAreas, + loading: loadingAssemblyAreas, + onTap: onToggleAssemblyAreas, + // TODO: replace with thumbnail image + imageWidget: + Container(color: const Color(0xFFD4EDDA)), + ), + ], + ), + ), ), + + const Divider(height: 24, indent: 16, endIndent: 16), ], ), ), ), - const Divider(height: 24, indent: 16, endIndent: 16), - // Row 3: Scene mode chips sectionLabel('SCENE'), Padding( diff --git a/lib/ui/esrimap/esrimap_scene.dart b/lib/ui/esrimap/esrimap_scene.dart index d668d76cd..a5f29634a 100644 --- a/lib/ui/esrimap/esrimap_scene.dart +++ b/lib/ui/esrimap/esrimap_scene.dart @@ -24,6 +24,9 @@ class _EsriSceneWidgetState extends State { _sceneViewController = ArcGISSceneView.createController(); _sceneViewController.interactionOptions.rotateEnabled = true; _sceneViewController.interactionOptions.panEnabled = true; + // Reduce GPU overhead: skip atmosphere and star-field rendering + _sceneViewController.atmosphereEffect = AtmosphereEffect.none; + _sceneViewController.spaceEffect = SpaceEffect.transparent; } void _onSceneViewReady() { @@ -32,7 +35,27 @@ class _EsriSceneWidgetState extends State { portal: portal, itemId: widget.itemId, ); - _sceneViewController.arcGISScene = ArcGISScene.withItem(portalItem); + final scene = ArcGISScene.withItem(portalItem); + _sceneViewController.arcGISScene = scene; + _applyLabelScales(scene); + } + + // Restrict building labels to only appear when zoomed in close (scale <= 4000) + Future _applyLabelScales(ArcGISScene scene) async { + await scene.load(); + _applyLabelScalesToLayers(scene.operationalLayers); + } + + void _applyLabelScalesToLayers(List layers) { + for (final layer in layers) { + if (layer is FeatureLayer) { + for (final labelDef in layer.labelDefinitions) { + labelDef.minScale = 4000; + } + } else if (layer is GroupLayer) { + _applyLabelScalesToLayers(layer.layers); + } + } } @override From 54773c0392a40d673e1164949f242e728e52354f Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 6 May 2026 14:43:50 -0700 Subject: [PATCH 15/20] layer panel enhancements, add busses (rough) --- lib/ui/esrimap/esrimap.dart | 151 +++++++++++++++--- lib/ui/esrimap/esrimap_fab.dart | 94 +++++++++-- lib/ui/esrimap/esrimap_layers_panel.dart | 53 +++--- .../temp_assets/construction-thumbnail.png | Bin 0 -> 15020 bytes lib/ui/esrimap/temp_assets/dark-thumbnail.png | Bin 0 -> 11767 bytes .../esrimap/temp_assets/default-thumbnail.png | Bin 0 -> 15769 bytes .../temp_assets/districts-thumbnail.png | Bin 0 -> 6086 bytes .../esrimap/temp_assets/light-thumbnail.png | Bin 0 -> 9597 bytes .../temp_assets/satellite-thumbnail.png | Bin 0 -> 34892 bytes .../temp_assets/shuttles-thumbnail.png | Bin 0 -> 14508 bytes pubspec.yaml | 1 + 11 files changed, 249 insertions(+), 50 deletions(-) create mode 100644 lib/ui/esrimap/temp_assets/construction-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/dark-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/default-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/districts-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/light-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/satellite-thumbnail.png create mode 100644 lib/ui/esrimap/temp_assets/shuttles-thumbnail.png diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 8f4aaa0b9..92be42be9 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -245,6 +245,15 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { bool _showAssemblyAreas = false; ArcGISMapImageLayer? _assemblyAreasLayer; bool _loadingAssemblyAreas = false; + bool _showTransitLayer = false; + FeatureLayer? _transitShuttlesLayer; + FeatureLayer? _transitRoutesLayer; + bool _loadingTransitLayer = false; + Timer? _transitRefreshTimer; + + // Compass + double _mapRotation = 0.0; + StreamSubscription? _viewpointChangedSubscription; final _mapReadyCompleter = Completer(); @@ -275,6 +284,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _fromFocusNode.dispose(); _toFocusNode.dispose(); _focusNode.dispose(); + _transitRefreshTimer?.cancel(); + _viewpointChangedSubscription?.cancel(); super.dispose(); } @@ -320,6 +331,20 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _startLocationDisplay(); _preloadAlternateBasemaps(); + + _viewpointChangedSubscription = + _mapViewController.onViewpointChanged.listen((_) { + final vp = _mapViewController.getCurrentViewpoint( + ViewpointType.centerAndScale, + ); + if (vp != null && mounted) { + setState(() => _mapRotation = vp.rotation); + } + }); + } + + void _snapToNorth() { + _mapViewController.setViewpointRotation(angleDegrees: 0); } ///Loads metadata and style sheets; tile content still fetches lazily once the basemap is applied to the MapView. @@ -363,6 +388,71 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); } + void _toggleTransitLayer() async { + if (_showTransitLayer) { + _transitRefreshTimer?.cancel(); + _transitRefreshTimer = null; + if (_transitShuttlesLayer != null) { + _map.operationalLayers.remove(_transitShuttlesLayer!); + } + if (_transitRoutesLayer != null) { + _map.operationalLayers.remove(_transitRoutesLayer!); + } + setState(() => _showTransitLayer = false); + } else { + setState(() => _loadingTransitLayer = true); + if (_transitRoutesLayer == null) { + _transitRoutesLayer = FeatureLayer.withFeatureTable( + ServiceFeatureTable.withUri(Uri.parse( + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Triton_Transit_Route_Lines/FeatureServer/0', + )), + ); + } + if (_transitShuttlesLayer == null) { + _transitShuttlesLayer = FeatureLayer.withFeatureTable( + ServiceFeatureTable.withUri(Uri.parse( + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Triton_Transit_Shuttle_Positions/FeatureServer/0', + )), + ); + } + _map.operationalLayers.add(_transitRoutesLayer!); + _map.operationalLayers.add(_transitShuttlesLayer!); + try { + await Future.wait([ + _transitRoutesLayer!.load(), + _transitShuttlesLayer!.load(), + ]); + } catch (e) { + debugPrint('Transit layer load error: $e'); + } + setState(() { + _showTransitLayer = true; + _loadingTransitLayer = false; + }); + _transitRefreshTimer = Timer.periodic( + const Duration(seconds: 15), + (_) async { + if (!mounted || _transitShuttlesLayer == null) return; + _map.operationalLayers.remove(_transitShuttlesLayer!); + _transitShuttlesLayer = FeatureLayer.withFeatureTable( + ServiceFeatureTable.withUri(Uri.parse( + 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' + 'Triton_Transit_Shuttle_Positions/FeatureServer/0', + )), + ); + _map.operationalLayers.add(_transitShuttlesLayer!); + try { + await _transitShuttlesLayer!.load(); + } catch (e) { + debugPrint('Transit shuttle refresh error: $e'); + } + }, + ); + } + } + void _toggleCampusDistricts() { if (_showCampusDistricts) { if (_campusDistrictsLayer != null) { @@ -395,9 +485,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { setState(() => _loadingConstruction = true); if (_constructionLayer == null) { _constructionLayer = ArcGISMapImageLayer.withUri(Uri.parse( - // TODO: replace with the real AGE construction layer URL 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'CampusServices/Construction_Impacts/MapServer', + 'Construction/Construction_Alert_Approved/MapServer', )); } _map.operationalLayers.add(_constructionLayer!); @@ -2679,12 +2768,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { hasLastSelectedResult: _lastSelectedResult != null, showRouteFields: _showRouteFields, hasRoute: _hasRoute, + mapRotation: _mapRotation, onShowLayersPanel: () => setState(() => _showLayersPanel = true), onToggleCategoryList: () => setState(() => _showCategoryList = !_showCategoryList), onReopenDetail: _reopenDetail, onClearRoute: _clearRoute, onRecenterOnView: _recenterOnView, onRecenterOnUser: _recenterOnUser, + onSnapToNorth: _snapToNorth, ), ), @@ -2697,6 +2788,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { EsriMapLayersPanel( currentBasemapType: _currentBasemapType, sceneMode: _sceneMode, + showTransitLayer: _showTransitLayer, + loadingTransitLayer: _loadingTransitLayer, showCampusDistricts: _showCampusDistricts, showConstruction: _showConstruction, loadingConstruction: _loadingConstruction, @@ -2704,6 +2797,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { loadingAssemblyAreas: _loadingAssemblyAreas, onSwitchBasemap: _switchBasemap, onSetSceneMode: _setSceneMode, + onToggleTransitLayer: _toggleTransitLayer, onToggleCampusDistricts: _toggleCampusDistricts, onToggleConstruction: _toggleConstruction, onToggleAssemblyAreas: _toggleAssemblyAreas, @@ -2717,23 +2811,43 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { final Future> Function(Map) callLambda; - - String? _cachedToken; - DateTime? _tokenExpiry; + + String? _cachedAgeToken; + DateTime? _ageTokenExpiry; + + String? _cachedAgoToken; + DateTime? _agoTokenExpiry; _AgeAuthChallengeHandler(this.callLambda); - Future _getToken() async { - if (_cachedToken != null && - _tokenExpiry != null && - DateTime.now().isBefore(_tokenExpiry!.subtract(const Duration(minutes: 5)))) { - return _cachedToken; + Future<(String?, DateTime?)> _getTokenForHost(String host) async { + final isAgo = host.contains('arcgis.com'); + + if (isAgo) { + if (_cachedAgoToken != null && + _agoTokenExpiry != null && + DateTime.now().isBefore(_agoTokenExpiry!.subtract(const Duration(minutes: 5)))) { + return (_cachedAgoToken, _agoTokenExpiry); + } + } else { + if (_cachedAgeToken != null && + _ageTokenExpiry != null && + DateTime.now().isBefore(_ageTokenExpiry!.subtract(const Duration(minutes: 5)))) { + return (_cachedAgeToken, _ageTokenExpiry); + } } + final data = await callLambda({'action': 'getTokens'}); - _cachedToken = data['age']?['token'] as String?; - final expiresIn = data['age']?['expires_in'] as int? ?? 7200; - _tokenExpiry = DateTime.now().add(Duration(seconds: expiresIn)); - return _cachedToken; + + _cachedAgeToken = data['age']?['token'] as String?; + final ageExpiresIn = data['age']?['expires_in'] as int? ?? 7200; + _ageTokenExpiry = DateTime.now().add(Duration(seconds: ageExpiresIn)); + + _cachedAgoToken = data['ago']?['token'] as String?; + final agoExpiresIn = data['ago']?['expires_in'] as int? ?? 7200; + _agoTokenExpiry = DateTime.now().add(Duration(seconds: agoExpiresIn)); + + return isAgo ? (_cachedAgoToken, _agoTokenExpiry) : (_cachedAgeToken, _ageTokenExpiry); } @override @@ -2741,14 +2855,15 @@ class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { ArcGISAuthenticationChallenge challenge, ) async { try { - final token = await _getToken(); - if (token == null) { + final host = challenge.requestUri.host; + final (token, expiry) = await _getTokenForHost(host); + if (token == null || expiry == null) { challenge.continueAndFail(); return; } final tokenInfo = TokenInfo.create( accessToken: token, - expirationDate: _tokenExpiry!, + expirationDate: expiry, isSslRequired: true, ); if (tokenInfo == null) { @@ -2762,7 +2877,7 @@ class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { ); challenge.continueWithCredential(credential); } catch (e) { - debugPrint('AGE auth challenge failed: $e'); + debugPrint('Auth challenge failed: $e'); challenge.continueAndFail(); } } diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart index fe187abbd..5a3f0c578 100644 --- a/lib/ui/esrimap/esrimap_fab.dart +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'package:flutter/material.dart'; class EsriMapFabCluster extends StatelessWidget { @@ -7,12 +8,14 @@ class EsriMapFabCluster extends StatelessWidget { final bool hasLastSelectedResult; final bool showRouteFields; final bool hasRoute; + final double mapRotation; final VoidCallback onShowLayersPanel; final VoidCallback onToggleCategoryList; final VoidCallback onReopenDetail; final VoidCallback onClearRoute; final VoidCallback onRecenterOnView; final VoidCallback onRecenterOnUser; + final VoidCallback onSnapToNorth; const EsriMapFabCluster({ Key? key, @@ -22,16 +25,21 @@ class EsriMapFabCluster extends StatelessWidget { required this.hasLastSelectedResult, required this.showRouteFields, required this.hasRoute, + required this.mapRotation, required this.onShowLayersPanel, required this.onToggleCategoryList, required this.onReopenDetail, required this.onClearRoute, required this.onRecenterOnView, required this.onRecenterOnUser, + required this.onSnapToNorth, }) : super(key: key); @override Widget build(BuildContext context) { + final bgColor = isDark ? Colors.grey[800]! : Colors.white; + final fgColor = isDark ? Colors.white : Colors.grey[800]!; + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, @@ -39,8 +47,8 @@ class EsriMapFabCluster extends StatelessWidget { // Layers/display panel toggle FloatingActionButton.small( heroTag: 'layersBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, + backgroundColor: bgColor, + foregroundColor: fgColor, onPressed: onShowLayersPanel, child: const Icon(Icons.layers_outlined), ), @@ -50,20 +58,22 @@ class EsriMapFabCluster extends StatelessWidget { FloatingActionButton.small( heroTag: 'listBtn', onPressed: onToggleCategoryList, - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, + backgroundColor: bgColor, + foregroundColor: fgColor, child: const Icon(Icons.list), ), const SizedBox(height: 10), ], - if (hasLastSelectedResult) + if (hasLastSelectedResult) ...[ FloatingActionButton.small( heroTag: 'infoBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, + backgroundColor: bgColor, + foregroundColor: fgColor, onPressed: onReopenDetail, child: const Icon(Icons.info_outline), ), + const SizedBox(height: 10), + ], if (showRouteFields || hasRoute) ...[ FloatingActionButton.small( heroTag: 'clearRouteBtn', @@ -74,24 +84,82 @@ class EsriMapFabCluster extends StatelessWidget { ), const SizedBox(height: 10), ], - if (allCategoryResultsCount > 0 || hasLastSelectedResult) - const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'recenterBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, + backgroundColor: bgColor, + foregroundColor: fgColor, onPressed: onRecenterOnView, child: const Icon(Icons.center_focus_strong), ), const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'locateBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, + backgroundColor: bgColor, + foregroundColor: fgColor, onPressed: onRecenterOnUser, child: const Icon(Icons.my_location), ), + const SizedBox(height: 10), + // Compass — rotates with the map, tapping snaps back to north + FloatingActionButton.small( + heroTag: 'compassBtn', + backgroundColor: bgColor, + foregroundColor: fgColor, + onPressed: onSnapToNorth, + child: CustomPaint( + size: const Size(22, 22), + painter: _CompassNeedlePainter( + rotationDegrees: mapRotation, + southColor: fgColor.withOpacity(0.35), + ), + ), + ), ], ); } } + +class _CompassNeedlePainter extends CustomPainter { + final double rotationDegrees; + final Color southColor; + + const _CompassNeedlePainter({ + required this.rotationDegrees, + required this.southColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + final tipDist = size.height * 0.46; + final halfWidth = size.width * 0.18; + + canvas.save(); + canvas.translate(cx, cy); + canvas.rotate(-rotationDegrees * math.pi / 180); + + // North (red) half — points up + final northPath = Path() + ..moveTo(0, -tipDist) + ..lineTo(halfWidth, 0) + ..lineTo(-halfWidth, 0) + ..close(); + + // South (muted) half — points down + final southPath = Path() + ..moveTo(0, tipDist) + ..lineTo(halfWidth, 0) + ..lineTo(-halfWidth, 0) + ..close(); + + canvas.drawPath(northPath, Paint()..color = Colors.red); + canvas.drawPath(southPath, Paint()..color = southColor); + + canvas.restore(); + } + + @override + bool shouldRepaint(_CompassNeedlePainter old) => + old.rotationDegrees != rotationDegrees || old.southColor != southColor; +} diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index 500f3dec0..e234ada10 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -5,6 +5,8 @@ import 'esrimap_basemaps.dart'; class EsriMapLayersPanel extends StatelessWidget { final BasemapType currentBasemapType; final String sceneMode; + final bool showTransitLayer; + final bool loadingTransitLayer; final bool showCampusDistricts; final bool showConstruction; final bool loadingConstruction; @@ -12,6 +14,7 @@ class EsriMapLayersPanel extends StatelessWidget { final bool loadingAssemblyAreas; final void Function(BasemapType) onSwitchBasemap; final void Function(String) onSetSceneMode; + final VoidCallback onToggleTransitLayer; final VoidCallback onToggleCampusDistricts; final VoidCallback onToggleConstruction; final VoidCallback onToggleAssemblyAreas; @@ -21,6 +24,8 @@ class EsriMapLayersPanel extends StatelessWidget { Key? key, required this.currentBasemapType, required this.sceneMode, + required this.showTransitLayer, + required this.loadingTransitLayer, required this.showCampusDistricts, required this.showConstruction, required this.loadingConstruction, @@ -28,6 +33,7 @@ class EsriMapLayersPanel extends StatelessWidget { required this.loadingAssemblyAreas, required this.onSwitchBasemap, required this.onSetSceneMode, + required this.onToggleTransitLayer, required this.onToggleCampusDistricts, required this.onToggleConstruction, required this.onToggleAssemblyAreas, @@ -218,17 +224,14 @@ class EsriMapLayersPanel extends StatelessWidget { label: basemapOptions[type]!.label, selected: currentBasemapType == type, onTap: () => onSwitchBasemap(type), - // TODO: swap Container for - // Image.asset('assets/map/basemap_${type.name}.png', fit: BoxFit.cover) - imageWidget: Container( - color: { - BasemapType.defaultMap: - const Color(0xFFD6E4F0), - BasemapType.light: const Color(0xFFEEEEEE), - BasemapType.dark: const Color(0xFF444444), - BasemapType.satellite: - const Color(0xFF3A5A3A), - }[type], + imageWidget: Image.asset( + { + BasemapType.defaultMap: 'lib/ui/esrimap/temp_assets/default-thumbnail.png', + BasemapType.light: 'lib/ui/esrimap/temp_assets/light-thumbnail.png', + BasemapType.dark: 'lib/ui/esrimap/temp_assets/dark-thumbnail.png', + BasemapType.satellite: 'lib/ui/esrimap/temp_assets/satellite-thumbnail.png', + }[type]!, + fit: BoxFit.cover, ), ), const SizedBox(width: 8), @@ -249,12 +252,24 @@ class EsriMapLayersPanel extends StatelessWidget { child: Row( children: [ imageTile( - label: 'Campus Districts', + label: 'Triton Transit', + selected: showTransitLayer, + loading: loadingTransitLayer, + onTap: onToggleTransitLayer, + imageWidget: Image.asset( + 'lib/ui/esrimap/temp_assets/shuttles-thumbnail.png', + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 8), + imageTile( + label: 'Districts', selected: showCampusDistricts, onTap: onToggleCampusDistricts, - // TODO: replace with thumbnail image - imageWidget: - Container(color: const Color(0xFFCCE5FF)), + imageWidget: Image.asset( + 'lib/ui/esrimap/temp_assets/districts-thumbnail.png', + fit: BoxFit.cover, + ), ), const SizedBox(width: 8), imageTile( @@ -262,9 +277,10 @@ class EsriMapLayersPanel extends StatelessWidget { selected: showConstruction, loading: loadingConstruction, onTap: onToggleConstruction, - // TODO: replace with thumbnail image - imageWidget: - Container(color: const Color(0xFFFFE5CC)), + imageWidget: Image.asset( + 'lib/ui/esrimap/temp_assets/construction-thumbnail.png', + fit: BoxFit.cover, + ), ), const SizedBox(width: 8), imageTile( @@ -272,7 +288,6 @@ class EsriMapLayersPanel extends StatelessWidget { selected: showAssemblyAreas, loading: loadingAssemblyAreas, onTap: onToggleAssemblyAreas, - // TODO: replace with thumbnail image imageWidget: Container(color: const Color(0xFFD4EDDA)), ), diff --git a/lib/ui/esrimap/temp_assets/construction-thumbnail.png b/lib/ui/esrimap/temp_assets/construction-thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..61830070a33456782be9c6885877663c768914a5 GIT binary patch literal 15020 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ℜ1QX&hZf%6)sBx$8Zn>%>mfFLN* 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 literal 0 HcmV?d00001 diff --git a/lib/ui/esrimap/temp_assets/districts-thumbnail.png b/lib/ui/esrimap/temp_assets/districts-thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..148916efacc3abc8b4a3ec77d048aa8286301d97 GIT binary patch literal 6086 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 M07*qoM6N<$g1=0}CjbBd literal 0 HcmV?d00001 diff --git a/lib/ui/esrimap/temp_assets/light-thumbnail.png b/lib/ui/esrimap/temp_assets/light-thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..36b5e8643df2f78a24aeac4fcce68e3ea24b338a GIT binary patch literal 9597 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 KMNUMnLSTXuSe1zY literal 0 HcmV?d00001 diff --git a/pubspec.yaml b/pubspec.yaml index f44275eb2..789232d86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ flutter: - assets/images/ - assets/images/onboarding/ - assets/images/tgpt/ + - lib/ui/esrimap/temp_assets/ - .env fonts: - family: Refrigerator Deluxe From 82231604c3809fddb217e3b69a6391f82567c611 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Tue, 12 May 2026 17:56:10 -0700 Subject: [PATCH 16/20] move urls to lambda --- lib/main.dart | 6 - lib/ui/esrimap/esrimap.dart | 136 +- lib/ui/esrimap/esrimap_basemaps.dart | 131 +- lib/ui/esrimap/esrimap_config.dart | 123 ++ lib/ui/esrimap/esrimap_layers_panel.dart | 2 +- lib/ui/esrimap/esrimap_models.dart | 1815 ---------------------- lib/ui/esrimap/esrimap_service.dart | 1815 ---------------------- 7 files changed, 239 insertions(+), 3789 deletions(-) create mode 100644 lib/ui/esrimap/esrimap_config.dart delete mode 100644 lib/ui/esrimap/esrimap_models.dart delete mode 100644 lib/ui/esrimap/esrimap_service.dart diff --git a/lib/main.dart b/lib/main.dart index ece87217f..6255348d6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,12 +39,6 @@ void main() async { // dotenv loading await dotenv.load(isOptional: true); - // Initialize ArcGIS - final arcgisAgeKey = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; - if (arcgisAgeKey.isNotEmpty) { - ArcGISEnvironment.apiKey = arcgisAgeKey; - } - /// Enable crash analytics - https://firebase.flutter.dev/docs/crashlytics/usage#toggle-crashlytics-collection await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 92be42be9..c092cc825 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'dart:async'; import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -12,6 +11,7 @@ import 'esrimap_basemaps.dart'; import 'esrimap_fab.dart'; import 'esrimap_layers_panel.dart'; import 'esrimap_scene.dart'; +import 'esrimap_config.dart'; // ----------------------------------------------------------------------------- // Model @@ -172,8 +172,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final _categorySheetController = DraggableScrollableController(); final _detailSheetController = DraggableScrollableController(); - // Service endpoints - static const _lambdaUrl = "https://i0slpyw2gb.execute-api.us-west-2.amazonaws.com/default/ArcGIS-Map"; + // Service base URL + static const _baseUrl = 'https://appzxi70zi.execute-api.us-west-2.amazonaws.com/test/ArcGIS-Map'; + + // Config -- loaded on init, gates map setup + EsriMapConfig? _config; // SharedPreferences key for recent searches static const _recentSearchesKey = 'esri_map_recent_searches'; @@ -268,12 +271,24 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); - _initMap(); + _fetchConfigThenInit(); _loadRecentSearches(); - _fetchAllPoiClasses(); _focusNode.addListener(_onFocusChanged); } + Future _fetchConfigThenInit() async { + try { + final config = await EsriMapConfigService.instance.fetch(); + if (!mounted) return; + setState(() => _config = config); + _initMap(config); + _fetchAllPoiClasses(); + } catch (e) { + debugPrint('Config fetch failed: $e'); + _mapReadyCompleter.completeError(e); + } + } + @override void dispose() { _categorySheetController.dispose(); @@ -292,12 +307,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { void _setupAgeAuthChallengeHandler() { ArcGISEnvironment .authenticationManager - .arcGISAuthenticationChallengeHandler = _AgeAuthChallengeHandler(_callLambda); + .arcGISAuthenticationChallengeHandler = _AgeAuthChallengeHandler( + EsriMapConfigService.instance.tokensUrl, + ); } - void _initMap() { + void _initMap(EsriMapConfig config) { for (final type in BasemapType.values) { - _basemaps[type] = buildBasemap(type); + _basemaps[type] = buildBasemap(type, config); } _map = ArcGISMap.withBasemap(_basemaps[_currentBasemapType]!); @@ -374,15 +391,17 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { setState(() { _sceneMode = mode; if (mode != 'Default') _showLayersPanel = false; - if (mode == '3D Building' && _scene3DWidget == null) { - _scene3DWidget = const EsriSceneWidget( - portalUri: 'https://ucsd-admin.maps.arcgis.com', - itemId: 'a0a255ad97534836aa9e159d4a546bfc', + if (mode == '3D Building' && _scene3DWidget == null && _config != null) { + final scene = _config!.scenes['building3d']!; + _scene3DWidget = EsriSceneWidget( + portalUri: scene.portalUrl, + itemId: scene.itemId, ); - } else if (mode == 'Drone View' && _sceneDroneWidget == null) { - _sceneDroneWidget = const EsriSceneWidget( - portalUri: 'https://admin-enterprise-gis.ucsd.edu/portal', - itemId: '0ffe293479844ce49ff5c30ffc0a0b67', + } else if (mode == 'Drone View' && _sceneDroneWidget == null && _config != null) { + final scene = _config!.scenes['droneView']!; + _sceneDroneWidget = EsriSceneWidget( + portalUri: scene.portalUrl, + itemId: scene.itemId, ); } }); @@ -403,18 +422,12 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { setState(() => _loadingTransitLayer = true); if (_transitRoutesLayer == null) { _transitRoutesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse( - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Triton_Transit_Route_Lines/FeatureServer/0', - )), + ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitRoutes)), ); } if (_transitShuttlesLayer == null) { _transitShuttlesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse( - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Triton_Transit_Shuttle_Positions/FeatureServer/0', - )), + ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitShuttles)), ); } _map.operationalLayers.add(_transitRoutesLayer!); @@ -437,10 +450,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { if (!mounted || _transitShuttlesLayer == null) return; _map.operationalLayers.remove(_transitShuttlesLayer!); _transitShuttlesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse( - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Triton_Transit_Shuttle_Positions/FeatureServer/0', - )), + ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitShuttles)), ); _map.operationalLayers.add(_transitShuttlesLayer!); try { @@ -461,10 +471,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { setState(() => _showCampusDistricts = false); } else { if (_campusDistrictsLayer == null) { - _campusDistrictsLayer = ArcGISMapImageLayer.withUri(Uri.parse( - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'AdministrationServices/Areas_and_Boundaries/MapServer', - )); + _campusDistrictsLayer = ArcGISMapImageLayer.withUri( + Uri.parse(_config!.layers.campusDistricts), + ); // Show only sublayer 4 (Campus Districts) for (final sublayer in _campusDistrictsLayer!.mapImageSublayers) { sublayer.isVisible = sublayer.id == 4; @@ -484,10 +493,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } else { setState(() => _loadingConstruction = true); if (_constructionLayer == null) { - _constructionLayer = ArcGISMapImageLayer.withUri(Uri.parse( - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Construction/Construction_Alert_Approved/MapServer', - )); + _constructionLayer = ArcGISMapImageLayer.withUri( + Uri.parse(_config!.layers.construction), + ); } _map.operationalLayers.add(_constructionLayer!); try { @@ -511,11 +519,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } else { setState(() => _loadingAssemblyAreas = true); if (_assemblyAreasLayer == null) { - _assemblyAreasLayer = ArcGISMapImageLayer.withUri(Uri.parse( - // TODO: replace with the real AGE assembly areas layer URL - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'CampusServices/Assembly_Areas/MapServer', - )); + _assemblyAreasLayer = ArcGISMapImageLayer.withUri( + Uri.parse(_config!.layers.assemblyAreas), + ); } _map.operationalLayers.add(_assemblyAreasLayer!); try { @@ -641,21 +647,30 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // Query helpers // --------------------------------------------------------------------------- - /// POST to the Lambda map handler. - Future> _callLambda(Map payload) async { + /// GET from the REST API. + Future> _apiGet(String path, [Map? params]) async { + final uri = Uri.parse('$_baseUrl/$path').replace(queryParameters: params); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + return jsonDecode(response.body) as Map; + } + + Future> _apiPost(String path, Map body) async { final response = await http.post( - Uri.parse(_lambdaUrl), + Uri.parse('$_baseUrl/$path'), headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), + body: jsonEncode(body), ); if (response.statusCode != 200) { - throw Exception('Lambda error ${response.statusCode}: ${response.body}'); + throw Exception('API error ${response.statusCode}: ${response.body}'); } return jsonDecode(response.body) as Map; } Future> _queryBuildings(String query) async { - final data = await _callLambda({'action': 'searchBuildings', 'query': query}); + final data = await _apiGet('buildings', {'q': query}); return (data['results'] as List? ?? []).map((r) { return MapSearchResult( name: r['name'] as String? ?? 'Unknown Building', @@ -670,7 +685,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { Future _fetchAllPoiClasses() async { try { - final data = await _callLambda({'action': 'fetchAllPoiClasses'}); + final data = await _apiGet('poi/classes'); final classes = (data['classes'] as List? ?? []) .map((c) => c as String) .toList(); @@ -681,16 +696,12 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } Future> _queryPOIs(String query) async { - final data = await _callLambda({'action': 'searchPOI', 'query': query}); + final data = await _apiGet('poi', {'q': query}); return _parsePOIResults(data); } Future> _queryPOIsByClass(String classValue) async { - final data = await _callLambda({ - 'action': 'searchPOIByClass', - 'classValue': classValue, - 'maxResults': 100, - }); + final data = await _apiGet('poi', {'class': classValue, 'limit': '100'}); return _parsePOIResults(data); } @@ -1269,11 +1280,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); try { - await dotenv.load(fileName: ".env"); - final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; - final routeTask = RouteTask.withUri(Uri.parse(_routeServiceUrl)); - routeTask.apiKey = token; await routeTask.load(); final params = await routeTask.createDefaultParameters(); @@ -2803,6 +2810,10 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { onToggleAssemblyAreas: _toggleAssemblyAreas, onClose: () => setState(() => _showLayersPanel = false), ), + + // Detail slide-over + if (_selectedResult != null) + _buildDetailSlideOver(context, _selectedResult!), ], ), ); @@ -2810,7 +2821,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { - final Future> Function(Map) callLambda; + final String tokensUrl; String? _cachedAgeToken; DateTime? _ageTokenExpiry; @@ -2818,7 +2829,7 @@ class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { String? _cachedAgoToken; DateTime? _agoTokenExpiry; - _AgeAuthChallengeHandler(this.callLambda); + _AgeAuthChallengeHandler(this.tokensUrl); Future<(String?, DateTime?)> _getTokenForHost(String host) async { final isAgo = host.contains('arcgis.com'); @@ -2837,7 +2848,10 @@ class _AgeAuthChallengeHandler implements ArcGISAuthenticationChallengeHandler { } } - final data = await callLambda({'action': 'getTokens'}); + final response = await http.get(Uri.parse(tokensUrl)); + if (response.statusCode != 200) throw Exception('Token fetch failed: ${response.statusCode}'); + final data = jsonDecode(response.body) as Map; + _cachedAgeToken = data['age']?['token'] as String?; final ageExpiresIn = data['age']?['expires_in'] as int? ?? 7200; diff --git a/lib/ui/esrimap/esrimap_basemaps.dart b/lib/ui/esrimap/esrimap_basemaps.dart index ff3afc855..6a872b132 100644 --- a/lib/ui/esrimap/esrimap_basemaps.dart +++ b/lib/ui/esrimap/esrimap_basemaps.dart @@ -1,106 +1,55 @@ -/* Basemap config for campus map - -There are three basemaps with three layers each. -1. World Hillshade shows terrain relief and adds depth to non campus areas -2. Esri world context fills off campus areas -3. UCSD Campus Vector Tile layer loaded from AGE - */ - import 'package:arcgis_maps/arcgis_maps.dart'; +import 'esrimap_config.dart'; enum BasemapType { defaultMap, light, dark, satellite } -class BasemapOption { - final String label; - final String itemId; - const BasemapOption({ - required this.label, - required this.itemId, - }); -} - -const basemapOptions = { - BasemapType.defaultMap: BasemapOption( - label: 'Default', - itemId: 'e19f33d2c1f44967aef673306c483913', - ), - BasemapType.light: BasemapOption( - label: 'Light', - itemId: '6643ee62af494f5bafe7dfdb8eb3f857', - ), - BasemapType.dark: BasemapOption( - label: 'Dark', - itemId: '09d7b3934b6c4c2cad8380c04e08c1b1', - ), - BasemapType.satellite: BasemapOption( - label: 'Satellite', - itemId: '6643ee62af494f5bafe7dfdb8eb3f857', - ), +// Label lookup -- used by the layers panel +const basemapLabels = { + BasemapType.defaultMap: 'Default', + BasemapType.light: 'Light', + BasemapType.dark: 'Dark', + BasemapType.satellite: 'Satellite', }; -// Esri world tile service URIs (raster). -const _hillshadeUri = - 'https://services.arcgisonline.com/arcgis/rest/services/' - 'Elevation/World_Hillshade/MapServer'; - -const _worldTopoUri = - 'https://services.arcgisonline.com/arcgis/rest/services/' - 'World_Topo_Map/MapServer'; - -const _lightGrayBaseUri = - 'https://services.arcgisonline.com/arcgis/rest/services/' - 'Canvas/World_Light_Gray_Base/MapServer'; - -const _darkGrayBaseUri = - 'https://services.arcgisonline.com/arcgis/rest/services/' - 'Canvas/World_Dark_Gray_Base/MapServer'; - -// Nearmap tile service -const _nearmapUri = - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'Campus_Imagery_Nearmap_9in_2025_0423_0514/MapServer'; - -// AGE portal for campus vector tiles -final _agePortal = Portal( - Uri.parse('https://admin-enterprise-gis.ucsd.edu/portal'), -); - - +String _typeKey(BasemapType type) { + switch (type) { + case BasemapType.defaultMap: return 'defaultMap'; + case BasemapType.light: return 'light'; + case BasemapType.dark: return 'dark'; + case BasemapType.satellite: return 'satellite'; + } +} -Basemap buildBasemap(BasemapType type) { +Basemap buildBasemap(BasemapType type, EsriMapConfig config) { final basemap = Basemap(); + final agePortal = Portal(Uri.parse(config.agePortalUrl)); if (type == BasemapType.satellite) { - basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri))); - basemap.baseLayers.add(ArcGISMapImageLayer.withUri(Uri.parse(_nearmapUri))); + basemap.baseLayers.add( + ArcGISTiledLayer.withUri(Uri.parse(config.esriTileUrls.lightGrayBase)), + ); + basemap.baseLayers.add( + ArcGISMapImageLayer.withUri(Uri.parse(config.nearmapUrl)), + ); } else { - basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(_hillshadeUri))); - basemap.baseLayers.add(_esriContextLayer(type)); + basemap.baseLayers.add( + ArcGISTiledLayer.withUri(Uri.parse(config.esriTileUrls.hillshade)), + ); + final contextUrl = type == BasemapType.dark + ? config.esriTileUrls.darkGrayBase + : config.esriTileUrls.lightGrayBase; + basemap.baseLayers.add( + ArcGISTiledLayer.withUri(Uri.parse(contextUrl)), + ); + final itemId = config.basemaps[_typeKey(type)]?.itemId; + if (itemId != null) { + basemap.baseLayers.add( + ArcGISVectorTiledLayer.withItem( + PortalItem.withPortalAndItemId(portal: agePortal, itemId: itemId), + ), + ); + } } - if (type != BasemapType.satellite) { - basemap.baseLayers.add(_ucsdCampusLayer(type)); - } return basemap; -} - -ArcGISTiledLayer _esriContextLayer(BasemapType type) { - switch (type) { - case BasemapType.defaultMap: - return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); - case BasemapType.light: - return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); - case BasemapType.dark: - return ArcGISTiledLayer.withUri(Uri.parse(_darkGrayBaseUri)); - case BasemapType.satellite: - return ArcGISTiledLayer.withUri(Uri.parse(_lightGrayBaseUri)); - } -} - -ArcGISVectorTiledLayer _ucsdCampusLayer(BasemapType type) { - final portalItem = PortalItem.withPortalAndItemId( - portal: _agePortal, - itemId: basemapOptions[type]!.itemId, - ); - return ArcGISVectorTiledLayer.withItem(portalItem); } \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_config.dart b/lib/ui/esrimap/esrimap_config.dart new file mode 100644 index 000000000..6598a5359 --- /dev/null +++ b/lib/ui/esrimap/esrimap_config.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// Models +class EsriMapConfig { + final String agePortalUrl; + final String agoPortalUrl; + final String nearmapUrl; + final EsriTileUrls esriTileUrls; + final Map basemaps; + final LayerUrls layers; + final Map scenes; + + const EsriMapConfig({ + required this.agePortalUrl, + required this.agoPortalUrl, + required this.nearmapUrl, + required this.esriTileUrls, + required this.basemaps, + required this.layers, + required this.scenes, + }); + + factory EsriMapConfig.fromJson(Map json) { + return EsriMapConfig( + agePortalUrl: json['agePortalUrl'] as String, + agoPortalUrl: json['agoPortalUrl'] as String, + nearmapUrl: json['nearmapUrl'] as String, + esriTileUrls: EsriTileUrls.fromJson(json['esriTileUrls'] as Map), + basemaps: (json['basemaps'] as Map).map( + (k, v) => MapEntry(k, BasemapConfig.fromJson(v as Map)), + ), + layers: LayerUrls.fromJson(json['layers'] as Map), + scenes: (json['scenes'] as Map).map( + (k, v) => MapEntry(k, SceneConfig.fromJson(v as Map)), + ), + ); + } +} + +class BasemapConfig { + final String label; + final String itemId; + const BasemapConfig({required this.label, required this.itemId}); + factory BasemapConfig.fromJson(Map json) => BasemapConfig( + label: json['label'] as String, + itemId: json['itemId'] as String, + ); +} + +class EsriTileUrls { + final String hillshade; + final String lightGrayBase; + final String darkGrayBase; + const EsriTileUrls({required this.hillshade, required this.lightGrayBase, required this.darkGrayBase}); + factory EsriTileUrls.fromJson(Map json) => EsriTileUrls( + hillshade: json['hillshade'] as String, + lightGrayBase: json['lightGrayBase'] as String, + darkGrayBase: json['darkGrayBase'] as String, + ); +} + +class LayerUrls { + final String transitRoutes; + final String transitShuttles; + final String campusDistricts; + final String construction; + final String assemblyAreas; + const LayerUrls({ + required this.transitRoutes, + required this.transitShuttles, + required this.campusDistricts, + required this.construction, + required this.assemblyAreas, + }); + factory LayerUrls.fromJson(Map json) => LayerUrls( + transitRoutes: json['transitRoutes'] as String, + transitShuttles: json['transitShuttles'] as String, + campusDistricts: json['campusDistricts'] as String, + construction: json['construction'] as String, + assemblyAreas: json['assemblyAreas'] as String, + ); +} + +class SceneConfig { + final String portalUrl; + final String itemId; + const SceneConfig({required this.portalUrl, required this.itemId}); + factory SceneConfig.fromJson(Map json) => SceneConfig( + portalUrl: json['portalUrl'] as String, + itemId: json['itemId'] as String, + ); +} + +// Service +class EsriMapConfigService { + static final EsriMapConfigService instance = EsriMapConfigService._(); + EsriMapConfigService._(); + + static const _baseUrl = + 'https://appzxi70zi.execute-api.us-west-2.amazonaws.com/test/ArcGIS-Map'; + + EsriMapConfig? _config; + Future? _pending; + + /// Fetches config once and caches it for the session. + Future fetch() => _pending ??= _doFetch(); + + EsriMapConfig? get cached => _config; + + String get tokensUrl => '$_baseUrl/tokens'; + + Future _doFetch() async { + final response = await http.get(Uri.parse('$_baseUrl/config')); + if (response.statusCode != 200) { + throw Exception('Config fetch failed: ${response.statusCode}'); + } + _config = EsriMapConfig.fromJson( + jsonDecode(response.body) as Map, + ); + return _config!; + } +} \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index e234ada10..b55505145 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -221,7 +221,7 @@ class EsriMapLayersPanel extends StatelessWidget { children: [ for (final type in BasemapType.values) ...[ imageTile( - label: basemapOptions[type]!.label, + label: basemapLabels[type]!, selected: currentBasemapType == type, onTap: () => onSwitchBasemap(type), imageWidget: Image.asset( diff --git a/lib/ui/esrimap/esrimap_models.dart b/lib/ui/esrimap/esrimap_models.dart deleted file mode 100644 index 6696836ce..000000000 --- a/lib/ui/esrimap/esrimap_models.dart +++ /dev/null @@ -1,1815 +0,0 @@ -import 'dart:convert'; -import 'dart:math' as math; -import 'package:arcgis_maps/arcgis_maps.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; - -// ----------------------------------------------------------------------------- -// Model -// ----------------------------------------------------------------------------- - -/// Unified search result model for both Buildings and POIs. -class MapSearchResult { - final String name; - final String subtitle; - final double latitude; - final double longitude; - final MapSearchSource source; - final String address; - final String description; - final String? websiteUrl; - - const MapSearchResult({ - required this.name, - required this.subtitle, - required this.latitude, - required this.longitude, - required this.source, - this.address = '', - this.description = '', - this.websiteUrl, - }); - - /// Serialize to JSON for SharedPreferences storage. - Map toJson() => { - 'name': name, - 'subtitle': subtitle, - 'latitude': latitude, - 'longitude': longitude, - 'source': source.index, - 'address': address, - 'description': description, - 'websiteUrl': websiteUrl, - }; - - /// Deserialize from JSON. - factory MapSearchResult.fromJson(Map json) { - return MapSearchResult( - name: json['name'] as String? ?? '', - subtitle: json['subtitle'] as String? ?? '', - latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, - longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, - source: MapSearchSource.values[json['source'] as int? ?? 0], - address: json['address'] as String? ?? '', - description: json['description'] as String? ?? '', - websiteUrl: json['websiteUrl'] as String?, - ); - } -} - -enum MapSearchSource { building, poi } - -// ----------------------------------------------------------------------------- -// Category definitions -// ----------------------------------------------------------------------------- - -class _SearchCategory { - final String label; - final IconData icon; - final String poiClassValue; // maps to POI "Class" field value - - const _SearchCategory({ - required this.label, - required this.icon, - required this.poiClassValue, - }); -} - -const _categories = [ - _SearchCategory( - label: 'Parking', - icon: Icons.local_parking, - poiClassValue: 'Parking', - ), - _SearchCategory( - label: 'Dining', - icon: Icons.restaurant, - poiClassValue: 'Dining and Beverage', - ), - _SearchCategory( - label: 'Recreation', - icon: Icons.fitness_center, - poiClassValue: 'Athletic Facilities', - ), - _SearchCategory( - label: 'Transit', - icon: Icons.directions_bus, - poiClassValue: 'Transit', - ), -]; - -// ----------------------------------------------------------------------------- -// Slide-over header delegate -// ----------------------------------------------------------------------------- - -class _SlideOverHeaderDelegate extends SliverPersistentHeaderDelegate { - final Widget child; - final double height; - final Color backgroundColor; - - _SlideOverHeaderDelegate({ - required this.child, - required this.height, - required this.backgroundColor, - }); - - @override - double get minExtent => height; - @override - double get maxExtent => height; - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return Container( - height: height, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: shrinkOffset == 0 - ? const BorderRadius.vertical(top: Radius.circular(16)) - : null, - ), - child: child, - ); - } - - @override - bool shouldRebuild(covariant _SlideOverHeaderDelegate oldDelegate) { - return child != oldDelegate.child || - height != oldDelegate.height || - backgroundColor != oldDelegate.backgroundColor; - } -} - -// ----------------------------------------------------------------------------- -// Widget -// ----------------------------------------------------------------------------- - -class EsriMap extends StatefulWidget { - const EsriMap({Key? key}) : super(key: key); - - @override - State createState() => _EsriMapState(); -} - -class _EsriMapState extends State with AutomaticKeepAliveClientMixin { - final _mapViewController = ArcGISMapView.createController(); - late final ArcGISMap _map; - final _graphicsOverlay = GraphicsOverlay(); - final _searchController = TextEditingController(); - final _focusNode = FocusNode(); - - // Slide-over controllers - final _categorySheetController = DraggableScrollableController(); - final _detailSheetController = DraggableScrollableController(); - - // Service endpoints - static const _buildingsQueryUrl = - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'AdministrationServices/Buildings_Public/MapServer/0/query'; - static const _poiQueryUrl = - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Points_Of_Interest/FeatureServer/0/query'; - - // SharedPreferences key for recent searches - static const _recentSearchesKey = 'esri_map_recent_searches'; - static const _maxRecentSearches = 5; - - // Search state - List _searchResults = []; - List _allPoiClasses = []; // all distinct Class values from the server - List _matchingPoiClasses = []; // live-filtered subset while typing - bool _showResults = false; - bool _isSearching = false; - bool _showSuggestions = false; - - // Recent searches - List _recentSearches = []; - - // Detail slide-over state - MapSearchResult? _selectedResult; - MapSearchResult? _lastSelectedResult; - - // Results currently plotted as pins on the map (from category search) - List _mappedResults = []; - - // Full unfiltered result set for the active category (used by "See All") - List _allCategoryResults = []; - - // Whether to show the "See All" pill button - bool _showSeeAll = false; - - // Whether to show the full category list panel - bool _showCategoryList = false; - - // The active category (for display in the "See All" label) - _SearchCategory? _activeCategory; - - // Location display - final _locationDataSource = SystemLocationDataSource(); - bool _locationStarted = false; - - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - _initMap(); - _loadRecentSearches(); - _fetchAllPoiClasses(); - // Listen for focus changes to show/hide suggestions - _focusNode.addListener(_onFocusChanged); - } - - @override - void dispose() { - _categorySheetController.dispose(); - _detailSheetController.dispose(); - _searchController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _initMap() { - final hillshadeLayer = ArcGISTiledLayer.withUri( - Uri.parse( - 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', - ), - ); - - final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( - Uri.parse( - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', - ), - ); - - final basemap = Basemap(); - basemap.baseLayers.add(hillshadeLayer); - basemap.baseLayers.add(campusVectorTileLayer); - - _map = ArcGISMap.withBasemap(basemap); - _map.initialViewpoint = Viewpoint.fromCenter( - ArcGISPoint( - x: -117.2340, - y: 32.8801, - spatialReference: SpatialReference.wgs84, - ), - scale: 24000, - ); - } - - void _onMapViewReady() { - _mapViewController.arcGISMap = _map; - _mapViewController.interactionOptions.rotateEnabled = false; - _mapViewController.graphicsOverlays.add(_graphicsOverlay); - - // Wire up location display — blue dot, no auto-pan on start - _mapViewController.locationDisplay.dataSource = _locationDataSource; - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.off; - - _startLocationDisplay(); - } - - Future _startLocationDisplay() async { - try { - await _locationDataSource.start(); - setState(() => _locationStarted = true); - } on ArcGISException catch (e) { - debugPrint('Location error: ${e.message}'); - } catch (e) { - debugPrint('Location error: $e'); - } - } - - void _onFocusChanged() { - if (_focusNode.hasFocus && _searchController.text.isEmpty) { - setState(() { - _showSuggestions = true; - _showResults = false; - }); - } - } - - // --------------------------------------------------------------------------- - // Recent searches persistence - // --------------------------------------------------------------------------- - - Future _loadRecentSearches() async { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = prefs.getString(_recentSearchesKey); - if (jsonStr != null) { - try { - final list = jsonDecode(jsonStr) as List; - setState(() { - _recentSearches = list - .map((e) => - MapSearchResult.fromJson(e as Map)) - .toList(); - }); - } catch (_) { - // ignore corrupted data - } - } - } - - Future _saveRecentSearches() async { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = - jsonEncode(_recentSearches.map((r) => r.toJson()).toList()); - await prefs.setString(_recentSearchesKey, jsonStr); - } - - void _addToRecentSearches(MapSearchResult result) { - // Remove duplicate if exists (match by name + source) - _recentSearches.removeWhere( - (r) => r.name == result.name && r.source == result.source); - // Insert at front - _recentSearches.insert(0, result); - // Trim to max - if (_recentSearches.length > _maxRecentSearches) { - _recentSearches = _recentSearches.sublist(0, _maxRecentSearches); - } - _saveRecentSearches(); - } - - void _removeFromRecentSearches(int index) { - setState(() { - _recentSearches.removeAt(index); - }); - _saveRecentSearches(); - } - - // --------------------------------------------------------------------------- - // Query helpers - // --------------------------------------------------------------------------- - - String _escSql(String input) => input.replaceAll("'", "''"); - - /// Query the Buildings (AGE) MapServer - Future> _queryBuildings(String query) async { - await dotenv.load(fileName: ".env"); - final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; - final escaped = _escSql(query); - final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " - "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; - - final uri = Uri.parse(_buildingsQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '8', - 'f': 'json', - if (token.isNotEmpty) 'token': token, - }, - ); - - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final name = - (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; - final alias = (attrs['BuildingAliases'] as String?) ?? ''; - final street = (attrs['StreetAddress'] as String?) ?? ''; - final city = (attrs['City'] as String?) ?? ''; - final zip = (attrs['Zipcode'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final addressParts = [ - if (street.isNotEmpty) street, - if (city.isNotEmpty) city, - if (zip.isNotEmpty) zip, - ]; - final fullAddress = addressParts.join(', '); - final subtitle = alias.isNotEmpty ? alias : 'Building'; - - return MapSearchResult( - name: name, - subtitle: subtitle, - latitude: lat, - longitude: lng, - source: MapSearchSource.building, - address: fullAddress, - ); - }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); - } - - /// Fetches all distinct POI Class values from the FeatureServer and caches them. - Future _fetchAllPoiClasses() async { - try { - final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { - 'where': '1=1', - 'outFields': 'Class', - 'returnDistinctValues': 'true', - 'orderByFields': 'Class', - 'returnGeometry': 'false', - 'resultRecordCount': '200', - 'f': 'json', - }); - final response = await http.get(uri); - if (response.statusCode != 200) return; - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - final classes = features - .map((f) => (f['attributes']['Class'] as String?) ?? '') - .where((c) => c.isNotEmpty) - .toList() - ..sort(); - setState(() => _allPoiClasses = classes); - print(_allPoiClasses); - } catch (e) { - debugPrint('Failed to fetch POI classes: $e'); - } - } - - /// Query the POIs (AGO) FeatureServer by text search. - Future> _queryPOIs(String query) async { - final escaped = _escSql(query); - final where = "UpdatedName LIKE '%$escaped%' " - "OR C3DName LIKE '%$escaped%' " - "OR C3DKeywords LIKE '%$escaped%'"; - return _executePOIQuery(where); - } - - /// Query POIs filtered by a specific Class value (for category taps). - Future> _queryPOIsByClass(String classValue) async { - final escaped = _escSql(classValue); - final where = "Class = '$escaped'"; - return _executePOIQuery(where, maxResults: 100); - } - - /// Shared POI query execution. - Future> _executePOIQuery( - String where, { - int maxResults = 8, - }) async { - final uri = Uri.parse(_poiQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '$maxResults', - 'f': 'json', - }, - ); - - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final updatedName = (attrs['UpdatedName'] as String?) ?? ''; - final c3dName = (attrs['C3DName'] as String?) ?? ''; - final name = updatedName.isNotEmpty ? updatedName : c3dName; - final poiClass = (attrs['Class'] as String?) ?? ''; - final subclass = (attrs['Subclass'] as String?) ?? ''; - final description = (attrs['C3DDescription'] as String?) ?? ''; - final url = (attrs['URL'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final subtitle = - subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; - - return MapSearchResult( - name: name.isNotEmpty ? name : 'Unknown POI', - subtitle: subtitle, - latitude: lat, - longitude: lng, - source: MapSearchSource.poi, - description: description, - websiteUrl: url.isNotEmpty ? url : null, - ); - }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); - } - - // --------------------------------------------------------------------------- - // Search actions - // --------------------------------------------------------------------------- - - /// Text search: query both buildings and POIs in parallel. - Future _performSearch(String query) async { - if (query.trim().isEmpty) return; - - // Recompute category matches on submit - final q = query.toLowerCase(); - final matched = _allPoiClasses - .where((c) => c.toLowerCase().contains(q)) - .toList(); - - for (final cat in _categories) { - if (cat.poiClassValue.toLowerCase().contains(q) && - !matched.contains(cat.poiClassValue)) { - matched.insert(0, cat.poiClassValue); - } - } - - setState(() { - _isSearching = true; - _showResults = true; - _showSuggestions = false; - _selectedResult = null; - _matchingPoiClasses = matched; - }); - - try { - final results = await Future.wait([ - _queryBuildings(query), - _queryPOIs(query), - ]); - final merged = [...results[0], ...results[1]]; - setState(() { - _searchResults = merged; - _isSearching = false; - }); - } catch (e) { - print('Search error: $e'); - setState(() { - _searchResults = []; - _isSearching = false; - }); - } - } - - /// Returns the current visible map extent projected to WGS84. - Envelope? _getViewportEnvelopeWGS84() { - final vp = _mapViewController.getCurrentViewpoint( - ViewpointType.boundingGeometry, - ); - if (vp == null) return null; - final geom = vp.targetGeometry; - if (geom == null) return null; - final projected = GeometryEngine.project( - geom, - outputSpatialReference: SpatialReference.wgs84, - ); - return projected as Envelope?; - } - - /// Filters a result list to only those within the current map viewport. - /// Returns the full list unchanged if the envelope can't be determined. - List _filterToViewport(List results) { - final env = _getViewportEnvelopeWGS84(); - if (env == null) return results; - return results.where((r) { - return r.latitude >= env.yMin && - r.latitude <= env.yMax && - r.longitude >= env.xMin && - r.longitude <= env.xMax; - }).toList(); - } - - /// distance in meters between two WGS84 points - double _distanceMeters( - double lat1, double lng1, double lat2, double lng2) { - const r = 6371000.0; - final dLat = (lat2 - lat1) * math.pi / 180; - final dLng = (lng2 - lng1) * math.pi / 180; - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(lat1 * math.pi / 180) * - math.cos(lat2 * math.pi / 180) * - math.sin(dLng / 2) * - math.sin(dLng / 2); - return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); - } - - /// Returns the user's current WGS84 lat/lng, or null if unavailable. - (double lat, double lng)? _getUserLatLng() { - final pos = _mapViewController.locationDisplay.location?.position; - if (pos == null) return null; - final wgs = GeometryEngine.project( - pos, - outputSpatialReference: SpatialReference.wgs84, - ) as ArcGISPoint?; - if (wgs == null) return null; - return (wgs.y, wgs.x); - } - - /// Plot a list of results as pins on the map. - void _plotResultsOnMap(List results) { - _graphicsOverlay.graphics.clear(); - _mappedResults = results; - for (int i = 0; i < results.length; i++) { - final result = results[i]; - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - final graphic = Graphic( - geometry: point, - symbol: SimpleMarkerSymbol( - style: SimpleMarkerSymbolStyle.circle, - color: Colors.red, - size: 14, - ), - ); - // Store the list index so we can retrieve the result on tap - graphic.attributes['resultIndex'] = i; - _graphicsOverlay.graphics.add(graphic); - } - } - - /// Zoom the map to fit all mapped results, with padding. - void _zoomToResults(List results) { - if (results.isEmpty) return; - - if (results.length == 1) { - final r = results.first; - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter( - ArcGISPoint( - x: r.longitude, - y: r.latitude, - spatialReference: SpatialReference.wgs84, - ), - scale: 5000, - ), - ); - return; - } - - double minLat = results.map((r) => r.latitude).reduce((a, b) => a < b ? a : b); - double maxLat = results.map((r) => r.latitude).reduce((a, b) => a > b ? a : b); - double minLng = results.map((r) => r.longitude).reduce((a, b) => a < b ? a : b); - double maxLng = results.map((r) => r.longitude).reduce((a, b) => a > b ? a : b); - - const padding = 0.005; - final envelope = Envelope.fromXY( - xMin: minLng - padding, - yMin: minLat - padding, - xMax: maxLng + padding, - yMax: maxLat + padding, - spatialReference: SpatialReference.wgs84, - ); - _mapViewController.setViewpointAnimated( - Viewpoint.fromTargetExtent(envelope), - ); - } - - /// Category search: query POIs filtered by Class. - /// Category search: query POIs filtered by Class and plot all as map pins. - Future _performCategorySearch(_SearchCategory category) async { - setState(() { - _isSearching = true; - _showResults = false; - _showSuggestions = false; - _selectedResult = null; - _showSeeAll = false; - _showCategoryList = false; - _activeCategory = category; - _searchController.text = category.label; - }); - _focusNode.unfocus(); - - try { - final allResults = await _queryPOIsByClass(category.poiClassValue); - - // Always plot ALL pins — no zoom, no viewport filtering. - // The user pans/zooms freely to discover them. - _plotResultsOnMap(allResults); - - setState(() { - _allCategoryResults = allResults; - // Show "Zoom to all" pill whenever there are results - _showSeeAll = allResults.isNotEmpty; - _isSearching = false; - }); - } catch (e) { - print('Category search error: $e'); - setState(() { - _mappedResults = []; - _allCategoryResults = []; - _showSeeAll = false; - _isSearching = false; - }); - } - } - - /// Triggers a category-style search from a raw POI class string. - Future _performClassSearch(String classValue) async { - final category = _SearchCategory( - label: _labelForClass(classValue), - icon: _iconForClass(classValue), - poiClassValue: classValue, - ); - await _performCategorySearch(category); - } - - /// Plots all results for the active category - void _seeAllCategoryResults() { - if (_allCategoryResults.isEmpty) return; - _zoomToResults(_allCategoryResults); - setState(() => _showSeeAll = false); - } - - void _selectResult(MapSearchResult result) { - _graphicsOverlay.graphics.clear(); - - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - - final graphic = Graphic( - geometry: point, - symbol: SimpleMarkerSymbol( - style: SimpleMarkerSymbolStyle.circle, - color: result.source == MapSearchSource.building - ? Colors.blue - : Colors.red, - size: 14, - ), - ); - _graphicsOverlay.graphics.add(graphic); - - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter(point, scale: 5000), - ); - - setState(() { - _showResults = false; - _showSuggestions = false; - _searchController.text = result.name; - _selectedResult = result; - _lastSelectedResult = result; - }); - _focusNode.unfocus(); - - // Save to recent searches - _addToRecentSearches(result); - } - - /// Called when the ArcGISMapView is tapped - /// Identifies any graphic at the tap location and shows its detail panel - Future _onMapTap(Offset screenPoint) async { - // Dismiss any open dropdowns first - if (_showSuggestions || _showResults) { - setState(() { - _showSuggestions = false; - _showResults = false; - }); - _focusNode.unfocus(); - return; - } - - // Only attempt identify when pins are on the map - if (_mappedResults.isEmpty) { - if (_selectedResult != null) _closeDetail(); - return; - } - - try { - final identifyResult = await _mapViewController.identifyGraphicsOverlay( - _graphicsOverlay, - screenPoint: screenPoint, - tolerance: 12.0, - maximumResults: 1, - ); - - if (identifyResult.graphics.isNotEmpty) { - final tappedGraphic = identifyResult.graphics.first; - final index = tappedGraphic.attributes['resultIndex'] as int?; - if (index != null && index >= 0 && index < _mappedResults.length) { - _selectResultFromPin(_mappedResults[index]); - } - } else { - // Tapped empty space — close detail if open - if (_selectedResult != null) _closeDetail(); - } - } catch (e) { - print('Identify error: $e'); - } - } - - /// Select a result from a map pin tap - /// keeps all existing category pins visible - void _selectResultFromPin(MapSearchResult result) { - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter(point, scale: 5000), - ); - - setState(() { - _selectedResult = result; - _lastSelectedResult = result; - _searchController.text = result.name; - _showResults = false; - _showSuggestions = false; - _showCategoryList = false; - }); - _focusNode.unfocus(); - _addToRecentSearches(result); - } - - void _closeDetail() { - setState(() { - _selectedResult = null; - // If a category search is active, reopen the list view - if (_allCategoryResults.isNotEmpty) { - _showCategoryList = true; - } - }); - } - - void _reopenDetail() { - if (_lastSelectedResult != null) { - setState(() { - _selectedResult = _lastSelectedResult; - }); - } - } - - void _recenterOnUser() { - if (!_locationStarted) { - _startLocationDisplay(); - return; - } - final loc = _mapViewController.locationDisplay.location; - if (loc == null) return; - - // Briefly snap to recenter mode, then release back to free pan - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.recenter; - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.off; - } - }); - } - - void _clearSearch() { - _searchController.clear(); - _graphicsOverlay.graphics.clear(); - setState(() { - _searchResults = []; - _matchingPoiClasses = []; - _mappedResults = []; - _allCategoryResults = []; - _showResults = false; - _showSuggestions = false; - _showSeeAll = false; - _showCategoryList = false; - _activeCategory = null; - _selectedResult = null; - _lastSelectedResult = null; - }); - } - - Future _launchDirections(MapSearchResult result) async { - final uri = Uri.parse( - 'https://www.google.com/maps/dir/?api=1' - '&destination=${result.latitude},${result.longitude}', - ); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - Future _launchWebsite(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - IconData _iconForResult(MapSearchResult result) { - return result.source == MapSearchSource.building - ? Icons.business - : Icons.place; - } - - /// Returns the icon for a POI class — uses the hardcoded category icon if one - /// exists, otherwise falls back to a generic place icon. - IconData _iconForClass(String classValue) { - return _categories - .firstWhere( - (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase(), - orElse: () => const _SearchCategory( - label: '', icon: Icons.place, poiClassValue: ''), - ) - .icon; - } - - /// Converts a raw class value like "Food and Beverage" into a display label. - /// Uses the hardcoded category label if available, otherwise returns as-is. - String _labelForClass(String classValue) { - final match = _categories.where( - (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase()); - return match.isNotEmpty ? match.first.label : classValue; - } - - // --------------------------------------------------------------------------- - // Suggestions panel (categories + recent searches) - // --------------------------------------------------------------------------- - - Widget _buildSuggestionsPanel(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bgColor = isDark ? Colors.grey[850] : Colors.white; - - return Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: bgColor, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category section header - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Suggested', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - SizedBox(height: 12), - - // Category icons row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: _categories - .map((cat) => _buildCategoryChip(context, cat)) - .toList(), - ), - - // Recent searches section - if (_recentSearches.isNotEmpty) ...[ - SizedBox(height: 16), - Divider(height: 1), - SizedBox(height: 8), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Recently viewed', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - SizedBox(height: 4), - ...List.generate(_recentSearches.length, (index) { - final recent = _recentSearches[index]; - return ListTile( - dense: true, - leading: Icon( - Icons.history, - size: 20, - color: isDark ? Colors.grey[500] : Colors.grey[400], - ), - title: Text( - recent.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: GestureDetector( - onTap: () => _removeFromRecentSearches(index), - child: Icon( - Icons.close, - size: 16, - color: isDark ? Colors.grey[500] : Colors.grey[400], - ), - ), - onTap: () => _selectResult(recent), - ); - }), - ], - ], - ), - ), - ); - } - - Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () => _performCategorySearch(category), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: isDark ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - category.icon, - size: 24, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - ), - SizedBox(height: 6), - Text( - category.label, - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ); - } - - Widget _buildSeeAllButton(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final total = _allCategoryResults.length; - - return GestureDetector( - onTap: _seeAllCategoryResults, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.layers_outlined, - size: 16, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - const SizedBox(width: 6), - Text( - 'See all $total results', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.grey[900], - ), - ), - ], - ), - ), - ); - } - - // --------------------------------------------------------------------------- - // Map interaction → collapse slideover - // --------------------------------------------------------------------------- - - void _onMapPointerDown(PointerDownEvent _) { - // Collapse whichever sheet is active to the minimum snap - if (_showCategoryList && _selectedResult == null) { - if (_categorySheetController.isAttached) { - _categorySheetController.animateTo( - 0.15, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - } - } - if (_selectedResult != null) { - if (_detailSheetController.isAttached) { - _detailSheetController.animateTo( - 0.15, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - } - } - } - - // --------------------------------------------------------------------------- - // Shared slide-over template - // --------------------------------------------------------------------------- - - /// Builds a consistent DraggableScrollableSheet with a pinned header. - /// [headerTitle] and [headerIcon] are shown in the header row. - /// [onClose] is called when the X button is tapped. - /// [trailing] is an optional widget shown before the close button (e.g. "Search here"). - /// [sliverBody] is the scrollable content below the header. - static const _slideOverHeaderHeight = 80.0; - - Widget _buildSlideOverContent({ - required BuildContext context, - required ScrollController scrollController, - required String headerTitle, - IconData? headerIcon, - required VoidCallback onClose, - Widget? trailing, - required List sliverBody, - }) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bgColor = isDark ? Colors.grey[900]! : Colors.white; - - return Material( - elevation: 8, - color: bgColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - clipBehavior: Clip.antiAlias, - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _SlideOverHeaderDelegate( - height: _slideOverHeaderHeight, - backgroundColor: bgColor, - child: Column( - children: [ - // Drag handle - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 4), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: isDark ? Colors.grey[700] : Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - // Header row - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 8), - child: Row( - children: [ - if (headerIcon != null) ...[ - Icon( - headerIcon, - size: 18, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - const SizedBox(width: 8), - ], - Expanded( - child: Text( - headerTitle, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.grey[900], - ), - ), - ), - if (trailing != null) trailing!, - GestureDetector( - onTap: onClose, - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - isDark ? Colors.grey[800] : Colors.grey[200], - ), - child: Icon( - Icons.close, - size: 18, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - // Divider below header - SliverToBoxAdapter( - child: Divider(height: 1), - ), - ...sliverBody, - ], - ), - ); - } - - // --------------------------------------------------------------------------- - // List view - // --------------------------------------------------------------------------- - Widget _buildCategoryListPanel(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final label = _activeCategory?.label ?? 'Results'; - - final viewport = _filterToViewport(_allCategoryResults); - final userPos = _getUserLatLng(); - final results = List.from(viewport); - if (userPos != null) { - results.sort((a, b) => - _distanceMeters(userPos.$1, userPos.$2, a.latitude, a.longitude) - .compareTo( - _distanceMeters( - userPos.$1, userPos.$2, b.latitude, b.longitude))); - } - - // Content-aware sizing: estimate content height as fraction of screen - final screenHeight = MediaQuery.of(context).size.height; - // Each list tile ~56px + header ~80px + divider - final contentHeight = - _slideOverHeaderHeight + (results.length * 56.0).clamp(56.0, 600.0); - final contentFraction = (contentHeight / screenHeight).clamp(0.15, 0.80); - final initialSize = - contentFraction < 0.45 ? contentFraction : 0.45; - final maxSize = contentFraction < 0.80 ? contentFraction.clamp(0.45, 0.80) : 0.80; - // Snap sizes must be strictly increasing and within [min, max] - final snaps = [0.15]; - if (initialSize > 0.15 + 0.01) snaps.add(initialSize); - - return DraggableScrollableSheet( - controller: _categorySheetController, - initialChildSize: initialSize, - minChildSize: 0.15, - maxChildSize: maxSize, - snap: true, - snapSizes: snaps, - builder: (context, scrollController) { - final headerTitle = results.isEmpty - ? '$label - None in view' - : '$label - ${results.length} in view'; - - return _buildSlideOverContent( - context: context, - scrollController: scrollController, - headerTitle: headerTitle, - headerIcon: _activeCategory?.icon ?? Icons.place, - onClose: () => setState(() => _showCategoryList = false), - trailing: TextButton.icon( - icon: Icon( - Icons.refresh, - size: 16, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - label: Text( - 'Search here', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - ), - style: TextButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - onPressed: () => setState(() {}), - ), - sliverBody: results.isEmpty - ? [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.location_off, - size: 32, - color: isDark - ? Colors.grey[600] - : Colors.grey[400]), - const SizedBox(height: 8), - Text( - 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: isDark - ? Colors.grey[500] - : Colors.grey[600], - ), - ), - ], - ), - ), - ), - ] - : [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final r = results[index]; - final dist = userPos != null - ? _distanceMeters(userPos.$1, userPos.$2, - r.latitude, r.longitude) - : null; - final distLabel = dist == null - ? null - : dist < 1000 - ? '${dist.round()} m away' - : '${(dist / 1000).toStringAsFixed(1)} km away'; - - return Column( - children: [ - ListTile( - splashColor: Colors.transparent, - leading: Icon( - _iconForResult(r), - size: 20, - color: isDark - ? Colors.grey[400] - : Colors.grey[600], - ), - title: Text( - r.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - subtitle: distLabel != null - ? Text(distLabel, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - )) - : (r.subtitle.isNotEmpty - ? Text(r.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - const TextStyle(fontSize: 12)) - : null), - dense: true, - onTap: () => _selectResultFromPin(r), - ), - if (index < results.length - 1) - const Divider(height: 1), - ], - ); - }, - childCount: results.length, - ), - ), - ], - ); - }, - ); - } - - // --------------------------------------------------------------------------- - // Detail slide-over - // --------------------------------------------------------------------------- - - Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - final detailText = result.source == MapSearchSource.building - ? result.address - : result.description; - final categoryLabel = result.source == MapSearchSource.building - ? 'Building' - : result.subtitle; - - // Content-aware sizing: estimate how tall the detail content is - final screenHeight = MediaQuery.of(context).size.height; - // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 - double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; - if (detailText.isNotEmpty) contentEst += 60; - final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); - final initialSize = - contentFraction < 0.30 ? contentFraction : 0.30; - final maxSize = - contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; - // Snap sizes must be strictly increasing and within [min, max] - final snaps = [0.15]; - if (initialSize > 0.15 + 0.01) snaps.add(initialSize); - - return DraggableScrollableSheet( - controller: _detailSheetController, - initialChildSize: initialSize, - minChildSize: 0.15, - maxChildSize: maxSize, - snap: true, - snapSizes: snaps, - builder: (context, scrollController) { - return _buildSlideOverContent( - context: context, - scrollController: scrollController, - headerTitle: result.name, - headerIcon: _iconForResult(result), - onClose: _closeDetail, - sliverBody: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category label - Text( - categoryLabel, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - - // Detail text - if (detailText.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - detailText, - style: TextStyle( - fontSize: 14, - color: - isDark ? Colors.grey[400] : Colors.grey[600], - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 16), - - // Action buttons - Row( - children: [ - if (result.websiteUrl != null) ...[ - Expanded( - child: OutlinedButton.icon( - onPressed: () => - _launchWebsite(result.websiteUrl!), - icon: const Icon(Icons.language, size: 18), - label: const Text('View website'), - style: OutlinedButton.styleFrom( - foregroundColor: isDark - ? const Color(0xFFFFCD00) - : null, - side: isDark - ? const BorderSide( - color: Color(0xFFFFCD00)) - : null, - padding: const EdgeInsets.symmetric( - vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - const SizedBox(width: 12), - ], - Expanded( - child: FilledButton.icon( - onPressed: () => _launchDirections(result), - icon: const Icon(Icons.directions, size: 18), - label: const Text('Get Directions'), - style: FilledButton.styleFrom( - padding: - const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ], - ); - }, - ); - } - - // --------------------------------------------------------------------------- - // Build - // --------------------------------------------------------------------------- - - @override - Widget build(BuildContext context) { - super.build(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - body: Stack( - children: [ - // Map — Listener detects pointer-down to collapse slideovers - Column( - children: [ - Expanded( - child: Listener( - onPointerDown: _onMapPointerDown, - child: ArcGISMapView( - controllerProvider: () => _mapViewController, - onMapViewReady: _onMapViewReady, - onTap: _onMapTap, - ), - ), - ), - ], - ), - - // Floating search bar + dropdown - Positioned( - top: 8, - left: 12, - right: 12, - child: Column( - children: [ - // Search bar - Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: isDark ? Colors.grey[850] : Colors.white, - child: Row( - children: [ - Padding( - padding: EdgeInsets.only(left: 12), - child: Icon( - Icons.search, - color: isDark ? Colors.white70 : Colors.grey[600], - ), - ), - Expanded( - child: TextField( - controller: _searchController, - focusNode: _focusNode, - textInputAction: TextInputAction.search, - onSubmitted: _performSearch, - onChanged: (text) { - if (text.isEmpty) { - setState(() { - _showResults = false; - _searchResults = []; - _matchingPoiClasses = []; - _showSuggestions = _focusNode.hasFocus; - }); - } else { - final q = text.toLowerCase(); - // Match against server-fetched classes - final matched = _allPoiClasses - .where((c) => c.toLowerCase().contains(q)) - .toList(); - - // Always include hardcoded category class values as a fallback - // so chips appear even if the server fetch is still in flight - for (final cat in _categories) { - if (cat.poiClassValue.toLowerCase().contains(q) && - !matched.contains(cat.poiClassValue)) { - matched.insert(0, cat.poiClassValue); - } - } - - setState(() { - _showSuggestions = false; - _matchingPoiClasses = matched; - }); - } - }, - onTap: () { - // Close detail panel when user taps search bar - if (_selectedResult != null) { - setState(() { - _selectedResult = null; - }); - } - // Show suggestions if text is empty - if (_searchController.text.isEmpty) { - setState(() { - _showSuggestions = true; - _showResults = false; - }); - } - }, - style: TextStyle(fontSize: 16), - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 14, - ), - hintText: 'Search buildings, places...', - ), - ), - ), - if (_searchController.text.isNotEmpty) - IconButton( - icon: Icon(Icons.clear), - onPressed: _clearSearch, - ), - ], - ), - ), - - SizedBox(height: 4), - - // Suggestions panel (categories + recents) - if (_showSuggestions && !_showResults) - _buildSuggestionsPanel(context), - - // Search results dropdown - if (_showResults || (_matchingPoiClasses.isNotEmpty && _searchController.text.isNotEmpty && !_showSuggestions)) - Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: isDark ? Colors.grey[850] : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── Category matches (always at top) ────────────────────────── - if (_matchingPoiClasses.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), - child: Row( - children: [ - Icon(Icons.category_outlined, - size: 13, - color: isDark ? Colors.grey[500] : Colors.grey[500]), - const SizedBox(width: 6), - Text( - 'CATEGORIES', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: isDark ? Colors.grey[500] : Colors.grey[500], - ), - ), - ], - ), - ), - ..._matchingPoiClasses.map( - (classValue) => ListTile( - leading: Icon( - _iconForClass(classValue), - size: 20, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - title: Text(_labelForClass(classValue)), - subtitle: Text( - classValue, // show raw class value as subtitle so user knows what they're getting - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[500] : Colors.grey[500]), - ), - dense: true, - onTap: () => _performClassSearch(classValue), - ), - ), - if (_showResults && (_isSearching || _searchResults.isNotEmpty)) - const Divider(height: 1), - ], - - // ── POI / building results ───────────────────────────────────── - if (_showResults) - _isSearching - ? const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ) - : _searchResults.isEmpty - // Only show "no results" if there are also no category matches - ? (_matchingPoiClasses.isEmpty - ? const Padding( - padding: EdgeInsets.all(16), - child: Text('No results found'), - ) - : const SizedBox.shrink()) - : ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, - ), - child: ListView.separated( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: _searchResults.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final result = _searchResults[index]; - return ListTile( - splashColor: Colors.transparent, - leading: Icon(_iconForResult(result)), - title: Text(result.name, - maxLines: 1, - overflow: TextOverflow.ellipsis), - subtitle: result.subtitle.isNotEmpty - ? Text(result.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis) - : null, - dense: true, - onTap: () => _selectResult(result), - ); - }, - ), - ), - - ], - ), - ), - ], - ), - ), - - - // "See All" pill — shown when category results are filtered to viewport - if (_showSeeAll && _selectedResult == null) - Positioned( - bottom: 32, - left: 0, - right: 0, - child: Center( - child: _buildSeeAllButton(context), - ), - ), - // Bottom-right FAB cluster: list view button + info button - if (_selectedResult == null) - Positioned( - right: 16, - bottom: 32, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // List view button — only when a category search is active - if (_allCategoryResults.isNotEmpty) ...[ - FloatingActionButton.small( - heroTag: 'listBtn', - onPressed: () { - setState(() { - _showCategoryList = !_showCategoryList; - }); - }, - backgroundColor: _showCategoryList - ? Theme.of(context).colorScheme.primary - : (isDark ? Colors.grey[800] : null), - foregroundColor: _showCategoryList - ? Colors.white - : (isDark ? Colors.white : null), - child: Icon( - _showCategoryList ? Icons.map_outlined : Icons.list, - ), - ), - const SizedBox(height: 10), - ], - if (_lastSelectedResult != null) - FloatingActionButton.small( - heroTag: 'infoBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _reopenDetail, - child: const Icon(Icons.info_outline), - ), - if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) - const SizedBox(height: 10), - FloatingActionButton.small( - heroTag: 'locateBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _recenterOnUser, - child: const Icon(Icons.my_location), - ), - ], - ), - ), - - // Category list panel — shown when list button is toggled - if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) - _buildCategoryListPanel(context), - - // Detail slide-over - if (_selectedResult != null) - _buildDetailSlideOver(context, _selectedResult!), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_service.dart b/lib/ui/esrimap/esrimap_service.dart deleted file mode 100644 index 6696836ce..000000000 --- a/lib/ui/esrimap/esrimap_service.dart +++ /dev/null @@ -1,1815 +0,0 @@ -import 'dart:convert'; -import 'dart:math' as math; -import 'package:arcgis_maps/arcgis_maps.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; - -// ----------------------------------------------------------------------------- -// Model -// ----------------------------------------------------------------------------- - -/// Unified search result model for both Buildings and POIs. -class MapSearchResult { - final String name; - final String subtitle; - final double latitude; - final double longitude; - final MapSearchSource source; - final String address; - final String description; - final String? websiteUrl; - - const MapSearchResult({ - required this.name, - required this.subtitle, - required this.latitude, - required this.longitude, - required this.source, - this.address = '', - this.description = '', - this.websiteUrl, - }); - - /// Serialize to JSON for SharedPreferences storage. - Map toJson() => { - 'name': name, - 'subtitle': subtitle, - 'latitude': latitude, - 'longitude': longitude, - 'source': source.index, - 'address': address, - 'description': description, - 'websiteUrl': websiteUrl, - }; - - /// Deserialize from JSON. - factory MapSearchResult.fromJson(Map json) { - return MapSearchResult( - name: json['name'] as String? ?? '', - subtitle: json['subtitle'] as String? ?? '', - latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, - longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, - source: MapSearchSource.values[json['source'] as int? ?? 0], - address: json['address'] as String? ?? '', - description: json['description'] as String? ?? '', - websiteUrl: json['websiteUrl'] as String?, - ); - } -} - -enum MapSearchSource { building, poi } - -// ----------------------------------------------------------------------------- -// Category definitions -// ----------------------------------------------------------------------------- - -class _SearchCategory { - final String label; - final IconData icon; - final String poiClassValue; // maps to POI "Class" field value - - const _SearchCategory({ - required this.label, - required this.icon, - required this.poiClassValue, - }); -} - -const _categories = [ - _SearchCategory( - label: 'Parking', - icon: Icons.local_parking, - poiClassValue: 'Parking', - ), - _SearchCategory( - label: 'Dining', - icon: Icons.restaurant, - poiClassValue: 'Dining and Beverage', - ), - _SearchCategory( - label: 'Recreation', - icon: Icons.fitness_center, - poiClassValue: 'Athletic Facilities', - ), - _SearchCategory( - label: 'Transit', - icon: Icons.directions_bus, - poiClassValue: 'Transit', - ), -]; - -// ----------------------------------------------------------------------------- -// Slide-over header delegate -// ----------------------------------------------------------------------------- - -class _SlideOverHeaderDelegate extends SliverPersistentHeaderDelegate { - final Widget child; - final double height; - final Color backgroundColor; - - _SlideOverHeaderDelegate({ - required this.child, - required this.height, - required this.backgroundColor, - }); - - @override - double get minExtent => height; - @override - double get maxExtent => height; - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return Container( - height: height, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: shrinkOffset == 0 - ? const BorderRadius.vertical(top: Radius.circular(16)) - : null, - ), - child: child, - ); - } - - @override - bool shouldRebuild(covariant _SlideOverHeaderDelegate oldDelegate) { - return child != oldDelegate.child || - height != oldDelegate.height || - backgroundColor != oldDelegate.backgroundColor; - } -} - -// ----------------------------------------------------------------------------- -// Widget -// ----------------------------------------------------------------------------- - -class EsriMap extends StatefulWidget { - const EsriMap({Key? key}) : super(key: key); - - @override - State createState() => _EsriMapState(); -} - -class _EsriMapState extends State with AutomaticKeepAliveClientMixin { - final _mapViewController = ArcGISMapView.createController(); - late final ArcGISMap _map; - final _graphicsOverlay = GraphicsOverlay(); - final _searchController = TextEditingController(); - final _focusNode = FocusNode(); - - // Slide-over controllers - final _categorySheetController = DraggableScrollableController(); - final _detailSheetController = DraggableScrollableController(); - - // Service endpoints - static const _buildingsQueryUrl = - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/' - 'AdministrationServices/Buildings_Public/MapServer/0/query'; - static const _poiQueryUrl = - 'https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/' - 'Points_Of_Interest/FeatureServer/0/query'; - - // SharedPreferences key for recent searches - static const _recentSearchesKey = 'esri_map_recent_searches'; - static const _maxRecentSearches = 5; - - // Search state - List _searchResults = []; - List _allPoiClasses = []; // all distinct Class values from the server - List _matchingPoiClasses = []; // live-filtered subset while typing - bool _showResults = false; - bool _isSearching = false; - bool _showSuggestions = false; - - // Recent searches - List _recentSearches = []; - - // Detail slide-over state - MapSearchResult? _selectedResult; - MapSearchResult? _lastSelectedResult; - - // Results currently plotted as pins on the map (from category search) - List _mappedResults = []; - - // Full unfiltered result set for the active category (used by "See All") - List _allCategoryResults = []; - - // Whether to show the "See All" pill button - bool _showSeeAll = false; - - // Whether to show the full category list panel - bool _showCategoryList = false; - - // The active category (for display in the "See All" label) - _SearchCategory? _activeCategory; - - // Location display - final _locationDataSource = SystemLocationDataSource(); - bool _locationStarted = false; - - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - _initMap(); - _loadRecentSearches(); - _fetchAllPoiClasses(); - // Listen for focus changes to show/hide suggestions - _focusNode.addListener(_onFocusChanged); - } - - @override - void dispose() { - _categorySheetController.dispose(); - _detailSheetController.dispose(); - _searchController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void _initMap() { - final hillshadeLayer = ArcGISTiledLayer.withUri( - Uri.parse( - 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer', - ), - ); - - final campusVectorTileLayer = ArcGISVectorTiledLayer.withUri( - Uri.parse( - 'https://admin-enterprise-gis.ucsd.edu/server/rest/services/Hosted/CampusMapVector/VectorTileServer', - ), - ); - - final basemap = Basemap(); - basemap.baseLayers.add(hillshadeLayer); - basemap.baseLayers.add(campusVectorTileLayer); - - _map = ArcGISMap.withBasemap(basemap); - _map.initialViewpoint = Viewpoint.fromCenter( - ArcGISPoint( - x: -117.2340, - y: 32.8801, - spatialReference: SpatialReference.wgs84, - ), - scale: 24000, - ); - } - - void _onMapViewReady() { - _mapViewController.arcGISMap = _map; - _mapViewController.interactionOptions.rotateEnabled = false; - _mapViewController.graphicsOverlays.add(_graphicsOverlay); - - // Wire up location display — blue dot, no auto-pan on start - _mapViewController.locationDisplay.dataSource = _locationDataSource; - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.off; - - _startLocationDisplay(); - } - - Future _startLocationDisplay() async { - try { - await _locationDataSource.start(); - setState(() => _locationStarted = true); - } on ArcGISException catch (e) { - debugPrint('Location error: ${e.message}'); - } catch (e) { - debugPrint('Location error: $e'); - } - } - - void _onFocusChanged() { - if (_focusNode.hasFocus && _searchController.text.isEmpty) { - setState(() { - _showSuggestions = true; - _showResults = false; - }); - } - } - - // --------------------------------------------------------------------------- - // Recent searches persistence - // --------------------------------------------------------------------------- - - Future _loadRecentSearches() async { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = prefs.getString(_recentSearchesKey); - if (jsonStr != null) { - try { - final list = jsonDecode(jsonStr) as List; - setState(() { - _recentSearches = list - .map((e) => - MapSearchResult.fromJson(e as Map)) - .toList(); - }); - } catch (_) { - // ignore corrupted data - } - } - } - - Future _saveRecentSearches() async { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = - jsonEncode(_recentSearches.map((r) => r.toJson()).toList()); - await prefs.setString(_recentSearchesKey, jsonStr); - } - - void _addToRecentSearches(MapSearchResult result) { - // Remove duplicate if exists (match by name + source) - _recentSearches.removeWhere( - (r) => r.name == result.name && r.source == result.source); - // Insert at front - _recentSearches.insert(0, result); - // Trim to max - if (_recentSearches.length > _maxRecentSearches) { - _recentSearches = _recentSearches.sublist(0, _maxRecentSearches); - } - _saveRecentSearches(); - } - - void _removeFromRecentSearches(int index) { - setState(() { - _recentSearches.removeAt(index); - }); - _saveRecentSearches(); - } - - // --------------------------------------------------------------------------- - // Query helpers - // --------------------------------------------------------------------------- - - String _escSql(String input) => input.replaceAll("'", "''"); - - /// Query the Buildings (AGE) MapServer - Future> _queryBuildings(String query) async { - await dotenv.load(fileName: ".env"); - final token = dotenv.env['ARCGIS_AGE_API_KEY'] ?? ''; - final escaped = _escSql(query); - final where = "UPPER(FacilityLongName) LIKE UPPER('%$escaped%') " - "OR UPPER(BuildingAliases) LIKE UPPER('%$escaped%')"; - - final uri = Uri.parse(_buildingsQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'FacilityLongName,BuildingAliases,StreetAddress,City,Zipcode,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '8', - 'f': 'json', - if (token.isNotEmpty) 'token': token, - }, - ); - - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final name = - (attrs['FacilityLongName'] as String?) ?? 'Unknown Building'; - final alias = (attrs['BuildingAliases'] as String?) ?? ''; - final street = (attrs['StreetAddress'] as String?) ?? ''; - final city = (attrs['City'] as String?) ?? ''; - final zip = (attrs['Zipcode'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final addressParts = [ - if (street.isNotEmpty) street, - if (city.isNotEmpty) city, - if (zip.isNotEmpty) zip, - ]; - final fullAddress = addressParts.join(', '); - final subtitle = alias.isNotEmpty ? alias : 'Building'; - - return MapSearchResult( - name: name, - subtitle: subtitle, - latitude: lat, - longitude: lng, - source: MapSearchSource.building, - address: fullAddress, - ); - }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); - } - - /// Fetches all distinct POI Class values from the FeatureServer and caches them. - Future _fetchAllPoiClasses() async { - try { - final uri = Uri.parse(_poiQueryUrl).replace(queryParameters: { - 'where': '1=1', - 'outFields': 'Class', - 'returnDistinctValues': 'true', - 'orderByFields': 'Class', - 'returnGeometry': 'false', - 'resultRecordCount': '200', - 'f': 'json', - }); - final response = await http.get(uri); - if (response.statusCode != 200) return; - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - final classes = features - .map((f) => (f['attributes']['Class'] as String?) ?? '') - .where((c) => c.isNotEmpty) - .toList() - ..sort(); - setState(() => _allPoiClasses = classes); - print(_allPoiClasses); - } catch (e) { - debugPrint('Failed to fetch POI classes: $e'); - } - } - - /// Query the POIs (AGO) FeatureServer by text search. - Future> _queryPOIs(String query) async { - final escaped = _escSql(query); - final where = "UpdatedName LIKE '%$escaped%' " - "OR C3DName LIKE '%$escaped%' " - "OR C3DKeywords LIKE '%$escaped%'"; - return _executePOIQuery(where); - } - - /// Query POIs filtered by a specific Class value (for category taps). - Future> _queryPOIsByClass(String classValue) async { - final escaped = _escSql(classValue); - final where = "Class = '$escaped'"; - return _executePOIQuery(where, maxResults: 100); - } - - /// Shared POI query execution. - Future> _executePOIQuery( - String where, { - int maxResults = 8, - }) async { - final uri = Uri.parse(_poiQueryUrl).replace( - queryParameters: { - 'where': where, - 'outFields': - 'UpdatedName,C3DName,Class,Subclass,C3DDescription,URL,Latitude,Longitude', - 'returnGeometry': 'false', - 'resultRecordCount': '$maxResults', - 'f': 'json', - }, - ); - - final response = await http.get(uri); - if (response.statusCode != 200) return []; - - final json = jsonDecode(response.body); - final features = json['features'] as List? ?? []; - - return features.map((f) { - final attrs = f['attributes'] as Map; - final updatedName = (attrs['UpdatedName'] as String?) ?? ''; - final c3dName = (attrs['C3DName'] as String?) ?? ''; - final name = updatedName.isNotEmpty ? updatedName : c3dName; - final poiClass = (attrs['Class'] as String?) ?? ''; - final subclass = (attrs['Subclass'] as String?) ?? ''; - final description = (attrs['C3DDescription'] as String?) ?? ''; - final url = (attrs['URL'] as String?) ?? ''; - final lat = (attrs['Latitude'] as num?)?.toDouble() ?? 0.0; - final lng = (attrs['Longitude'] as num?)?.toDouble() ?? 0.0; - - final subtitle = - subclass.isNotEmpty ? '$poiClass - $subclass' : poiClass; - - return MapSearchResult( - name: name.isNotEmpty ? name : 'Unknown POI', - subtitle: subtitle, - latitude: lat, - longitude: lng, - source: MapSearchSource.poi, - description: description, - websiteUrl: url.isNotEmpty ? url : null, - ); - }).where((r) => r.latitude != 0.0 && r.longitude != 0.0).toList(); - } - - // --------------------------------------------------------------------------- - // Search actions - // --------------------------------------------------------------------------- - - /// Text search: query both buildings and POIs in parallel. - Future _performSearch(String query) async { - if (query.trim().isEmpty) return; - - // Recompute category matches on submit - final q = query.toLowerCase(); - final matched = _allPoiClasses - .where((c) => c.toLowerCase().contains(q)) - .toList(); - - for (final cat in _categories) { - if (cat.poiClassValue.toLowerCase().contains(q) && - !matched.contains(cat.poiClassValue)) { - matched.insert(0, cat.poiClassValue); - } - } - - setState(() { - _isSearching = true; - _showResults = true; - _showSuggestions = false; - _selectedResult = null; - _matchingPoiClasses = matched; - }); - - try { - final results = await Future.wait([ - _queryBuildings(query), - _queryPOIs(query), - ]); - final merged = [...results[0], ...results[1]]; - setState(() { - _searchResults = merged; - _isSearching = false; - }); - } catch (e) { - print('Search error: $e'); - setState(() { - _searchResults = []; - _isSearching = false; - }); - } - } - - /// Returns the current visible map extent projected to WGS84. - Envelope? _getViewportEnvelopeWGS84() { - final vp = _mapViewController.getCurrentViewpoint( - ViewpointType.boundingGeometry, - ); - if (vp == null) return null; - final geom = vp.targetGeometry; - if (geom == null) return null; - final projected = GeometryEngine.project( - geom, - outputSpatialReference: SpatialReference.wgs84, - ); - return projected as Envelope?; - } - - /// Filters a result list to only those within the current map viewport. - /// Returns the full list unchanged if the envelope can't be determined. - List _filterToViewport(List results) { - final env = _getViewportEnvelopeWGS84(); - if (env == null) return results; - return results.where((r) { - return r.latitude >= env.yMin && - r.latitude <= env.yMax && - r.longitude >= env.xMin && - r.longitude <= env.xMax; - }).toList(); - } - - /// distance in meters between two WGS84 points - double _distanceMeters( - double lat1, double lng1, double lat2, double lng2) { - const r = 6371000.0; - final dLat = (lat2 - lat1) * math.pi / 180; - final dLng = (lng2 - lng1) * math.pi / 180; - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(lat1 * math.pi / 180) * - math.cos(lat2 * math.pi / 180) * - math.sin(dLng / 2) * - math.sin(dLng / 2); - return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); - } - - /// Returns the user's current WGS84 lat/lng, or null if unavailable. - (double lat, double lng)? _getUserLatLng() { - final pos = _mapViewController.locationDisplay.location?.position; - if (pos == null) return null; - final wgs = GeometryEngine.project( - pos, - outputSpatialReference: SpatialReference.wgs84, - ) as ArcGISPoint?; - if (wgs == null) return null; - return (wgs.y, wgs.x); - } - - /// Plot a list of results as pins on the map. - void _plotResultsOnMap(List results) { - _graphicsOverlay.graphics.clear(); - _mappedResults = results; - for (int i = 0; i < results.length; i++) { - final result = results[i]; - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - final graphic = Graphic( - geometry: point, - symbol: SimpleMarkerSymbol( - style: SimpleMarkerSymbolStyle.circle, - color: Colors.red, - size: 14, - ), - ); - // Store the list index so we can retrieve the result on tap - graphic.attributes['resultIndex'] = i; - _graphicsOverlay.graphics.add(graphic); - } - } - - /// Zoom the map to fit all mapped results, with padding. - void _zoomToResults(List results) { - if (results.isEmpty) return; - - if (results.length == 1) { - final r = results.first; - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter( - ArcGISPoint( - x: r.longitude, - y: r.latitude, - spatialReference: SpatialReference.wgs84, - ), - scale: 5000, - ), - ); - return; - } - - double minLat = results.map((r) => r.latitude).reduce((a, b) => a < b ? a : b); - double maxLat = results.map((r) => r.latitude).reduce((a, b) => a > b ? a : b); - double minLng = results.map((r) => r.longitude).reduce((a, b) => a < b ? a : b); - double maxLng = results.map((r) => r.longitude).reduce((a, b) => a > b ? a : b); - - const padding = 0.005; - final envelope = Envelope.fromXY( - xMin: minLng - padding, - yMin: minLat - padding, - xMax: maxLng + padding, - yMax: maxLat + padding, - spatialReference: SpatialReference.wgs84, - ); - _mapViewController.setViewpointAnimated( - Viewpoint.fromTargetExtent(envelope), - ); - } - - /// Category search: query POIs filtered by Class. - /// Category search: query POIs filtered by Class and plot all as map pins. - Future _performCategorySearch(_SearchCategory category) async { - setState(() { - _isSearching = true; - _showResults = false; - _showSuggestions = false; - _selectedResult = null; - _showSeeAll = false; - _showCategoryList = false; - _activeCategory = category; - _searchController.text = category.label; - }); - _focusNode.unfocus(); - - try { - final allResults = await _queryPOIsByClass(category.poiClassValue); - - // Always plot ALL pins — no zoom, no viewport filtering. - // The user pans/zooms freely to discover them. - _plotResultsOnMap(allResults); - - setState(() { - _allCategoryResults = allResults; - // Show "Zoom to all" pill whenever there are results - _showSeeAll = allResults.isNotEmpty; - _isSearching = false; - }); - } catch (e) { - print('Category search error: $e'); - setState(() { - _mappedResults = []; - _allCategoryResults = []; - _showSeeAll = false; - _isSearching = false; - }); - } - } - - /// Triggers a category-style search from a raw POI class string. - Future _performClassSearch(String classValue) async { - final category = _SearchCategory( - label: _labelForClass(classValue), - icon: _iconForClass(classValue), - poiClassValue: classValue, - ); - await _performCategorySearch(category); - } - - /// Plots all results for the active category - void _seeAllCategoryResults() { - if (_allCategoryResults.isEmpty) return; - _zoomToResults(_allCategoryResults); - setState(() => _showSeeAll = false); - } - - void _selectResult(MapSearchResult result) { - _graphicsOverlay.graphics.clear(); - - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - - final graphic = Graphic( - geometry: point, - symbol: SimpleMarkerSymbol( - style: SimpleMarkerSymbolStyle.circle, - color: result.source == MapSearchSource.building - ? Colors.blue - : Colors.red, - size: 14, - ), - ); - _graphicsOverlay.graphics.add(graphic); - - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter(point, scale: 5000), - ); - - setState(() { - _showResults = false; - _showSuggestions = false; - _searchController.text = result.name; - _selectedResult = result; - _lastSelectedResult = result; - }); - _focusNode.unfocus(); - - // Save to recent searches - _addToRecentSearches(result); - } - - /// Called when the ArcGISMapView is tapped - /// Identifies any graphic at the tap location and shows its detail panel - Future _onMapTap(Offset screenPoint) async { - // Dismiss any open dropdowns first - if (_showSuggestions || _showResults) { - setState(() { - _showSuggestions = false; - _showResults = false; - }); - _focusNode.unfocus(); - return; - } - - // Only attempt identify when pins are on the map - if (_mappedResults.isEmpty) { - if (_selectedResult != null) _closeDetail(); - return; - } - - try { - final identifyResult = await _mapViewController.identifyGraphicsOverlay( - _graphicsOverlay, - screenPoint: screenPoint, - tolerance: 12.0, - maximumResults: 1, - ); - - if (identifyResult.graphics.isNotEmpty) { - final tappedGraphic = identifyResult.graphics.first; - final index = tappedGraphic.attributes['resultIndex'] as int?; - if (index != null && index >= 0 && index < _mappedResults.length) { - _selectResultFromPin(_mappedResults[index]); - } - } else { - // Tapped empty space — close detail if open - if (_selectedResult != null) _closeDetail(); - } - } catch (e) { - print('Identify error: $e'); - } - } - - /// Select a result from a map pin tap - /// keeps all existing category pins visible - void _selectResultFromPin(MapSearchResult result) { - final point = ArcGISPoint( - x: result.longitude, - y: result.latitude, - spatialReference: SpatialReference.wgs84, - ); - - _mapViewController.setViewpointAnimated( - Viewpoint.fromCenter(point, scale: 5000), - ); - - setState(() { - _selectedResult = result; - _lastSelectedResult = result; - _searchController.text = result.name; - _showResults = false; - _showSuggestions = false; - _showCategoryList = false; - }); - _focusNode.unfocus(); - _addToRecentSearches(result); - } - - void _closeDetail() { - setState(() { - _selectedResult = null; - // If a category search is active, reopen the list view - if (_allCategoryResults.isNotEmpty) { - _showCategoryList = true; - } - }); - } - - void _reopenDetail() { - if (_lastSelectedResult != null) { - setState(() { - _selectedResult = _lastSelectedResult; - }); - } - } - - void _recenterOnUser() { - if (!_locationStarted) { - _startLocationDisplay(); - return; - } - final loc = _mapViewController.locationDisplay.location; - if (loc == null) return; - - // Briefly snap to recenter mode, then release back to free pan - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.recenter; - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - _mapViewController.locationDisplay.autoPanMode = - LocationDisplayAutoPanMode.off; - } - }); - } - - void _clearSearch() { - _searchController.clear(); - _graphicsOverlay.graphics.clear(); - setState(() { - _searchResults = []; - _matchingPoiClasses = []; - _mappedResults = []; - _allCategoryResults = []; - _showResults = false; - _showSuggestions = false; - _showSeeAll = false; - _showCategoryList = false; - _activeCategory = null; - _selectedResult = null; - _lastSelectedResult = null; - }); - } - - Future _launchDirections(MapSearchResult result) async { - final uri = Uri.parse( - 'https://www.google.com/maps/dir/?api=1' - '&destination=${result.latitude},${result.longitude}', - ); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - Future _launchWebsite(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - - IconData _iconForResult(MapSearchResult result) { - return result.source == MapSearchSource.building - ? Icons.business - : Icons.place; - } - - /// Returns the icon for a POI class — uses the hardcoded category icon if one - /// exists, otherwise falls back to a generic place icon. - IconData _iconForClass(String classValue) { - return _categories - .firstWhere( - (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase(), - orElse: () => const _SearchCategory( - label: '', icon: Icons.place, poiClassValue: ''), - ) - .icon; - } - - /// Converts a raw class value like "Food and Beverage" into a display label. - /// Uses the hardcoded category label if available, otherwise returns as-is. - String _labelForClass(String classValue) { - final match = _categories.where( - (c) => c.poiClassValue.toLowerCase() == classValue.toLowerCase()); - return match.isNotEmpty ? match.first.label : classValue; - } - - // --------------------------------------------------------------------------- - // Suggestions panel (categories + recent searches) - // --------------------------------------------------------------------------- - - Widget _buildSuggestionsPanel(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bgColor = isDark ? Colors.grey[850] : Colors.white; - - return Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: bgColor, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category section header - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Suggested', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - SizedBox(height: 12), - - // Category icons row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: _categories - .map((cat) => _buildCategoryChip(context, cat)) - .toList(), - ), - - // Recent searches section - if (_recentSearches.isNotEmpty) ...[ - SizedBox(height: 16), - Divider(height: 1), - SizedBox(height: 8), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Recently viewed', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - SizedBox(height: 4), - ...List.generate(_recentSearches.length, (index) { - final recent = _recentSearches[index]; - return ListTile( - dense: true, - leading: Icon( - Icons.history, - size: 20, - color: isDark ? Colors.grey[500] : Colors.grey[400], - ), - title: Text( - recent.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: GestureDetector( - onTap: () => _removeFromRecentSearches(index), - child: Icon( - Icons.close, - size: 16, - color: isDark ? Colors.grey[500] : Colors.grey[400], - ), - ), - onTap: () => _selectResult(recent), - ); - }), - ], - ], - ), - ), - ); - } - - Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return GestureDetector( - onTap: () => _performCategorySearch(category), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: isDark ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - category.icon, - size: 24, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - ), - SizedBox(height: 6), - Text( - category.label, - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - ], - ), - ); - } - - Widget _buildSeeAllButton(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final total = _allCategoryResults.length; - - return GestureDetector( - onTap: _seeAllCategoryResults, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.layers_outlined, - size: 16, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - const SizedBox(width: 6), - Text( - 'See all $total results', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.grey[900], - ), - ), - ], - ), - ), - ); - } - - // --------------------------------------------------------------------------- - // Map interaction → collapse slideover - // --------------------------------------------------------------------------- - - void _onMapPointerDown(PointerDownEvent _) { - // Collapse whichever sheet is active to the minimum snap - if (_showCategoryList && _selectedResult == null) { - if (_categorySheetController.isAttached) { - _categorySheetController.animateTo( - 0.15, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - } - } - if (_selectedResult != null) { - if (_detailSheetController.isAttached) { - _detailSheetController.animateTo( - 0.15, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - } - } - } - - // --------------------------------------------------------------------------- - // Shared slide-over template - // --------------------------------------------------------------------------- - - /// Builds a consistent DraggableScrollableSheet with a pinned header. - /// [headerTitle] and [headerIcon] are shown in the header row. - /// [onClose] is called when the X button is tapped. - /// [trailing] is an optional widget shown before the close button (e.g. "Search here"). - /// [sliverBody] is the scrollable content below the header. - static const _slideOverHeaderHeight = 80.0; - - Widget _buildSlideOverContent({ - required BuildContext context, - required ScrollController scrollController, - required String headerTitle, - IconData? headerIcon, - required VoidCallback onClose, - Widget? trailing, - required List sliverBody, - }) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bgColor = isDark ? Colors.grey[900]! : Colors.white; - - return Material( - elevation: 8, - color: bgColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - clipBehavior: Clip.antiAlias, - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _SlideOverHeaderDelegate( - height: _slideOverHeaderHeight, - backgroundColor: bgColor, - child: Column( - children: [ - // Drag handle - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 4), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: isDark ? Colors.grey[700] : Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - // Header row - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 8), - child: Row( - children: [ - if (headerIcon != null) ...[ - Icon( - headerIcon, - size: 18, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - const SizedBox(width: 8), - ], - Expanded( - child: Text( - headerTitle, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.grey[900], - ), - ), - ), - if (trailing != null) trailing!, - GestureDetector( - onTap: onClose, - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - isDark ? Colors.grey[800] : Colors.grey[200], - ), - child: Icon( - Icons.close, - size: 18, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - // Divider below header - SliverToBoxAdapter( - child: Divider(height: 1), - ), - ...sliverBody, - ], - ), - ); - } - - // --------------------------------------------------------------------------- - // List view - // --------------------------------------------------------------------------- - Widget _buildCategoryListPanel(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final label = _activeCategory?.label ?? 'Results'; - - final viewport = _filterToViewport(_allCategoryResults); - final userPos = _getUserLatLng(); - final results = List.from(viewport); - if (userPos != null) { - results.sort((a, b) => - _distanceMeters(userPos.$1, userPos.$2, a.latitude, a.longitude) - .compareTo( - _distanceMeters( - userPos.$1, userPos.$2, b.latitude, b.longitude))); - } - - // Content-aware sizing: estimate content height as fraction of screen - final screenHeight = MediaQuery.of(context).size.height; - // Each list tile ~56px + header ~80px + divider - final contentHeight = - _slideOverHeaderHeight + (results.length * 56.0).clamp(56.0, 600.0); - final contentFraction = (contentHeight / screenHeight).clamp(0.15, 0.80); - final initialSize = - contentFraction < 0.45 ? contentFraction : 0.45; - final maxSize = contentFraction < 0.80 ? contentFraction.clamp(0.45, 0.80) : 0.80; - // Snap sizes must be strictly increasing and within [min, max] - final snaps = [0.15]; - if (initialSize > 0.15 + 0.01) snaps.add(initialSize); - - return DraggableScrollableSheet( - controller: _categorySheetController, - initialChildSize: initialSize, - minChildSize: 0.15, - maxChildSize: maxSize, - snap: true, - snapSizes: snaps, - builder: (context, scrollController) { - final headerTitle = results.isEmpty - ? '$label - None in view' - : '$label - ${results.length} in view'; - - return _buildSlideOverContent( - context: context, - scrollController: scrollController, - headerTitle: headerTitle, - headerIcon: _activeCategory?.icon ?? Icons.place, - onClose: () => setState(() => _showCategoryList = false), - trailing: TextButton.icon( - icon: Icon( - Icons.refresh, - size: 16, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - label: Text( - 'Search here', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - ), - style: TextButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - onPressed: () => setState(() {}), - ), - sliverBody: results.isEmpty - ? [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.location_off, - size: 32, - color: isDark - ? Colors.grey[600] - : Colors.grey[400]), - const SizedBox(height: 8), - Text( - 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: isDark - ? Colors.grey[500] - : Colors.grey[600], - ), - ), - ], - ), - ), - ), - ] - : [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final r = results[index]; - final dist = userPos != null - ? _distanceMeters(userPos.$1, userPos.$2, - r.latitude, r.longitude) - : null; - final distLabel = dist == null - ? null - : dist < 1000 - ? '${dist.round()} m away' - : '${(dist / 1000).toStringAsFixed(1)} km away'; - - return Column( - children: [ - ListTile( - splashColor: Colors.transparent, - leading: Icon( - _iconForResult(r), - size: 20, - color: isDark - ? Colors.grey[400] - : Colors.grey[600], - ), - title: Text( - r.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - subtitle: distLabel != null - ? Text(distLabel, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - )) - : (r.subtitle.isNotEmpty - ? Text(r.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - const TextStyle(fontSize: 12)) - : null), - dense: true, - onTap: () => _selectResultFromPin(r), - ), - if (index < results.length - 1) - const Divider(height: 1), - ], - ); - }, - childCount: results.length, - ), - ), - ], - ); - }, - ); - } - - // --------------------------------------------------------------------------- - // Detail slide-over - // --------------------------------------------------------------------------- - - Widget _buildDetailSlideOver(BuildContext context, MapSearchResult result) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - final detailText = result.source == MapSearchSource.building - ? result.address - : result.description; - final categoryLabel = result.source == MapSearchSource.building - ? 'Building' - : result.subtitle; - - // Content-aware sizing: estimate how tall the detail content is - final screenHeight = MediaQuery.of(context).size.height; - // header ~80 + name ~30 + detail ~20 + buttons ~48 + padding ~40 - double contentEst = _slideOverHeaderHeight + 30 + 48 + 40; - if (detailText.isNotEmpty) contentEst += 60; - final contentFraction = (contentEst / screenHeight).clamp(0.15, 0.80); - final initialSize = - contentFraction < 0.30 ? contentFraction : 0.30; - final maxSize = - contentFraction < 0.80 ? contentFraction.clamp(0.30, 0.80) : 0.80; - // Snap sizes must be strictly increasing and within [min, max] - final snaps = [0.15]; - if (initialSize > 0.15 + 0.01) snaps.add(initialSize); - - return DraggableScrollableSheet( - controller: _detailSheetController, - initialChildSize: initialSize, - minChildSize: 0.15, - maxChildSize: maxSize, - snap: true, - snapSizes: snaps, - builder: (context, scrollController) { - return _buildSlideOverContent( - context: context, - scrollController: scrollController, - headerTitle: result.name, - headerIcon: _iconForResult(result), - onClose: _closeDetail, - sliverBody: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category label - Text( - categoryLabel, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), - - // Detail text - if (detailText.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - detailText, - style: TextStyle( - fontSize: 14, - color: - isDark ? Colors.grey[400] : Colors.grey[600], - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 16), - - // Action buttons - Row( - children: [ - if (result.websiteUrl != null) ...[ - Expanded( - child: OutlinedButton.icon( - onPressed: () => - _launchWebsite(result.websiteUrl!), - icon: const Icon(Icons.language, size: 18), - label: const Text('View website'), - style: OutlinedButton.styleFrom( - foregroundColor: isDark - ? const Color(0xFFFFCD00) - : null, - side: isDark - ? const BorderSide( - color: Color(0xFFFFCD00)) - : null, - padding: const EdgeInsets.symmetric( - vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - const SizedBox(width: 12), - ], - Expanded( - child: FilledButton.icon( - onPressed: () => _launchDirections(result), - icon: const Icon(Icons.directions, size: 18), - label: const Text('Get Directions'), - style: FilledButton.styleFrom( - padding: - const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ], - ); - }, - ); - } - - // --------------------------------------------------------------------------- - // Build - // --------------------------------------------------------------------------- - - @override - Widget build(BuildContext context) { - super.build(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - body: Stack( - children: [ - // Map — Listener detects pointer-down to collapse slideovers - Column( - children: [ - Expanded( - child: Listener( - onPointerDown: _onMapPointerDown, - child: ArcGISMapView( - controllerProvider: () => _mapViewController, - onMapViewReady: _onMapViewReady, - onTap: _onMapTap, - ), - ), - ), - ], - ), - - // Floating search bar + dropdown - Positioned( - top: 8, - left: 12, - right: 12, - child: Column( - children: [ - // Search bar - Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: isDark ? Colors.grey[850] : Colors.white, - child: Row( - children: [ - Padding( - padding: EdgeInsets.only(left: 12), - child: Icon( - Icons.search, - color: isDark ? Colors.white70 : Colors.grey[600], - ), - ), - Expanded( - child: TextField( - controller: _searchController, - focusNode: _focusNode, - textInputAction: TextInputAction.search, - onSubmitted: _performSearch, - onChanged: (text) { - if (text.isEmpty) { - setState(() { - _showResults = false; - _searchResults = []; - _matchingPoiClasses = []; - _showSuggestions = _focusNode.hasFocus; - }); - } else { - final q = text.toLowerCase(); - // Match against server-fetched classes - final matched = _allPoiClasses - .where((c) => c.toLowerCase().contains(q)) - .toList(); - - // Always include hardcoded category class values as a fallback - // so chips appear even if the server fetch is still in flight - for (final cat in _categories) { - if (cat.poiClassValue.toLowerCase().contains(q) && - !matched.contains(cat.poiClassValue)) { - matched.insert(0, cat.poiClassValue); - } - } - - setState(() { - _showSuggestions = false; - _matchingPoiClasses = matched; - }); - } - }, - onTap: () { - // Close detail panel when user taps search bar - if (_selectedResult != null) { - setState(() { - _selectedResult = null; - }); - } - // Show suggestions if text is empty - if (_searchController.text.isEmpty) { - setState(() { - _showSuggestions = true; - _showResults = false; - }); - } - }, - style: TextStyle(fontSize: 16), - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 14, - ), - hintText: 'Search buildings, places...', - ), - ), - ), - if (_searchController.text.isNotEmpty) - IconButton( - icon: Icon(Icons.clear), - onPressed: _clearSearch, - ), - ], - ), - ), - - SizedBox(height: 4), - - // Suggestions panel (categories + recents) - if (_showSuggestions && !_showResults) - _buildSuggestionsPanel(context), - - // Search results dropdown - if (_showResults || (_matchingPoiClasses.isNotEmpty && _searchController.text.isNotEmpty && !_showSuggestions)) - Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - color: isDark ? Colors.grey[850] : Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── Category matches (always at top) ────────────────────────── - if (_matchingPoiClasses.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), - child: Row( - children: [ - Icon(Icons.category_outlined, - size: 13, - color: isDark ? Colors.grey[500] : Colors.grey[500]), - const SizedBox(width: 6), - Text( - 'CATEGORIES', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - color: isDark ? Colors.grey[500] : Colors.grey[500], - ), - ), - ], - ), - ), - ..._matchingPoiClasses.map( - (classValue) => ListTile( - leading: Icon( - _iconForClass(classValue), - size: 20, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - title: Text(_labelForClass(classValue)), - subtitle: Text( - classValue, // show raw class value as subtitle so user knows what they're getting - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.grey[500] : Colors.grey[500]), - ), - dense: true, - onTap: () => _performClassSearch(classValue), - ), - ), - if (_showResults && (_isSearching || _searchResults.isNotEmpty)) - const Divider(height: 1), - ], - - // ── POI / building results ───────────────────────────────────── - if (_showResults) - _isSearching - ? const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ) - : _searchResults.isEmpty - // Only show "no results" if there are also no category matches - ? (_matchingPoiClasses.isEmpty - ? const Padding( - padding: EdgeInsets.all(16), - child: Text('No results found'), - ) - : const SizedBox.shrink()) - : ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, - ), - child: ListView.separated( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: _searchResults.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final result = _searchResults[index]; - return ListTile( - splashColor: Colors.transparent, - leading: Icon(_iconForResult(result)), - title: Text(result.name, - maxLines: 1, - overflow: TextOverflow.ellipsis), - subtitle: result.subtitle.isNotEmpty - ? Text(result.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis) - : null, - dense: true, - onTap: () => _selectResult(result), - ); - }, - ), - ), - - ], - ), - ), - ], - ), - ), - - - // "See All" pill — shown when category results are filtered to viewport - if (_showSeeAll && _selectedResult == null) - Positioned( - bottom: 32, - left: 0, - right: 0, - child: Center( - child: _buildSeeAllButton(context), - ), - ), - // Bottom-right FAB cluster: list view button + info button - if (_selectedResult == null) - Positioned( - right: 16, - bottom: 32, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // List view button — only when a category search is active - if (_allCategoryResults.isNotEmpty) ...[ - FloatingActionButton.small( - heroTag: 'listBtn', - onPressed: () { - setState(() { - _showCategoryList = !_showCategoryList; - }); - }, - backgroundColor: _showCategoryList - ? Theme.of(context).colorScheme.primary - : (isDark ? Colors.grey[800] : null), - foregroundColor: _showCategoryList - ? Colors.white - : (isDark ? Colors.white : null), - child: Icon( - _showCategoryList ? Icons.map_outlined : Icons.list, - ), - ), - const SizedBox(height: 10), - ], - if (_lastSelectedResult != null) - FloatingActionButton.small( - heroTag: 'infoBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _reopenDetail, - child: const Icon(Icons.info_outline), - ), - if (_allCategoryResults.isNotEmpty || _lastSelectedResult != null) - const SizedBox(height: 10), - FloatingActionButton.small( - heroTag: 'locateBtn', - backgroundColor: isDark ? Colors.grey[800] : null, - foregroundColor: isDark ? Colors.white : null, - onPressed: _recenterOnUser, - child: const Icon(Icons.my_location), - ), - ], - ), - ), - - // Category list panel — shown when list button is toggled - if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) - _buildCategoryListPanel(context), - - // Detail slide-over - if (_selectedResult != null) - _buildDetailSlideOver(context, _selectedResult!), - ], - ), - ); - } -} \ No newline at end of file From d3e703a58d85bd25d18b43e3f8acf8bb8e270d29 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 18 May 2026 15:50:03 -0700 Subject: [PATCH 17/20] test esrimap ai search static demo --- lib/ui/esrimap/esrimap.dart | 78 ++++++ lib/ui/esrimap/esrimap_ai_search.dart | 317 ++++++++++++++++++++++ lib/ui/esrimap/esrimap_fab.dart | 11 + lib/ui/esrimap/temp_assets/tgpt-dark.svg | 14 + lib/ui/esrimap/temp_assets/tgpt-light.svg | 14 + 5 files changed, 434 insertions(+) create mode 100644 lib/ui/esrimap/esrimap_ai_search.dart create mode 100644 lib/ui/esrimap/temp_assets/tgpt-dark.svg create mode 100644 lib/ui/esrimap/temp_assets/tgpt-light.svg diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index c092cc825..d5058311a 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -12,6 +12,7 @@ import 'esrimap_fab.dart'; import 'esrimap_layers_panel.dart'; import 'esrimap_scene.dart'; import 'esrimap_config.dart'; +import 'esrimap_ai_search.dart'; // ----------------------------------------------------------------------------- // Model @@ -1411,6 +1412,72 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } } + void _onAiLocationSelected(AiSearchResult result) { + _searchController.text = result.name; + final point = ArcGISPoint( + x: result.longitude, + y: result.latitude, + spatialReference: SpatialReference.wgs84, + ); + _graphicsOverlay.graphics.clear(); + _graphicsOverlay.graphics.add(Graphic( + geometry: point, + symbol: SimpleMarkerSymbol( + style: SimpleMarkerSymbolStyle.circle, + color: Colors.red, + size: 14, + ), + )); + _mapViewController.setViewpointAnimated( + Viewpoint.fromCenter(point, scale: 5000), + ); + final mapResult = MapSearchResult( + name: result.name, + subtitle: result.subtitle, + latitude: result.latitude, + longitude: result.longitude, + source: MapSearchSource.poi, + address: result.address, + ); + setState(() { + _selectedResult = mapResult; + _lastSelectedResult = mapResult; + _showResults = false; + _showSuggestions = false; + }); + } + + void _onAiRouteRequested(List stops) { + if (stops.length < 2) return; + final first = stops.first; + final last = stops.last; + final destination = MapSearchResult( + name: last.name, + subtitle: last.subtitle, + latitude: last.latitude, + longitude: last.longitude, + source: MapSearchSource.poi, + address: last.address, + ); + _toController.text = destination.name; + _routeDestination = destination; + final gps = _getUserLatLng(); + _fromController.text = gps != null ? 'My Location' : first.name; + _fromLatLng = gps ?? (first.latitude, first.longitude); + _graphicsOverlay.graphics.clear(); + setState(() { + _showRouteFields = true; + _selectedResult = destination; + _lastSelectedResult = destination; + _mappedResults = []; + _allCategoryResults = []; + _showSeeAll = false; + _showCategoryList = false; + _activeCategory = null; + }); + _solveRoute(destination, originLatLng: _fromLatLng); + } + void _clearRoute() { _routeGraphicsOverlay.graphics.clear(); _fromController.clear(); @@ -2776,6 +2843,17 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { showRouteFields: _showRouteFields, hasRoute: _hasRoute, mapRotation: _mapRotation, + onShowAiSearch: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => EsriAiSearchSheet( + userLat: _getUserLatLng()?.$1, + userLon: _getUserLatLng()?.$2, + onLocationSelected: _onAiLocationSelected, + onRouteRequested: _onAiRouteRequested, + ), + ), onShowLayersPanel: () => setState(() => _showLayersPanel = true), onToggleCategoryList: () => setState(() => _showCategoryList = !_showCategoryList), onReopenDetail: _reopenDetail, diff --git a/lib/ui/esrimap/esrimap_ai_search.dart b/lib/ui/esrimap/esrimap_ai_search.dart new file mode 100644 index 000000000..0f40ec052 --- /dev/null +++ b/lib/ui/esrimap/esrimap_ai_search.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'esrimap_config.dart'; + +class AiSearchResult { + final String name; + final String subtitle; + final String address; + final double latitude; + final double longitude; + final int? distanceFeet; + final String? distanceFormatted; + + const AiSearchResult({ + required this.name, + required this.subtitle, + required this.address, + required this.latitude, + required this.longitude, + this.distanceFeet, + this.distanceFormatted, + }); + + factory AiSearchResult.fromJson(Map j) => AiSearchResult( + name: j['name'] as String, + subtitle: j['subtitle'] as String? ?? '', + address: j['address'] as String? ?? '', + latitude: (j['latitude'] as num).toDouble(), + longitude: (j['longitude'] as num).toDouble(), + distanceFeet: j['distanceFeet'] as int?, + distanceFormatted: j['distanceFormatted'] as String?, + ); +} + +class AiSearchRouteStop { + final double lat; + final double lon; + const AiSearchRouteStop({required this.lat, required this.lon}); + factory AiSearchRouteStop.fromJson(Map j) => + AiSearchRouteStop(lat: (j['lat'] as num).toDouble(), lon: (j['lon'] as num).toDouble()); +} + +class AiSearchResponse { + final String message; + final List results; + final List? routeStopNames; + final List? routeStopCoords; + + const AiSearchResponse({ + required this.message, + required this.results, + this.routeStopNames, + this.routeStopCoords, + }); + + factory AiSearchResponse.fromJson(Map j) { + List? stopNames; + List? stopCoords; + final route = j['suggestedRoute']; + if (route != null) { + stopNames = (route['stops'] as List).cast(); + stopCoords = (route['stopCoords'] as List) + .map((s) => AiSearchRouteStop.fromJson(s as Map)) + .toList(); + } + return AiSearchResponse( + message: j['message'] as String, + results: (j['results'] as List) + .map((r) => AiSearchResult.fromJson(r as Map)) + .toList(), + routeStopNames: stopNames, + routeStopCoords: stopCoords, + ); + } +} + +enum _AiSearchState { idle, loading, results, error } + +class EsriAiSearchSheet extends StatefulWidget { + final double? userLat; + final double? userLon; + final void Function(AiSearchResult result) onLocationSelected; + final void Function(List stops) onRouteRequested; + + const EsriAiSearchSheet({ + Key? key, + this.userLat, + this.userLon, + required this.onLocationSelected, + required this.onRouteRequested, + }) : super(key: key); + + @override + State createState() => _EsriAiSearchSheetState(); +} + +class _EsriAiSearchSheetState extends State { + final _controller = TextEditingController(); + _AiSearchState _state = _AiSearchState.idle; + AiSearchResponse? _response; + String? _errorMessage; + + static const _baseUrl = + 'https://appzxi70zi.execute-api.us-west-2.amazonaws.com/test/ArcGIS-Map'; + + Future _submit() async { + final query = _controller.text.trim(); + if (query.isEmpty) return; + setState(() { + _state = _AiSearchState.loading; + _response = null; + _errorMessage = null; + }); + try { + final body = {'query': query}; + if (widget.userLat != null) body['lat'] = widget.userLat; + if (widget.userLon != null) body['lon'] = widget.userLon; + + final res = await http.post( + Uri.parse('$_baseUrl/ai-search'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + if (res.statusCode != 200) throw Exception('Request failed: ${res.statusCode}'); + final data = jsonDecode(res.body) as Map; + setState(() { + _response = AiSearchResponse.fromJson(data); + _state = _AiSearchState.results; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _state = _AiSearchState.error; + }); + } + } + + IconData _iconForSubtitle(String subtitle) { + final s = subtitle.toLowerCase(); + if (s.contains('dining') || s.contains('food') || s.contains('coffee') || s.contains('cafe')) { + return Icons.restaurant; + } + if (s.contains('library') || s.contains('academic')) return Icons.menu_book; + if (s.contains('parking')) return Icons.local_parking; + if (s.contains('recreation') || s.contains('gym') || s.contains('fitness')) return Icons.fitness_center; + if (s.contains('transit') || s.contains('shuttle')) return Icons.directions_bus; + return Icons.place; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bgColor = isDark ? Colors.grey[900]! : Colors.white; + final textColor = isDark ? Colors.white : Colors.grey[900]!; + final subtitleColor = isDark ? Colors.grey[400]! : Colors.grey[600]!; + final accent = Theme.of(context).colorScheme.primary; + final bottomPad = MediaQuery.of(context).viewInsets.bottom; + + return Container( + decoration: BoxDecoration( + color: bgColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: EdgeInsets.only(bottom: bottomPad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + margin: const EdgeInsets.only(top: 10, bottom: 6), + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? Colors.grey[700] : Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Row( + children: [ + Icon(Icons.auto_awesome, size: 18, color: accent), + const SizedBox(width: 8), + Text('Ask the Map', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: textColor)), + ], + ), + ), + + // Search input + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _submit(), + style: TextStyle(color: textColor), + decoration: InputDecoration( + hintText: 'e.g. "good lunch spots near Price Center"', + hintStyle: TextStyle(color: subtitleColor, fontSize: 13), + filled: true, + fillColor: isDark ? Colors.grey[800] : Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _state == _AiSearchState.loading ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size(48, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _state == _AiSearchState.loading + ? const SizedBox(width: 18, height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.search), + ), + ], + ), + ), + + // Results + if (_state == _AiSearchState.results && _response != null) ...[ + if (_response!.message.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text(_response!.message, + style: TextStyle(fontSize: 13, color: subtitleColor)), + ), + + if (_response!.results.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text('No results found. Try a different query.', + style: TextStyle(fontSize: 13, color: subtitleColor)), + ), + + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView.separated( + shrinkWrap: true, + itemCount: _response!.results.length, + separatorBuilder: (_, __) => + Divider(height: 1, indent: 56, color: isDark ? Colors.grey[800] : Colors.grey[200]), + itemBuilder: (context, i) { + final r = _response!.results[i]; + return ListTile( + leading: CircleAvatar( + backgroundColor: accent.withOpacity(0.12), + child: Icon(_iconForSubtitle(r.subtitle), color: accent, size: 18), + ), + title: Text(r.name, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: textColor)), + subtitle: Text(r.distanceFormatted != null ? '${r.subtitle} · ${r.distanceFormatted}' : r.subtitle, + style: TextStyle(fontSize: 12, color: subtitleColor)), + onTap: () { + Navigator.pop(context); + widget.onLocationSelected(r); + }, + ); + }, + ), + ), + + // Multi-stop route button + if (_response!.routeStopNames != null) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.directions), + label: Text('Route through ${_response!.routeStopNames!.length} stops'), + onPressed: () { + Navigator.pop(context); + widget.onRouteRequested(_response!.results); + }, + ), + ), + ), + ] else + const SizedBox(height: 16), + ], + + if (_state == _AiSearchState.error) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text('Something went wrong. Please try again.', + style: TextStyle(fontSize: 13, color: Colors.redAccent)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart index 5a3f0c578..a9bd5376c 100644 --- a/lib/ui/esrimap/esrimap_fab.dart +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -10,6 +10,7 @@ class EsriMapFabCluster extends StatelessWidget { final bool hasRoute; final double mapRotation; final VoidCallback onShowLayersPanel; + final VoidCallback onShowAiSearch; final VoidCallback onToggleCategoryList; final VoidCallback onReopenDetail; final VoidCallback onClearRoute; @@ -27,6 +28,7 @@ class EsriMapFabCluster extends StatelessWidget { required this.hasRoute, required this.mapRotation, required this.onShowLayersPanel, + required this.onShowAiSearch, required this.onToggleCategoryList, required this.onReopenDetail, required this.onClearRoute, @@ -39,12 +41,21 @@ class EsriMapFabCluster extends StatelessWidget { Widget build(BuildContext context) { final bgColor = isDark ? Colors.grey[800]! : Colors.white; final fgColor = isDark ? Colors.white : Colors.grey[800]!; + final accent = Theme.of(context).colorScheme.primary; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ // Layers/display panel toggle + FloatingActionButton.small( + heroTag: 'aiSearchBtn', + backgroundColor: accent, + foregroundColor: Colors.white, + onPressed: onShowAiSearch, + child: const Icon(Icons.auto_awesome), + ), + const SizedBox(height: 10), FloatingActionButton.small( heroTag: 'layersBtn', backgroundColor: bgColor, diff --git a/lib/ui/esrimap/temp_assets/tgpt-dark.svg b/lib/ui/esrimap/temp_assets/tgpt-dark.svg new file mode 100644 index 000000000..59a576767 --- /dev/null +++ b/lib/ui/esrimap/temp_assets/tgpt-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/lib/ui/esrimap/temp_assets/tgpt-light.svg b/lib/ui/esrimap/temp_assets/tgpt-light.svg new file mode 100644 index 000000000..10d9304a8 --- /dev/null +++ b/lib/ui/esrimap/temp_assets/tgpt-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + From 7b3181f7dd7bf8713490e8e22232f1c7130740d4 Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Mon, 18 May 2026 17:20:18 -0700 Subject: [PATCH 18/20] 3D buildings map: geisel fix and fixed relevant fab --- lib/ui/esrimap/esrimap.dart | 25 ++++++++- lib/ui/esrimap/esrimap_fab.dart | 29 ++++++----- lib/ui/esrimap/esrimap_layers_panel.dart | 8 --- lib/ui/esrimap/esrimap_scene.dart | 65 ++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 25 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index d5058311a..4bf365940 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -265,6 +265,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { String _sceneMode = 'Default'; EsriSceneWidget? _scene3DWidget; EsriSceneWidget? _sceneDroneWidget; + final _scene3DKey = GlobalKey(); + final _sceneDroneKey = GlobalKey(); @override bool get wantKeepAlive => true; @@ -362,6 +364,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _snapToNorth() { + if (_sceneMode != 'Default') { + if (_sceneMode == '3D Building') { + _scene3DKey.currentState?.snapToNorth(); + } else { + _sceneDroneKey.currentState?.snapToNorth(); + } + return; + } _mapViewController.setViewpointRotation(angleDegrees: 0); } @@ -395,14 +405,18 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { if (mode == '3D Building' && _scene3DWidget == null && _config != null) { final scene = _config!.scenes['building3d']!; _scene3DWidget = EsriSceneWidget( + key: _scene3DKey, portalUri: scene.portalUrl, itemId: scene.itemId, + onHeadingChanged: (h) => setState(() => _mapRotation = h), ); } else if (mode == 'Drone View' && _sceneDroneWidget == null && _config != null) { final scene = _config!.scenes['droneView']!; _sceneDroneWidget = EsriSceneWidget( + key: _sceneDroneKey, portalUri: scene.portalUrl, itemId: scene.itemId, + onHeadingChanged: (h) => setState(() => _mapRotation = h), ); } }); @@ -1169,6 +1183,14 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _recenterOnView() { + if (_sceneMode != 'Default') { + if (_sceneMode == '3D Building') { + _scene3DKey.currentState?.resetCamera(); + } else { + _sceneDroneKey.currentState?.resetCamera(); + } + return; + } if (_selectedResult != null) { _mapViewController.setViewpointAnimated( Viewpoint.fromCenter( @@ -2835,8 +2857,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { Positioned( right: 16, bottom: 32, - child: EsriMapFabCluster( + child: EsriMapFabCluster( isDark: isDark, + is3D: _sceneMode != 'Default', allCategoryResultsCount: _allCategoryResults.length, showCategoryList: _showCategoryList, hasLastSelectedResult: _lastSelectedResult != null, diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart index a9bd5376c..ac3138be8 100644 --- a/lib/ui/esrimap/esrimap_fab.dart +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; class EsriMapFabCluster extends StatelessWidget { final bool isDark; + final bool is3D; final int allCategoryResultsCount; final bool showCategoryList; final bool hasLastSelectedResult; @@ -21,6 +22,7 @@ class EsriMapFabCluster extends StatelessWidget { const EsriMapFabCluster({ Key? key, required this.isDark, + this.is3D = false, required this.allCategoryResultsCount, required this.showCategoryList, required this.hasLastSelectedResult, @@ -47,15 +49,16 @@ class EsriMapFabCluster extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Layers/display panel toggle - FloatingActionButton.small( - heroTag: 'aiSearchBtn', - backgroundColor: accent, - foregroundColor: Colors.white, - onPressed: onShowAiSearch, - child: const Icon(Icons.auto_awesome), - ), - const SizedBox(height: 10), + if (!is3D) ...[ + FloatingActionButton.small( + heroTag: 'aiSearchBtn', + backgroundColor: accent, + foregroundColor: Colors.white, + onPressed: onShowAiSearch, + child: const Icon(Icons.auto_awesome), + ), + const SizedBox(height: 10), + ], FloatingActionButton.small( heroTag: 'layersBtn', backgroundColor: bgColor, @@ -64,8 +67,8 @@ class EsriMapFabCluster extends StatelessWidget { child: const Icon(Icons.layers_outlined), ), const SizedBox(height: 10), - // List view button — only when a category search is active - if (allCategoryResultsCount > 0) ...[ + // List view button — only when a category search is active and not in 3D + if (!is3D && allCategoryResultsCount > 0) ...[ FloatingActionButton.small( heroTag: 'listBtn', onPressed: onToggleCategoryList, @@ -75,7 +78,7 @@ class EsriMapFabCluster extends StatelessWidget { ), const SizedBox(height: 10), ], - if (hasLastSelectedResult) ...[ + if (!is3D && hasLastSelectedResult) ...[ FloatingActionButton.small( heroTag: 'infoBtn', backgroundColor: bgColor, @@ -85,7 +88,7 @@ class EsriMapFabCluster extends StatelessWidget { ), const SizedBox(height: 10), ], - if (showRouteFields || hasRoute) ...[ + if (!is3D && (showRouteFields || hasRoute)) ...[ FloatingActionButton.small( heroTag: 'clearRouteBtn', backgroundColor: Colors.redAccent, diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index b55505145..a710aa34d 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -283,14 +283,6 @@ class EsriMapLayersPanel extends StatelessWidget { ), ), const SizedBox(width: 8), - imageTile( - label: 'Assembly Areas', - selected: showAssemblyAreas, - loading: loadingAssemblyAreas, - onTap: onToggleAssemblyAreas, - imageWidget: - Container(color: const Color(0xFFD4EDDA)), - ), ], ), ), diff --git a/lib/ui/esrimap/esrimap_scene.dart b/lib/ui/esrimap/esrimap_scene.dart index a5f29634a..8fd902bb5 100644 --- a/lib/ui/esrimap/esrimap_scene.dart +++ b/lib/ui/esrimap/esrimap_scene.dart @@ -1,22 +1,27 @@ +import 'dart:async'; import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:flutter/material.dart'; class EsriSceneWidget extends StatefulWidget { final String portalUri; final String itemId; + final void Function(double heading)? onHeadingChanged; const EsriSceneWidget({ super.key, required this.portalUri, required this.itemId, + this.onHeadingChanged, }); @override - State createState() => _EsriSceneWidgetState(); + State createState() => EsriSceneWidgetState(); } -class _EsriSceneWidgetState extends State { +class EsriSceneWidgetState extends State { late final ArcGISSceneViewController _sceneViewController; + Camera? _initialCamera; + StreamSubscription? _viewpointSubscription; @override void initState() { @@ -24,7 +29,8 @@ class _EsriSceneWidgetState extends State { _sceneViewController = ArcGISSceneView.createController(); _sceneViewController.interactionOptions.rotateEnabled = true; _sceneViewController.interactionOptions.panEnabled = true; - // Reduce GPU overhead: skip atmosphere and star-field rendering + _sceneViewController.interactionOptions.flingEnabled = true; + _sceneViewController.interactionOptions.zoomFactor = 3.0; _sceneViewController.atmosphereEffect = AtmosphereEffect.none; _sceneViewController.spaceEffect = SpaceEffect.transparent; } @@ -37,6 +43,27 @@ class _EsriSceneWidgetState extends State { ); final scene = ArcGISScene.withItem(portalItem); _sceneViewController.arcGISScene = scene; + + _initialCamera = Camera.withLatLong( + latitude: 32.86872066, + longitude: -117.23732235, + altitude: 1062.871, + heading: 0, + pitch: 53.261, + roll: 0, + ); + _sceneViewController.setViewpointCamera(_initialCamera!); + + _viewpointSubscription = _sceneViewController.onViewpointChanged.listen((_) { + if (!mounted) return; + final vp = _sceneViewController.getCurrentViewpoint( + ViewpointType.centerAndScale, + ); + if (vp != null) { + widget.onHeadingChanged?.call(vp.rotation); + } + }); + _applyLabelScales(scene); } @@ -58,6 +85,38 @@ class _EsriSceneWidgetState extends State { } } + void resetCamera() { + if (_initialCamera == null) return; + _sceneViewController.setViewpointCamera(_initialCamera!); + } + + /// Snaps heading to north while preserving current position and pitch. + void snapToNorth() { + if (_initialCamera == null) return; + final vp = _sceneViewController.getCurrentViewpoint( + ViewpointType.centerAndScale, + ); + final pt = vp?.targetGeometry; + final lat = (pt is ArcGISPoint) ? pt.y : _initialCamera!.location.y; + final lon = (pt is ArcGISPoint) ? pt.x : _initialCamera!.location.x; + _sceneViewController.setViewpointCamera( + Camera.withLatLong( + latitude: lat, + longitude: lon, + altitude: _initialCamera!.location.z ?? 1062.871, + heading: 0, + pitch: _initialCamera!.pitch, + roll: 0, + ), + ); + } + + @override + void dispose() { + _viewpointSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return ArcGISSceneView( From 6881595b5e08f68982aa65838f098aae363e18df Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 20 May 2026 11:12:17 -0700 Subject: [PATCH 19/20] config from json, and routing behavior fix --- lib/ui/esrimap/esrimap.dart | 420 ++++++++++++----------- lib/ui/esrimap/esrimap_basemaps.dart | 83 +++-- lib/ui/esrimap/esrimap_config.dart | 272 +++++++++++---- lib/ui/esrimap/esrimap_fab.dart | 2 + lib/ui/esrimap/esrimap_layers_panel.dart | 301 +++++++--------- lib/ui/esrimap/mapConfig.json | 166 +++++++++ 6 files changed, 773 insertions(+), 471 deletions(-) create mode 100644 lib/ui/esrimap/mapConfig.json diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index 4bf365940..df0fd0231 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -85,28 +85,16 @@ class _SearchCategory { }); } -const _categories = [ - _SearchCategory( - label: 'Parking', - icon: Icons.local_parking, - poiClassValue: 'Parking', - ), - _SearchCategory( - label: 'Dining', - icon: Icons.restaurant, - poiClassValue: 'Dining and Beverage', - ), - _SearchCategory( - label: 'Recreation', - icon: Icons.fitness_center, - poiClassValue: 'Athletic Facilities', - ), - _SearchCategory( - label: 'Transit', - icon: Icons.directions_bus, - poiClassValue: 'Transit', - ), -]; +IconData _iconDataForName(String name) { + switch (name) { + case 'restaurant': return Icons.restaurant; + case 'menu_book': return Icons.menu_book; + case 'local_parking': return Icons.local_parking; + case 'fitness_center': return Icons.fitness_center; + case 'directions_bus': return Icons.directions_bus; + default: return Icons.place; + } +} // ----------------------------------------------------------------------------- // Slide-over header delegate @@ -240,20 +228,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final Map _basemaps = {}; bool _showLayersPanel = false; - // Operational layers - bool _showCampusDistricts = false; - ArcGISMapImageLayer? _campusDistrictsLayer; - bool _showConstruction = false; - ArcGISMapImageLayer? _constructionLayer; - bool _loadingConstruction = false; - bool _showAssemblyAreas = false; - ArcGISMapImageLayer? _assemblyAreasLayer; - bool _loadingAssemblyAreas = false; - bool _showTransitLayer = false; - FeatureLayer? _transitShuttlesLayer; - FeatureLayer? _transitRoutesLayer; - bool _loadingTransitLayer = false; - Timer? _transitRefreshTimer; + // Operational layers — keyed by config layer key + final Map _layerVisible = {}; + final Map _layerLoading = {}; + final Map> _layerInstances = {}; + final Map _layerTimers = {}; // Compass double _mapRotation = 0.0; @@ -261,8 +240,19 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final _mapReadyCompleter = Completer(); - // Scene mode: 'Default' | '3D Building' | 'Drone View' - String _sceneMode = 'Default'; + List<_SearchCategory> get _categories { + if (_config == null) return []; + return _config!.searchCategories + .map((c) => _SearchCategory( + label: c.label, + icon: _iconDataForName(c.icon), + poiClassValue: c.poiClass, + )) + .toList(); + } + + // Scene mode key — matches keys in config.scenes ('default' | 'building3d' | 'droneView') + String _sceneMode = 'default'; EsriSceneWidget? _scene3DWidget; EsriSceneWidget? _sceneDroneWidget; final _scene3DKey = GlobalKey(); @@ -302,7 +292,9 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _fromFocusNode.dispose(); _toFocusNode.dispose(); _focusNode.dispose(); - _transitRefreshTimer?.cancel(); + for (final timer in _layerTimers.values) { + timer.cancel(); + } _viewpointChangedSubscription?.cancel(); super.dispose(); } @@ -364,8 +356,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _snapToNorth() { - if (_sceneMode != 'Default') { - if (_sceneMode == '3D Building') { + if (_sceneMode != 'default') { + if (_sceneMode == 'building3d') { _scene3DKey.currentState?.snapToNorth(); } else { _sceneDroneKey.currentState?.snapToNorth(); @@ -397,158 +389,151 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { }); } - void _setSceneMode(String mode) { - if (mode == _sceneMode) return; + void _setSceneMode(String key) { + if (key == _sceneMode) return; setState(() { - _sceneMode = mode; - if (mode != 'Default') _showLayersPanel = false; - if (mode == '3D Building' && _scene3DWidget == null && _config != null) { - final scene = _config!.scenes['building3d']!; + _sceneMode = key; + if (key != 'default') _showLayersPanel = false; + if (_config == null) return; + final scene = _config!.scenes[key]; + if (scene == null || scene.type != 'scene') return; + final portalUrl = scene.portalKey != null + ? _config!.portals[scene.portalKey!] + : null; + if (portalUrl == null || scene.itemId == null) return; + if (key == 'building3d' && _scene3DWidget == null) { _scene3DWidget = EsriSceneWidget( key: _scene3DKey, - portalUri: scene.portalUrl, - itemId: scene.itemId, + portalUri: portalUrl, + itemId: scene.itemId!, onHeadingChanged: (h) => setState(() => _mapRotation = h), ); - } else if (mode == 'Drone View' && _sceneDroneWidget == null && _config != null) { - final scene = _config!.scenes['droneView']!; + } else if (key == 'droneView' && _sceneDroneWidget == null) { _sceneDroneWidget = EsriSceneWidget( key: _sceneDroneKey, - portalUri: scene.portalUrl, - itemId: scene.itemId, + portalUri: portalUrl, + itemId: scene.itemId!, onHeadingChanged: (h) => setState(() => _mapRotation = h), ); } }); } - void _toggleTransitLayer() async { - if (_showTransitLayer) { - _transitRefreshTimer?.cancel(); - _transitRefreshTimer = null; - if (_transitShuttlesLayer != null) { - _map.operationalLayers.remove(_transitShuttlesLayer!); - } - if (_transitRoutesLayer != null) { - _map.operationalLayers.remove(_transitRoutesLayer!); - } - setState(() => _showTransitLayer = false); - } else { - setState(() => _loadingTransitLayer = true); - if (_transitRoutesLayer == null) { - _transitRoutesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitRoutes)), - ); - } - if (_transitShuttlesLayer == null) { - _transitShuttlesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitShuttles)), - ); - } - _map.operationalLayers.add(_transitRoutesLayer!); - _map.operationalLayers.add(_transitShuttlesLayer!); - try { - await Future.wait([ - _transitRoutesLayer!.load(), - _transitShuttlesLayer!.load(), - ]); - } catch (e) { - debugPrint('Transit layer load error: $e'); +void _toggleLayer(String key) async { + final entry = _config?.layers[key]; + if (entry == null) return; + + if (_layerVisible[key] == true) { + _layerTimers[key]?.cancel(); + _layerTimers.remove(key); + for (final layer in _layerInstances[key] ?? []) { + if (layer != null) _map.operationalLayers.remove(layer); } - setState(() { - _showTransitLayer = true; - _loadingTransitLayer = false; - }); - _transitRefreshTimer = Timer.periodic( - const Duration(seconds: 15), - (_) async { - if (!mounted || _transitShuttlesLayer == null) return; - _map.operationalLayers.remove(_transitShuttlesLayer!); - _transitShuttlesLayer = FeatureLayer.withFeatureTable( - ServiceFeatureTable.withUri(Uri.parse(_config!.layers.transitShuttles)), - ); - _map.operationalLayers.add(_transitShuttlesLayer!); - try { - await _transitShuttlesLayer!.load(); - } catch (e) { - debugPrint('Transit shuttle refresh error: $e'); - } - }, - ); + _layerInstances.remove(key); + setState(() => _layerVisible[key] = false); + return; + } + + setState(() => _layerLoading[key] = true); + + final instances = _buildLayerInstances(entry); + _layerInstances[key] = instances; + + for (final layer in instances) { + if (layer != null) _map.operationalLayers.add(layer); + } + + try { + await Future.wait(instances.whereType().map((l) => l.load())); + _applyLayerSpecialCases(key, instances); + } catch (e) { + debugPrint('Layer $key load error: $e'); } + + _startLayerRefreshTimer(key, entry); + + setState(() { + _layerVisible[key] = true; + _layerLoading[key] = false; + }); } - void _toggleCampusDistricts() { - if (_showCampusDistricts) { - if (_campusDistrictsLayer != null) { - _map.operationalLayers.remove(_campusDistrictsLayer!); - } - setState(() => _showCampusDistricts = false); - } else { - if (_campusDistrictsLayer == null) { - _campusDistrictsLayer = ArcGISMapImageLayer.withUri( - Uri.parse(_config!.layers.campusDistricts), - ); - // Show only sublayer 4 (Campus Districts) - for (final sublayer in _campusDistrictsLayer!.mapImageSublayers) { - sublayer.isVisible = sublayer.id == 4; - } - } - _map.operationalLayers.add(_campusDistrictsLayer!); - setState(() => _showCampusDistricts = true); + List _buildLayerInstances(LayerEntry entry) { + if (entry.hasSublayers) { + return entry.sublayers!.map(_layerFromSublayer).toList(); } + return [_layerFromEntry(entry)]; } - void _toggleConstruction() async { - if (_showConstruction) { - if (_constructionLayer != null) { - _map.operationalLayers.remove(_constructionLayer!); - } - setState(() => _showConstruction = false); - } else { - setState(() => _loadingConstruction = true); - if (_constructionLayer == null) { - _constructionLayer = ArcGISMapImageLayer.withUri( - Uri.parse(_config!.layers.construction), - ); - } - _map.operationalLayers.add(_constructionLayer!); - try { - await _constructionLayer!.load(); - } catch (e) { - debugPrint('Construction layer load error: $e'); - } - setState(() { - _showConstruction = true; - _loadingConstruction = false; - }); + Layer? _layerFromSublayer(SublayerEntry sub) { + if (sub.source == 'url' && sub.url != null) { + return sub.url!.contains('FeatureServer') + ? FeatureLayer.withFeatureTable( + ServiceFeatureTable.withUri(Uri.parse(sub.url!))) + : ArcGISMapImageLayer.withUri(Uri.parse(sub.url!)); } + return null; } - void _toggleAssemblyAreas() async { - if (_showAssemblyAreas) { - if (_assemblyAreasLayer != null) { - _map.operationalLayers.remove(_assemblyAreasLayer!); - } - setState(() => _showAssemblyAreas = false); - } else { - setState(() => _loadingAssemblyAreas = true); - if (_assemblyAreasLayer == null) { - _assemblyAreasLayer = ArcGISMapImageLayer.withUri( - Uri.parse(_config!.layers.assemblyAreas), - ); + Layer? _layerFromEntry(LayerEntry entry) { + if (entry.source == 'url' && entry.url != null) { + return entry.url!.contains('FeatureServer') + ? FeatureLayer.withFeatureTable( + ServiceFeatureTable.withUri(Uri.parse(entry.url!))) + : ArcGISMapImageLayer.withUri(Uri.parse(entry.url!)); + } + return null; + } + + void _applyLayerSpecialCases(String key, List instances) { + // campusDistricts: show only sublayer id 4 + if (key == 'campusDistricts' && + instances.isNotEmpty && + instances.first is ArcGISMapImageLayer) { + final imageLayer = instances.first as ArcGISMapImageLayer; + for (final sub in imageLayer.mapImageSublayers) { + sub.isVisible = sub.id == 4; } - _map.operationalLayers.add(_assemblyAreasLayer!); - try { - await _assemblyAreasLayer!.load(); - } catch (e) { - debugPrint('Assembly areas layer load error: $e'); + } + } + + void _startLayerRefreshTimer(String key, LayerEntry entry) { + final subs = entry.sublayers; + if (subs == null) return; + + int? capturedIdx; + int? interval; + for (int i = 0; i < subs.length; i++) { + if (subs[i].refreshInterval > 0) { + capturedIdx = i; + interval = subs[i].refreshInterval; + break; } - setState(() { - _showAssemblyAreas = true; - _loadingAssemblyAreas = false; - }); } + if (capturedIdx == null || interval == null) return; + + final idx = capturedIdx; + final sub = subs[idx]; + + _layerTimers[key] = Timer.periodic(Duration(seconds: interval), (_) async { + if (!mounted) return; + final instances = _layerInstances[key]; + if (instances == null || idx >= instances.length) return; + + final oldLayer = instances[idx]; + if (oldLayer != null) _map.operationalLayers.remove(oldLayer); + + final newLayer = _layerFromSublayer(sub); + instances[idx] = newLayer; + if (newLayer != null) { + _map.operationalLayers.add(newLayer); + try { + await newLayer.load(); + } catch (e) { + debugPrint('Refresh error $key[$idx]: $e'); + } + } + }); } Future _startLocationDisplay() async { @@ -1183,8 +1168,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { } void _recenterOnView() { - if (_sceneMode != 'Default') { - if (_sceneMode == '3D Building') { + if (_sceneMode != 'default') { + if (_sceneMode == 'building3d') { _scene3DKey.currentState?.resetCamera(); } else { _sceneDroneKey.currentState?.resetCamera(); @@ -1424,6 +1409,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _showSeeAll = false; _showCategoryList = false; _activeCategory = null; + _selectedResult = destination; + _lastSelectedResult = destination; }); } catch (e) { debugPrint('Route solve error: $e'); @@ -1587,6 +1574,42 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // user location from from field in routing mode + if (_showRouteFields && _activeRouteField == 'from') ...[ + ListTile( + splashColor: Colors.transparent, + dense: true, + leading: Icon( + Icons.my_location, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + title: Text( + 'Current Location', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.primary, + ), + ), + onTap: () { + final gps = _getUserLatLng(); + if (gps == null) return; + _fromController.text = 'My Location'; + _fromLatLng = gps; + setState(() { + _showSuggestions = false; + _showResults = false; + }); + _fromFocusNode.unfocus(); + if (_routeDestination != null) { + _solveRoute(_routeDestination!, originLatLng: _fromLatLng); + } + }, + ), + Divider(height: 1), + SizedBox(height: 8), + ], // Category section header Padding( padding: EdgeInsets.symmetric(horizontal: 16), @@ -1666,16 +1689,16 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { final isDark = Theme.of(context).brightness == Brightness.dark; final colors = { - 'Parking': Color(0xFFCCF4F7), - 'Dining and Beverage': Color(0xFFF9DDEF), - 'Athletic Facilities': Color(0xFFFCF9CC), - 'Transit': Color(0xFFFFEDD1), + 'Parking': const Color(0xFFCCF4F7), + 'Dining': const Color(0xFFF9DDEF), + 'Recreation': const Color(0xFFFCF9CC), + 'Library': const Color(0xFFE8F4FD), }; final iconColors = { - 'Parking': Colors.black, - 'Dining and Beverage': Colors.black, - 'Athletic Facilities': Colors.black, - 'Transit': Colors.black, + 'Parking': Colors.black, + 'Dining': Colors.black, + 'Recreation': Colors.black, + 'Library': Colors.black, }; return GestureDetector( @@ -2315,6 +2338,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _graphicsOverlay.graphics.clear(); setState(() { _showRouteFields = true; + _selectedResult = null; _mappedResults = []; _allCategoryResults = []; _showSeeAll = false; @@ -2418,8 +2442,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { children: [ Expanded( child: IndexedStack( - index: _sceneMode == '3D Building' ? 1 - : _sceneMode == 'Drone View' ? 2 + index: _sceneMode == 'building3d' ? 1 + : _sceneMode == 'droneView' ? 2 : 0, children: [ Listener( @@ -2439,7 +2463,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ), // Floating search bar + dropdown — hidden in 3D/Drone View modes - if (_sceneMode == 'Default') + if (_sceneMode == 'default') Positioned( top: 8, left: 12, @@ -2501,8 +2525,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { isDense: true, ), onTap: () { - setState(() => - _activeRouteField = 'from'); + setState(() { + _activeRouteField = 'from'; + _showSuggestions = true; + _showResults = false; + }); }, onChanged: (text) { _fromLatLng = null; @@ -2561,8 +2588,11 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { isDense: true, ), onTap: () { - setState(() => - _activeRouteField = 'to'); + setState(() { + _activeRouteField = 'to'; + _showSuggestions = true; + _showResults = false; + }); }, onChanged: (text) { if (text.length >= 3) { @@ -2859,7 +2889,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { bottom: 32, child: EsriMapFabCluster( isDark: isDark, - is3D: _sceneMode != 'Default', + is3D: _sceneMode != 'default', + showAiSearch: _config?.features.aiSearch ?? false, allCategoryResultsCount: _allCategoryResults.length, showCategoryList: _showCategoryList, hasLastSelectedResult: _lastSelectedResult != null, @@ -2892,23 +2923,16 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _buildCategoryListPanel(context), // Layers/display panel - if (_showLayersPanel) + if (_showLayersPanel && _config != null) EsriMapLayersPanel( + config: _config!, currentBasemapType: _currentBasemapType, - sceneMode: _sceneMode, - showTransitLayer: _showTransitLayer, - loadingTransitLayer: _loadingTransitLayer, - showCampusDistricts: _showCampusDistricts, - showConstruction: _showConstruction, - loadingConstruction: _loadingConstruction, - showAssemblyAreas: _showAssemblyAreas, - loadingAssemblyAreas: _loadingAssemblyAreas, + currentSceneKey: _sceneMode, + layerVisible: _layerVisible, + layerLoading: _layerLoading, onSwitchBasemap: _switchBasemap, onSetSceneMode: _setSceneMode, - onToggleTransitLayer: _toggleTransitLayer, - onToggleCampusDistricts: _toggleCampusDistricts, - onToggleConstruction: _toggleConstruction, - onToggleAssemblyAreas: _toggleAssemblyAreas, + onToggleLayer: _toggleLayer, onClose: () => setState(() => _showLayersPanel = false), ), diff --git a/lib/ui/esrimap/esrimap_basemaps.dart b/lib/ui/esrimap/esrimap_basemaps.dart index 6a872b132..6c373c6ad 100644 --- a/lib/ui/esrimap/esrimap_basemaps.dart +++ b/lib/ui/esrimap/esrimap_basemaps.dart @@ -3,15 +3,7 @@ import 'esrimap_config.dart'; enum BasemapType { defaultMap, light, dark, satellite } -// Label lookup -- used by the layers panel -const basemapLabels = { - BasemapType.defaultMap: 'Default', - BasemapType.light: 'Light', - BasemapType.dark: 'Dark', - BasemapType.satellite: 'Satellite', -}; - -String _typeKey(BasemapType type) { +String basemapKey(BasemapType type) { switch (type) { case BasemapType.defaultMap: return 'defaultMap'; case BasemapType.light: return 'light'; @@ -20,36 +12,57 @@ String _typeKey(BasemapType type) { } } +BasemapType? basemapTypeFromKey(String key) { + switch (key) { + case 'defaultMap': return BasemapType.defaultMap; + case 'light': return BasemapType.light; + case 'dark': return BasemapType.dark; + case 'satellite': return BasemapType.satellite; + default: return null; + } +} + Basemap buildBasemap(BasemapType type, EsriMapConfig config) { + final entry = config.basemaps[basemapKey(type)]; + if (entry == null) return Basemap(); + final basemap = Basemap(); - final agePortal = Portal(Uri.parse(config.agePortalUrl)); + for (final layer in entry.baseLayers) { + switch (layer.type) { + case 'arcgisTiled': + final url = layer.serviceKey != null + ? config.serviceUrls[layer.serviceKey!] + : null; + if (url != null) { + basemap.baseLayers.add(ArcGISTiledLayer.withUri(Uri.parse(url))); + } + break; + + case 'arcgisVectorTiled': + final portalUrl = layer.portalKey != null + ? config.portals[layer.portalKey!] + : null; + if (portalUrl != null && layer.itemId != null) { + basemap.baseLayers.add( + ArcGISVectorTiledLayer.withItem( + PortalItem.withPortalAndItemId( + portal: Portal(Uri.parse(portalUrl)), + itemId: layer.itemId!, + ), + ), + ); + } + break; - if (type == BasemapType.satellite) { - basemap.baseLayers.add( - ArcGISTiledLayer.withUri(Uri.parse(config.esriTileUrls.lightGrayBase)), - ); - basemap.baseLayers.add( - ArcGISMapImageLayer.withUri(Uri.parse(config.nearmapUrl)), - ); - } else { - basemap.baseLayers.add( - ArcGISTiledLayer.withUri(Uri.parse(config.esriTileUrls.hillshade)), - ); - final contextUrl = type == BasemapType.dark - ? config.esriTileUrls.darkGrayBase - : config.esriTileUrls.lightGrayBase; - basemap.baseLayers.add( - ArcGISTiledLayer.withUri(Uri.parse(contextUrl)), - ); - final itemId = config.basemaps[_typeKey(type)]?.itemId; - if (itemId != null) { - basemap.baseLayers.add( - ArcGISVectorTiledLayer.withItem( - PortalItem.withPortalAndItemId(portal: agePortal, itemId: itemId), - ), - ); + case 'arcgisMapImage': + final url = layer.serviceKey != null + ? config.serviceUrls[layer.serviceKey!] + : null; + if (url != null) { + basemap.baseLayers.add(ArcGISMapImageLayer.withUri(Uri.parse(url))); + } + break; } } - return basemap; } \ No newline at end of file diff --git a/lib/ui/esrimap/esrimap_config.dart b/lib/ui/esrimap/esrimap_config.dart index 6598a5359..55a9b5bea 100644 --- a/lib/ui/esrimap/esrimap_config.dart +++ b/lib/ui/esrimap/esrimap_config.dart @@ -1,98 +1,233 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -// Models -class EsriMapConfig { - final String agePortalUrl; - final String agoPortalUrl; - final String nearmapUrl; - final EsriTileUrls esriTileUrls; - final Map basemaps; - final LayerUrls layers; - final Map scenes; +// Base layer spec - const EsriMapConfig({ - required this.agePortalUrl, - required this.agoPortalUrl, - required this.nearmapUrl, - required this.esriTileUrls, - required this.basemaps, - required this.layers, - required this.scenes, +class BaseLayerSpec { + final String type; // 'arcgisTiled' | 'arcgisVectorTiled' | 'arcgisMapImage' + final String? serviceKey; // resolves via EsriMapConfig.serviceUrls + final String? portalKey; // resolves via EsriMapConfig.portals + final String? itemId; + + const BaseLayerSpec({ + required this.type, + this.serviceKey, + this.portalKey, + this.itemId, }); - factory EsriMapConfig.fromJson(Map json) { - return EsriMapConfig( - agePortalUrl: json['agePortalUrl'] as String, - agoPortalUrl: json['agoPortalUrl'] as String, - nearmapUrl: json['nearmapUrl'] as String, - esriTileUrls: EsriTileUrls.fromJson(json['esriTileUrls'] as Map), - basemaps: (json['basemaps'] as Map).map( - (k, v) => MapEntry(k, BasemapConfig.fromJson(v as Map)), - ), - layers: LayerUrls.fromJson(json['layers'] as Map), - scenes: (json['scenes'] as Map).map( - (k, v) => MapEntry(k, SceneConfig.fromJson(v as Map)), - ), - ); - } + factory BaseLayerSpec.fromJson(Map json) => BaseLayerSpec( + type: json['type'] as String, + serviceKey: json['serviceKey'] as String?, + portalKey: json['portalKey'] as String?, + itemId: json['itemId'] as String?, + ); } +// Basemaps + class BasemapConfig { final String label; - final String itemId; - const BasemapConfig({required this.label, required this.itemId}); + final String thumbnailAsset; + final List baseLayers; + + const BasemapConfig({ + required this.label, + required this.thumbnailAsset, + required this.baseLayers, + }); + factory BasemapConfig.fromJson(Map json) => BasemapConfig( - label: json['label'] as String, - itemId: json['itemId'] as String, + label: json['label'] as String, + thumbnailAsset: json['thumbnailAsset'] as String, + baseLayers: (json['baseLayers'] as List) + .map((e) => BaseLayerSpec.fromJson(e as Map)) + .toList(), ); } -class EsriTileUrls { - final String hillshade; - final String lightGrayBase; - final String darkGrayBase; - const EsriTileUrls({required this.hillshade, required this.lightGrayBase, required this.darkGrayBase}); - factory EsriTileUrls.fromJson(Map json) => EsriTileUrls( - hillshade: json['hillshade'] as String, - lightGrayBase: json['lightGrayBase'] as String, - darkGrayBase: json['darkGrayBase'] as String, +// Operational layers + +class SublayerEntry { + final String name; + final String source; + final String? url; + final String? portalKey; + final String? itemId; + final int refreshInterval; + final bool popup; + final String? popupTemplate; + + const SublayerEntry({ + required this.name, + required this.source, + this.url, + this.portalKey, + this.itemId, + required this.refreshInterval, + required this.popup, + this.popupTemplate, + }); + + factory SublayerEntry.fromJson(Map json) => SublayerEntry( + name: json['name'] as String, + source: json['source'] as String, + url: json['url'] as String?, + portalKey: json['portalKey'] as String?, + itemId: json['itemId'] as String?, + refreshInterval: (json['refreshInterval'] as num?)?.toInt() ?? 0, + popup: json['popup'] as bool? ?? false, + popupTemplate: json['popupTemplate'] as String?, ); } -class LayerUrls { - final String transitRoutes; - final String transitShuttles; - final String campusDistricts; - final String construction; - final String assemblyAreas; - const LayerUrls({ - required this.transitRoutes, - required this.transitShuttles, - required this.campusDistricts, - required this.construction, - required this.assemblyAreas, +class LayerEntry { + final String label; + final String thumbnailAsset; + final String? source; + final String? url; + final String? portalKey; + final String? itemId; + final int refreshInterval; + final bool popup; + final String? popupTemplate; + final List? sublayers; + + const LayerEntry({ + required this.label, + required this.thumbnailAsset, + this.source, + this.url, + this.portalKey, + this.itemId, + this.refreshInterval = 0, + this.popup = false, + this.popupTemplate, + this.sublayers, }); - factory LayerUrls.fromJson(Map json) => LayerUrls( - transitRoutes: json['transitRoutes'] as String, - transitShuttles: json['transitShuttles'] as String, - campusDistricts: json['campusDistricts'] as String, - construction: json['construction'] as String, - assemblyAreas: json['assemblyAreas'] as String, + + bool get hasSublayers => sublayers != null && sublayers!.isNotEmpty; + + factory LayerEntry.fromJson(Map json) => LayerEntry( + label: json['label'] as String, + thumbnailAsset: json['thumbnailAsset'] as String, + source: json['source'] as String?, + url: json['url'] as String?, + portalKey: json['portalKey'] as String?, + itemId: json['itemId'] as String?, + refreshInterval: (json['refreshInterval'] as num?)?.toInt() ?? 0, + popup: json['popup'] as bool? ?? false, + popupTemplate: json['popupTemplate'] as String?, + sublayers: json['sublayers'] == null + ? null + : (json['sublayers'] as List) + .map((e) => SublayerEntry.fromJson(e as Map)) + .toList(), ); } +// Scenes + class SceneConfig { - final String portalUrl; - final String itemId; - const SceneConfig({required this.portalUrl, required this.itemId}); + final String label; + final String type; // '2d' | 'scene' + final String? portalKey; + final String? itemId; + + const SceneConfig({ + required this.label, + required this.type, + this.portalKey, + this.itemId, + }); + factory SceneConfig.fromJson(Map json) => SceneConfig( - portalUrl: json['portalUrl'] as String, - itemId: json['itemId'] as String, + label: json['label'] as String, + type: json['type'] as String, + portalKey: json['portalKey'] as String?, + itemId: json['itemId'] as String?, + ); +} + +// Features +// Allows remote enabling/disabling of app features without redeploying the app. + +class FeaturesConfig { + final bool aiSearch; + final bool scenes; + + const FeaturesConfig({required this.aiSearch, required this.scenes}); + + factory FeaturesConfig.fromJson(Map json) => FeaturesConfig( + aiSearch: json['aiSearch'] as bool? ?? false, + scenes: json['scenes'] as bool? ?? false, + ); +} + +// Search categories + +class SearchCategoryConfig { + final String label; + final String poiClass; + final String icon; + + const SearchCategoryConfig({ + required this.label, + required this.poiClass, + required this.icon, + }); + + factory SearchCategoryConfig.fromJson(Map json) => + SearchCategoryConfig( + label: json['label'] as String, + poiClass: json['poiClass'] as String, + icon: json['icon'] as String, + ); +} + +// Root config + +class EsriMapConfig { + final Map portals; + final Map serviceUrls; + final Map basemaps; + final Map layers; + final Map scenes; + final FeaturesConfig features; + final List searchCategories; + + const EsriMapConfig({ + required this.portals, + required this.serviceUrls, + required this.basemaps, + required this.layers, + required this.scenes, + required this.features, + required this.searchCategories, + }); + + factory EsriMapConfig.fromJson(Map json) => EsriMapConfig( + portals: (json['portals'] as Map).cast(), + serviceUrls: (json['serviceUrls'] as Map).cast(), + basemaps: (json['basemaps'] as Map).map( + (k, v) => MapEntry(k, BasemapConfig.fromJson(v as Map)), + ), + layers: (json['layers'] as Map).map( + (k, v) => MapEntry(k, LayerEntry.fromJson(v as Map)), + ), + scenes: (json['scenes'] as Map).map( + (k, v) => MapEntry(k, SceneConfig.fromJson(v as Map)), + ), + features: FeaturesConfig.fromJson(json['features'] as Map), + searchCategories: (json['searchCategories'] as List) + .map((e) => SearchCategoryConfig.fromJson(e as Map)) + .toList(), ); } // Service +// Fetches and caches the EsriMapConfig from the backend API. + class EsriMapConfigService { static final EsriMapConfigService instance = EsriMapConfigService._(); EsriMapConfigService._(); @@ -103,7 +238,6 @@ class EsriMapConfigService { EsriMapConfig? _config; Future? _pending; - /// Fetches config once and caches it for the session. Future fetch() => _pending ??= _doFetch(); EsriMapConfig? get cached => _config; diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart index ac3138be8..7cfed46cf 100644 --- a/lib/ui/esrimap/esrimap_fab.dart +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; class EsriMapFabCluster extends StatelessWidget { final bool isDark; final bool is3D; + final bool showAiSearch; final int allCategoryResultsCount; final bool showCategoryList; final bool hasLastSelectedResult; @@ -23,6 +24,7 @@ class EsriMapFabCluster extends StatelessWidget { Key? key, required this.isDark, this.is3D = false, + this.showAiSearch = true, required this.allCategoryResultsCount, required this.showCategoryList, required this.hasLastSelectedResult, diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index a710aa34d..a12e20f2b 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -1,42 +1,28 @@ import 'package:flutter/material.dart'; - import 'esrimap_basemaps.dart'; +import 'esrimap_config.dart'; class EsriMapLayersPanel extends StatelessWidget { + final EsriMapConfig config; final BasemapType currentBasemapType; - final String sceneMode; - final bool showTransitLayer; - final bool loadingTransitLayer; - final bool showCampusDistricts; - final bool showConstruction; - final bool loadingConstruction; - final bool showAssemblyAreas; - final bool loadingAssemblyAreas; + final String currentSceneKey; + final Map layerVisible; + final Map layerLoading; final void Function(BasemapType) onSwitchBasemap; final void Function(String) onSetSceneMode; - final VoidCallback onToggleTransitLayer; - final VoidCallback onToggleCampusDistricts; - final VoidCallback onToggleConstruction; - final VoidCallback onToggleAssemblyAreas; + final void Function(String) onToggleLayer; final VoidCallback onClose; const EsriMapLayersPanel({ Key? key, + required this.config, required this.currentBasemapType, - required this.sceneMode, - required this.showTransitLayer, - required this.loadingTransitLayer, - required this.showCampusDistricts, - required this.showConstruction, - required this.loadingConstruction, - required this.showAssemblyAreas, - required this.loadingAssemblyAreas, + required this.currentSceneKey, + required this.layerVisible, + required this.layerLoading, required this.onSwitchBasemap, required this.onSetSceneMode, - required this.onToggleTransitLayer, - required this.onToggleCampusDistricts, - required this.onToggleConstruction, - required this.onToggleAssemblyAreas, + required this.onToggleLayer, required this.onClose, }) : super(key: key); @@ -85,16 +71,14 @@ class EsriMapLayersPanel extends StatelessWidget { border: selected ? Border.all(color: accent, width: 2.5) : Border.all( - color: isDark - ? Colors.grey[700]! - : Colors.grey[300]!, + color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 1, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(6.5), child: loading - ? Center( + ? const Center( child: SizedBox( width: 22, height: 22, @@ -103,9 +87,7 @@ class EsriMapLayersPanel extends StatelessWidget { ) : (imageWidget ?? Container( - color: isDark - ? Colors.grey[700] - : Colors.grey[300], + color: isDark ? Colors.grey[700] : Colors.grey[300], )), ), ), @@ -117,8 +99,7 @@ class EsriMapLayersPanel extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, - fontWeight: - selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, color: selected ? accent : textColor, ), ), @@ -128,22 +109,25 @@ class EsriMapLayersPanel extends StatelessWidget { ); } - Widget sceneChip(String label, bool selected) => GestureDetector( - onTap: () { - onSetSceneMode(label); - // Keep panel open so the user sees the active chip update - }, + Widget networkImage(String url) => Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: isDark ? Colors.grey[700] : Colors.grey[300], + ), + ); + + Widget sceneChip(String key, String label, bool selected) => + GestureDetector( + onTap: () => onSetSceneMode(key), child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), border: selected ? Border.all(color: accent, width: 2) : Border.all( - color: isDark - ? Colors.grey[700]! - : Colors.grey[300]!, + color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 1, ), color: selected @@ -154,8 +138,7 @@ class EsriMapLayersPanel extends StatelessWidget { label, style: TextStyle( fontSize: 13, - fontWeight: - selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, color: selected ? accent : textColor, ), ), @@ -163,6 +146,7 @@ class EsriMapLayersPanel extends StatelessWidget { ); final bottomPad = MediaQuery.of(context).padding.bottom; + final isDefault = currentSceneKey == 'default'; return Positioned( left: 12, @@ -173,144 +157,123 @@ class EsriMapLayersPanel extends StatelessWidget { if (details.velocity.pixelsPerSecond.dy > 200) onClose(); }, child: Material( - elevation: 10, - color: bgColor, - borderRadius: BorderRadius.circular(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), - child: Row( - children: [ - Expanded( - child: Text( - 'Map Display', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: textColor, + elevation: 10, + color: bgColor, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Map Display', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: textColor, + ), ), ), - ), - IconButton( - icon: Icon(Icons.close, size: 20, color: subtitleColor), - onPressed: onClose, - ), - ], + IconButton( + icon: Icon(Icons.close, size: 20, color: subtitleColor), + onPressed: onClose, + ), + ], + ), ), - ), - // Basemap + Layers sections — grayed out when not in Default scene mode - Opacity( - opacity: sceneMode == 'Default' ? 1.0 : 0.35, - child: IgnorePointer( - ignoring: sceneMode != 'Default', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row 1: Basemaps - sectionLabel('BASEMAP'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (final type in BasemapType.values) ...[ - imageTile( - label: basemapLabels[type]!, - selected: currentBasemapType == type, - onTap: () => onSwitchBasemap(type), - imageWidget: Image.asset( - { - BasemapType.defaultMap: 'lib/ui/esrimap/temp_assets/default-thumbnail.png', - BasemapType.light: 'lib/ui/esrimap/temp_assets/light-thumbnail.png', - BasemapType.dark: 'lib/ui/esrimap/temp_assets/dark-thumbnail.png', - BasemapType.satellite: 'lib/ui/esrimap/temp_assets/satellite-thumbnail.png', - }[type]!, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), + // Basemap + Layers — grayed out when not in default scene + Opacity( + opacity: isDefault ? 1.0 : 0.35, + child: IgnorePointer( + ignoring: !isDefault, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionLabel('BASEMAP'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final entry in config.basemaps.entries) ...[ + if (basemapTypeFromKey(entry.key) != null) ...[ + imageTile( + label: entry.value.label, + selected: currentBasemapType == + basemapTypeFromKey(entry.key), + onTap: () { + final t = basemapTypeFromKey(entry.key); + if (t != null) onSwitchBasemap(t); + }, + imageWidget: networkImage(entry.value.thumbnailAsset), + ), + const SizedBox(width: 8), + ], + ], ], - ], + ), ), ), - ), - const Divider(height: 24, indent: 16, endIndent: 16), + const Divider(height: 24, indent: 16, endIndent: 16), - // Row 2: Operational layers - sectionLabel('LAYERS'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - imageTile( - label: 'Triton Transit', - selected: showTransitLayer, - loading: loadingTransitLayer, - onTap: onToggleTransitLayer, - imageWidget: Image.asset( - 'lib/ui/esrimap/temp_assets/shuttles-thumbnail.png', - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), - imageTile( - label: 'Districts', - selected: showCampusDistricts, - onTap: onToggleCampusDistricts, - imageWidget: Image.asset( - 'lib/ui/esrimap/temp_assets/districts-thumbnail.png', - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), - imageTile( - label: 'Construction', - selected: showConstruction, - loading: loadingConstruction, - onTap: onToggleConstruction, - imageWidget: Image.asset( - 'lib/ui/esrimap/temp_assets/construction-thumbnail.png', - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), - ], + sectionLabel('LAYERS'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final entry in config.layers.entries) ...[ + imageTile( + label: entry.value.label, + selected: layerVisible[entry.key] ?? false, + loading: layerLoading[entry.key] ?? false, + onTap: () => onToggleLayer(entry.key), + imageWidget: networkImage(entry.value.thumbnailAsset), + ), + const SizedBox(width: 8), + ], + ], + ), ), ), - ), - - const Divider(height: 24, indent: 16, endIndent: 16), - ], + ], + ), ), ), - ), - // Row 3: Scene mode chips - sectionLabel('SCENE'), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), - child: Wrap( - spacing: 8, - children: [ - sceneChip('Default', sceneMode == 'Default'), - sceneChip('3D Building', sceneMode == '3D Building'), - sceneChip('Drone View', sceneMode == 'Drone View'), - ], - ), - ), - ], + // Scene chips — gated by features flag + if (config.features.scenes) ...[ + const Divider(height: 24, indent: 16, endIndent: 16), + sectionLabel('SCENE'), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), + child: Wrap( + spacing: 8, + children: [ + for (final entry in config.scenes.entries) + sceneChip( + entry.key, + entry.value.label, + currentSceneKey == entry.key, + ), + ], + ), + ), + ] else + const SizedBox(height: 20), + ], + ), ), ), - ), ); } -} +} \ No newline at end of file diff --git a/lib/ui/esrimap/mapConfig.json b/lib/ui/esrimap/mapConfig.json new file mode 100644 index 000000000..4f8c2ec23 --- /dev/null +++ b/lib/ui/esrimap/mapConfig.json @@ -0,0 +1,166 @@ +{ + "portals": { + "age": "https://admin-enterprise-gis.ucsd.edu/portal", + "ago": "https://ucsd-admin.maps.arcgis.com" + }, + + "serviceUrls": { + "hillshade": "https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer", + "lightGrayBase": "https://services.arcgisonline.com/arcgis/rest/services/Canvas/World_Light_Gray_Base/MapServer", + "darkGrayBase": "https://services.arcgisonline.com/arcgis/rest/services/Canvas/World_Dark_Gray_Base/MapServer", + "nearmap": "https://admin-enterprise-gis.ucsd.edu/server/rest/services/Campus_Imagery_Nearmap_9in_2025_0423_0514/MapServer" + }, + + "basemaps": { + "defaultMap": { + "label": "Default", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/default-thumbnail.png", + "baseLayers": [ + { + "type": "arcgisTiled", + "serviceKey": "hillshade" + }, + { + "type": "arcgisTiled", + "serviceKey": "lightGrayBase" + }, + { + "type": "arcgisVectorTiled", + "portalKey": "age", + "itemId": "e19f33d2c1f44967aef673306c483913" + } + ] + }, + "light": { + "label": "Light", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/light-thumbnail.png", + "baseLayers": [ + { + "type": "arcgisTiled", + "serviceKey": "hillshade" + }, + { + "type": "arcgisTiled", + "serviceKey": "lightGrayBase" + }, + { + "type": "arcgisVectorTiled", + "portalKey": "age", + "itemId": "6643ee62af494f5bafe7dfdb8eb3f857" + } + ] + }, + "dark": { + "label": "Dark", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/dark-thumbnail.png", + "baseLayers": [ + { + "type": "arcgisTiled", + "serviceKey": "hillshade" + }, + { + "type": "arcgisTiled", + "serviceKey": "darkGrayBase" + }, + { + "type": "arcgisVectorTiled", + "portalKey": "age", + "itemId": "09d7b3934b6c4c2cad8380c04e08c1b1" + } + ] + }, + "satellite": { + "label": "Satellite", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/satellite-thumbnail.png", + "baseLayers": [ + { + "type": "arcgisTiled", + "serviceKey": "lightGrayBase" + }, + { + "type": "arcgisMapImage", + "serviceKey": "nearmap" + } + ] + } + }, + + "layers": { + "tritonTransit": { + "label": "Triton Transit", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/shuttles-thumbnail.png", + "sublayers": [ + { + "name": "transitRoutes", + "source": "url", + "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Route_Lines/FeatureServer/0", + "refreshInterval": 0, + "popup": false + }, + { + "name": "transitStops", + "source": "url", + "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Bus_Stops/FeatureServer/1", + "refreshInterval": 0, + "popup": true, + "popupTemplate": "Stop #${stop_id}: ${stop_name}\n${route_short_names}" + }, + { + "name": "transitShuttlePositions", + "source": "url", + "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Shuttle_Positions/FeatureServer/0", + "refreshInterval": 5, + "popup": true, + "popupTemplate": "${route_short_name} - ${route_long_name}\n${route_desc}" + } + ] + }, + "campusDistricts": { + "label": "Districts", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/districts-thumbnail.png", + "source": "url", + "url": "https://admin-enterprise-gis.ucsd.edu/server/rest/services/AdministrationServices/Areas_and_Boundaries/MapServer", + "refreshInterval": 0, + "popup": false + }, + "construction": { + "label": "Construction", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/construction-thumbnail.png", + "source": "url", + "url": "https://admin-enterprise-gis.ucsd.edu/server/rest/services/Construction/Construction_Alert_Approved/MapServer", + "refreshInterval": 0, + "popup": false + } + }, + + "scenes": { + "default": { + "label": "Default", + "type": "2d" + }, + "building3d": { + "label": "3D Building", + "type": "scene", + "portalKey": "ago", + "itemId": "b6931d8c429c4138afd9dd936ee7bcd1" + }, + "droneView": { + "label": "Drone View", + "type": "scene", + "portalKey": "age", + "itemId": "0ffe293479844ce49ff5c30ffc0a0b67" + } + }, + + "features": { + "aiSearch": true, + "scenes": true + }, + + "searchCategories": [ + { "label": "Dining", "poiClass": "Dining", "icon": "restaurant" }, + { "label": "Library", "poiClass": "Library", "icon": "menu_book" }, + { "label": "Parking", "poiClass": "Parking", "icon": "local_parking" }, + { "label": "Recreation", "poiClass": "Recreation", "icon": "fitness_center" } + ] +} \ No newline at end of file From 6619cb932a267cb42bca133662b0c4c175f1a1ac Mon Sep 17 00:00:00 2001 From: aleyu0 <83614302+aleyu0@users.noreply.github.com> Date: Wed, 20 May 2026 16:24:43 -0700 Subject: [PATCH 20/20] user flow improvements --- lib/ui/esrimap/esrimap.dart | 405 +++++++++++------------ lib/ui/esrimap/esrimap_config.dart | 3 + lib/ui/esrimap/esrimap_fab.dart | 176 +++++----- lib/ui/esrimap/esrimap_layers_panel.dart | 6 +- lib/ui/esrimap/esrimap_scene.dart | 38 ++- lib/ui/esrimap/mapConfig.json | 62 ++-- 6 files changed, 346 insertions(+), 344 deletions(-) diff --git a/lib/ui/esrimap/esrimap.dart b/lib/ui/esrimap/esrimap.dart index df0fd0231..e3e287e46 100644 --- a/lib/ui/esrimap/esrimap.dart +++ b/lib/ui/esrimap/esrimap.dart @@ -77,11 +77,13 @@ class _SearchCategory { final String label; final IconData icon; final String poiClassValue; + final Color color; const _SearchCategory({ required this.label, required this.icon, required this.poiClassValue, + this.color = const Color(0xFFF5F0E6), }); } @@ -96,6 +98,14 @@ IconData _iconDataForName(String name) { } } +Color _colorFromHex(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFFF5F0E6); + final stripped = hex.replaceFirst('#', ''); + final value = int.tryParse(stripped, radix: 16); + if (value == null) return const Color(0xFFF5F0E6); + return Color(0xFF000000 | value); +} + // ----------------------------------------------------------------------------- // Slide-over header delegate // ----------------------------------------------------------------------------- @@ -192,15 +202,17 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { // Full unfiltered result set for the active category (used by "See All") List _allCategoryResults = []; - // Whether to show the "See All" pill button - bool _showSeeAll = false; - // Whether to show the full category list panel bool _showCategoryList = false; // The active category (for display in the "See All" label) _SearchCategory? _activeCategory; + // FAB active-button highlight state + bool _isLocationActive = false; + bool _isRecenterActive = false; + bool _ignoreViewpointReset = false; + // Location display final _locationDataSource = SystemLocationDataSource(); bool _locationStarted = false; @@ -247,6 +259,7 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { label: c.label, icon: _iconDataForName(c.icon), poiClassValue: c.poiClass, + color: _colorFromHex(c.color), )) .toList(); } @@ -267,6 +280,8 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { _fetchConfigThenInit(); _loadRecentSearches(); _focusNode.addListener(_onFocusChanged); + _fromFocusNode.addListener(_onFromFocusChanged); + _toFocusNode.addListener(_onToFocusChanged); } Future _fetchConfigThenInit() async { @@ -350,7 +365,13 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { ViewpointType.centerAndScale, ); if (vp != null && mounted) { - setState(() => _mapRotation = vp.rotation); + setState(() { + _mapRotation = vp.rotation; + if (!_ignoreViewpointReset && (_isLocationActive || _isRecenterActive)) { + _isLocationActive = false; + _isRecenterActive = false; + } + }); } }); } @@ -406,14 +427,26 @@ class _EsriMapState extends State with AutomaticKeepAliveClientMixin { key: _scene3DKey, portalUri: portalUrl, itemId: scene.itemId!, - onHeadingChanged: (h) => setState(() => _mapRotation = h), + onHeadingChanged: (h) => setState(() { + _mapRotation = h; + if (!_ignoreViewpointReset && (_isLocationActive || _isRecenterActive)) { + _isLocationActive = false; + _isRecenterActive = false; + } + }), ); } else if (key == 'droneView' && _sceneDroneWidget == null) { _sceneDroneWidget = EsriSceneWidget( key: _sceneDroneKey, portalUri: portalUrl, itemId: scene.itemId!, - onHeadingChanged: (h) => setState(() => _mapRotation = h), + onHeadingChanged: (h) => setState(() { + _mapRotation = h; + if (!_ignoreViewpointReset && (_isLocationActive || _isRecenterActive)) { + _isLocationActive = false; + _isRecenterActive = false; + } + }), ); } }); @@ -890,8 +923,8 @@ void _toggleLayer(String key) async { _isSearching = true; _showResults = false; _showSuggestions = false; + _matchingPoiClasses = []; _selectedResult = null; - _showSeeAll = false; _showCategoryList = false; _activeCategory = category; _searchController.text = category.label; @@ -909,8 +942,7 @@ void _toggleLayer(String key) async { setState(() { _allCategoryResults = allResults; - // Show "Zoom to all" pill whenever there are results - _showSeeAll = allResults.isNotEmpty; + _showCategoryList = allResults.isNotEmpty; _isSearching = false; }); } catch (e) { @@ -918,7 +950,6 @@ void _toggleLayer(String key) async { setState(() { _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _isSearching = false; }); } @@ -938,7 +969,6 @@ void _toggleLayer(String key) async { void _seeAllCategoryResults() { if (_allCategoryResults.isEmpty) return; _zoomToResults(_allCategoryResults); - setState(() => _showSeeAll = false); } void _selectResult(MapSearchResult result) { @@ -953,7 +983,6 @@ void _toggleLayer(String key) async { _showSuggestions = false; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -973,7 +1002,6 @@ void _toggleLayer(String key) async { _showSuggestions = false; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -1077,7 +1105,6 @@ void _toggleLayer(String key) async { _showSuggestions = false; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -1096,7 +1123,6 @@ void _toggleLayer(String key) async { _showSuggestions = false; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -1130,21 +1156,15 @@ void _toggleLayer(String key) async { } void _closeDetail() { - setState(() { - _selectedResult = null; - // If a category search is active and not in routing mode, reopen the list view - if (_allCategoryResults.isNotEmpty && - !_showRouteFields && !_hasRoute && !_routeFailed) { - _showCategoryList = true; - } - }); - } - - void _reopenDetail() { - if (_lastSelectedResult != null) { + // If a category search is active and not in routing mode, reopen the list view + if (_allCategoryResults.isNotEmpty && + !_showRouteFields && !_hasRoute && !_routeFailed) { setState(() { - _selectedResult = _lastSelectedResult; + _selectedResult = null; + _showCategoryList = true; }); + } else { + _clearSearch(); } } @@ -1159,8 +1179,11 @@ void _toggleLayer(String key) async { // Briefly snap to recenter mode, then release back to free pan _mapViewController.locationDisplay.autoPanMode = LocationDisplayAutoPanMode.recenter; - Future.delayed(const Duration(seconds: 3), () { + _ignoreViewpointReset = true; + setState(() => _isLocationActive = true); + Future.delayed(const Duration(milliseconds: 1500), () { if (mounted) { + _ignoreViewpointReset = false; _mapViewController.locationDisplay.autoPanMode = LocationDisplayAutoPanMode.off; } @@ -1168,6 +1191,11 @@ void _toggleLayer(String key) async { } void _recenterOnView() { + _ignoreViewpointReset = true; + setState(() => _isRecenterActive = true); + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) _ignoreViewpointReset = false; + }); if (_sceneMode != 'default') { if (_sceneMode == 'building3d') { _scene3DKey.currentState?.resetCamera(); @@ -1235,7 +1263,6 @@ void _toggleLayer(String key) async { _allCategoryResults = []; _showResults = false; _showSuggestions = false; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; _selectedResult = null; @@ -1406,7 +1433,6 @@ void _toggleLayer(String key) async { setState(() { _isRouting = false; _hasRoute = true; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; _selectedResult = destination; @@ -1480,7 +1506,6 @@ void _toggleLayer(String key) async { _lastSelectedResult = destination; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -1569,29 +1594,18 @@ void _toggleLayer(String key) async { borderRadius: BorderRadius.circular(8), color: bgColor, child: Padding( - padding: EdgeInsets.symmetric(vertical: 12), + padding: EdgeInsets.only( + top: (_showRouteFields && _activeRouteField == 'from') ? 0 : 12, + bottom: 12, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // user location from from field in routing mode if (_showRouteFields && _activeRouteField == 'from') ...[ - ListTile( - splashColor: Colors.transparent, - dense: true, - leading: Icon( - Icons.my_location, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - title: Text( - 'Current Location', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.primary, - ), - ), + GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () { final gps = _getUserLatLng(); if (gps == null) return; @@ -1606,9 +1620,30 @@ void _toggleLayer(String key) async { _solveRoute(_routeDestination!, originLatLng: _fromLatLng); } }, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Row( + children: [ + Icon( + Icons.my_location, + size: 20, + color: isDark ? Colors.grey[500] : Theme.of(context).colorScheme.primary, + ), + SizedBox(width: 12), + Text( + 'Current Location', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white : Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), ), Divider(height: 1), - SizedBox(height: 8), + SizedBox(height: 12), ], // Category section header Padding( @@ -1616,7 +1651,7 @@ void _toggleLayer(String key) async { child: Text( 'Suggested', style: TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? Colors.grey[400] : Colors.grey[600], ), @@ -1645,7 +1680,7 @@ void _toggleLayer(String key) async { child: Text( 'Recently viewed', style: TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? Colors.grey[400] : Colors.grey[600], ), @@ -1688,19 +1723,6 @@ void _toggleLayer(String key) async { Widget _buildCategoryChip(BuildContext context, _SearchCategory category) { final isDark = Theme.of(context).brightness == Brightness.dark; - final colors = { - 'Parking': const Color(0xFFCCF4F7), - 'Dining': const Color(0xFFF9DDEF), - 'Recreation': const Color(0xFFFCF9CC), - 'Library': const Color(0xFFE8F4FD), - }; - final iconColors = { - 'Parking': Colors.black, - 'Dining': Colors.black, - 'Recreation': Colors.black, - 'Library': Colors.black, - }; - return GestureDetector( onTap: () => _performCategorySearch(category), child: Column( @@ -1710,20 +1732,20 @@ void _toggleLayer(String key) async { width: 56, height: 56, decoration: BoxDecoration( - color: colors[category.poiClassValue] ?? (isDark ? Colors.grey[800]! : Colors.grey[100]!), + color: category.color, shape: BoxShape.circle, ), child: Icon( category.icon, size: 24, - color: iconColors[category.poiClassValue] ?? (isDark ? Colors.white70 : Colors.grey[700]), + color: Colors.black, ), ), SizedBox(height: 6), Text( category.label, style: TextStyle( - fontSize: 12, + fontSize: 13, color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), @@ -1732,48 +1754,6 @@ void _toggleLayer(String key) async { ); } - Widget _buildSeeAllButton(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final total = _allCategoryResults.length; - - return GestureDetector( - onTap: _seeAllCategoryResults, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: isDark ? Colors.grey[850] : Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.layers_outlined, - size: 16, - color: isDark ? Colors.white70 : Colors.grey[700], - ), - const SizedBox(width: 6), - Text( - 'See all $total results', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.grey[900], - ), - ), - ], - ), - ), - ); - } - // --------------------------------------------------------------------------- // Map interaction → collapse slideover // --------------------------------------------------------------------------- @@ -1872,7 +1852,7 @@ void _toggleLayer(String key) async { child: Text( headerTitle, style: TextStyle( - fontSize: 15, + fontSize: 16, fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.grey[900], ), @@ -1961,27 +1941,29 @@ void _toggleLayer(String key) async { scrollController: scrollController, headerTitle: headerTitle, headerIcon: _activeCategory?.icon ?? Icons.place, - onClose: () => setState(() => _showCategoryList = false), - trailing: TextButton.icon( - icon: Icon( - Icons.refresh, - size: 16, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - label: Text( - 'Search here', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.grey[600], - ), - ), - style: TextButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - ), - onPressed: () => setState(() {}), - ), + onClose: _clearSearch, + trailing: _allCategoryResults.length > results.length + ? TextButton.icon( + icon: Icon( + Icons.layers_outlined, + size: 16, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + label: Text( + 'See all ${_allCategoryResults.length} results', + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white54 : Colors.grey[600], + ), + ), + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: _seeAllCategoryResults, + ) + : null, sliverBody: results.isEmpty ? [ SliverToBoxAdapter( @@ -1997,10 +1979,10 @@ void _toggleLayer(String key) async { : Colors.grey[400]), const SizedBox(height: 8), Text( - 'No $label in current view.\nPan the map, then tap "Search here" to refresh.', + 'No $label in current view.\nPan the map to see results.', textAlign: TextAlign.center, style: TextStyle( - fontSize: 14, + fontSize: 16, color: isDark ? Colors.grey[500] : Colors.grey[600], @@ -2041,12 +2023,12 @@ void _toggleLayer(String key) async { r.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 16), ), subtitle: distLabel != null ? Text(distLabel, style: TextStyle( - fontSize: 12, + fontSize: 13, color: Colors.grey[500], )) : (r.subtitle.isNotEmpty @@ -2054,7 +2036,7 @@ void _toggleLayer(String key) async { maxLines: 1, overflow: TextOverflow.ellipsis, style: - const TextStyle(fontSize: 12)) + const TextStyle(fontSize: 13)) : null), dense: true, onTap: () => _selectResultFromPin(r), @@ -2132,7 +2114,7 @@ void _toggleLayer(String key) async { Text( categoryLabel, style: TextStyle( - fontSize: 13, + fontSize: 14, color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), @@ -2143,7 +2125,7 @@ void _toggleLayer(String key) async { Text( detailText, style: TextStyle( - fontSize: 14, + fontSize: 16, color: isDark ? Colors.grey[400] : Colors.grey[600], ), @@ -2158,7 +2140,7 @@ void _toggleLayer(String key) async { Text( 'No route available from your current location.', style: TextStyle( - fontSize: 14, + fontSize: 16, color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), @@ -2198,7 +2180,7 @@ void _toggleLayer(String key) async { ? '< 1 min' : '${_routeTravelTimeMinutes.ceil()} min', style: TextStyle( - fontSize: 14, + fontSize: 16, fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.grey[900], ), @@ -2249,7 +2231,7 @@ void _toggleLayer(String key) async { Text( mode, style: TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w500, color: mode == _travelMode ? (isDark @@ -2341,7 +2323,6 @@ void _toggleLayer(String key) async { _selectedResult = null; _mappedResults = []; _allCategoryResults = []; - _showSeeAll = false; _showCategoryList = false; _activeCategory = null; }); @@ -2395,13 +2376,13 @@ void _toggleLayer(String key) async { ), title: Text( step.directionText, - style: const TextStyle(fontSize: 13), + style: const TextStyle(fontSize: 14), ), trailing: distMeters > 0 ? Text( distLabel, style: TextStyle( - fontSize: 12, + fontSize: 13, color: isDark ? Colors.grey[500] : Colors.grey[500], @@ -2514,13 +2495,13 @@ void _toggleLayer(String key) async { child: TextField( controller: _fromController, focusNode: _fromFocusNode, - style: const TextStyle(fontSize: 15), + style: const TextStyle(fontSize: 16), decoration: const InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: 10, - vertical: 10), + vertical: 14), hintText: 'From', isDense: true, ), @@ -2577,13 +2558,13 @@ void _toggleLayer(String key) async { child: TextField( controller: _toController, focusNode: _toFocusNode, - style: const TextStyle(fontSize: 15), + style: const TextStyle(fontSize: 16), decoration: const InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: 10, - vertical: 10), + vertical: 14), hintText: 'To', isDense: true, ), @@ -2634,17 +2615,31 @@ void _toggleLayer(String key) async { ], ), ), - // Swap button - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - icon: Icon(Icons.swap_vert, - size: 20, - color: isDark - ? Colors.white70 - : Colors.grey[600]), + // Close + Swap buttons + Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.close, + size: 20, + color: Colors.redAccent), + onPressed: _clearRoute, + ), + ), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.swap_vert, + size: 20, + color: isDark + ? Colors.white70 + : Colors.grey[600]), onPressed: () { final tmpText = _fromController.text; final tmpLatLng = _fromLatLng; @@ -2669,6 +2664,8 @@ void _toggleLayer(String key) async { } }, ), + ), + ], ), const SizedBox(width: 4), ], @@ -2745,7 +2742,7 @@ void _toggleLayer(String key) async { border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: 12, - vertical: 14, + vertical: 16, ), hintText: 'Search buildings, places...', ), @@ -2756,6 +2753,27 @@ void _toggleLayer(String key) async { icon: Icon(Icons.clear), onPressed: _clearSearch, ), + if (_config?.features.aiSearch ?? false) + IconButton( + icon: Icon( + Icons.auto_awesome, + color: isDark + ? Colors.amber[300] + : Theme.of(context).colorScheme.primary, + size: 20, + ), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => EsriAiSearchSheet( + userLat: _getUserLatLng()?.$1, + userLon: _getUserLatLng()?.$2, + onLocationSelected: _onAiLocationSelected, + onRouteRequested: _onAiRouteRequested, + ), + ), + ), ], ), ), @@ -2788,7 +2806,7 @@ void _toggleLayer(String key) async { Text( 'CATEGORIES', style: TextStyle( - fontSize: 11, + fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: isDark ? Colors.grey[500] : Colors.grey[500], @@ -2807,9 +2825,9 @@ void _toggleLayer(String key) async { ), title: Text(_labelForClass(classValue)), subtitle: Text( - classValue, // show raw class value as subtitle so user knows what they're getting + classValue, style: TextStyle( - fontSize: 12, + fontSize: 13, color: isDark ? Colors.grey[500] : Colors.grey[500]), ), dense: true, @@ -2872,56 +2890,32 @@ void _toggleLayer(String key) async { ), - // "See All" pill — shown when category results are filtered to viewport - if (_showSeeAll && _selectedResult == null) - Positioned( - bottom: 32, - left: 0, - right: 0, - child: Center( - child: _buildSeeAllButton(context), - ), - ), - // Bottom-right FAB cluster — hidden when keyboard or layers panel is active - if (_selectedResult == null && !keyboardVisible && !_showLayersPanel) + // Category list panel — shown when list button is toggled + if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) + _buildCategoryListPanel(context), + + // Detail slide-over + if (_selectedResult != null) + _buildDetailSlideOver(context, _selectedResult!), + + // Top-right FAB cluster — hidden when keyboard or layers panel is active + if (!keyboardVisible && !_focusNode.hasFocus && !_fromFocusNode.hasFocus && !_toFocusNode.hasFocus && !_showLayersPanel) Positioned( - right: 16, - bottom: 32, - child: EsriMapFabCluster( + right: 12, + top: _showRouteFields ? 120 : 68, + child: EsriMapFabCluster( isDark: isDark, is3D: _sceneMode != 'default', - showAiSearch: _config?.features.aiSearch ?? false, - allCategoryResultsCount: _allCategoryResults.length, - showCategoryList: _showCategoryList, - hasLastSelectedResult: _lastSelectedResult != null, - showRouteFields: _showRouteFields, - hasRoute: _hasRoute, mapRotation: _mapRotation, - onShowAiSearch: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => EsriAiSearchSheet( - userLat: _getUserLatLng()?.$1, - userLon: _getUserLatLng()?.$2, - onLocationSelected: _onAiLocationSelected, - onRouteRequested: _onAiRouteRequested, - ), - ), + isLocationActive: _isLocationActive, + isRecenterActive: _isRecenterActive, onShowLayersPanel: () => setState(() => _showLayersPanel = true), - onToggleCategoryList: () => setState(() => _showCategoryList = !_showCategoryList), - onReopenDetail: _reopenDetail, - onClearRoute: _clearRoute, onRecenterOnView: _recenterOnView, onRecenterOnUser: _recenterOnUser, onSnapToNorth: _snapToNorth, ), ), - // Category list panel — shown when list button is toggled - if (_showCategoryList && _selectedResult == null && _allCategoryResults.isNotEmpty) - _buildCategoryListPanel(context), - // Layers/display panel if (_showLayersPanel && _config != null) EsriMapLayersPanel( @@ -2930,15 +2924,12 @@ void _toggleLayer(String key) async { currentSceneKey: _sceneMode, layerVisible: _layerVisible, layerLoading: _layerLoading, + hideSceneSwitcher: _selectedResult != null, onSwitchBasemap: _switchBasemap, onSetSceneMode: _setSceneMode, onToggleLayer: _toggleLayer, onClose: () => setState(() => _showLayersPanel = false), ), - - // Detail slide-over - if (_selectedResult != null) - _buildDetailSlideOver(context, _selectedResult!), ], ), ); diff --git a/lib/ui/esrimap/esrimap_config.dart b/lib/ui/esrimap/esrimap_config.dart index 55a9b5bea..a63a01656 100644 --- a/lib/ui/esrimap/esrimap_config.dart +++ b/lib/ui/esrimap/esrimap_config.dart @@ -170,11 +170,13 @@ class SearchCategoryConfig { final String label; final String poiClass; final String icon; + final String? color; const SearchCategoryConfig({ required this.label, required this.poiClass, required this.icon, + this.color, }); factory SearchCategoryConfig.fromJson(Map json) => @@ -182,6 +184,7 @@ class SearchCategoryConfig { label: json['label'] as String, poiClass: json['poiClass'] as String, icon: json['icon'] as String, + color: json['color'] as String?, ); } diff --git a/lib/ui/esrimap/esrimap_fab.dart b/lib/ui/esrimap/esrimap_fab.dart index 7cfed46cf..a2187190a 100644 --- a/lib/ui/esrimap/esrimap_fab.dart +++ b/lib/ui/esrimap/esrimap_fab.dart @@ -4,18 +4,10 @@ import 'package:flutter/material.dart'; class EsriMapFabCluster extends StatelessWidget { final bool isDark; final bool is3D; - final bool showAiSearch; - final int allCategoryResultsCount; - final bool showCategoryList; - final bool hasLastSelectedResult; - final bool showRouteFields; - final bool hasRoute; final double mapRotation; + final bool isLocationActive; + final bool isRecenterActive; final VoidCallback onShowLayersPanel; - final VoidCallback onShowAiSearch; - final VoidCallback onToggleCategoryList; - final VoidCallback onReopenDetail; - final VoidCallback onClearRoute; final VoidCallback onRecenterOnView; final VoidCallback onRecenterOnUser; final VoidCallback onSnapToNorth; @@ -24,115 +16,107 @@ class EsriMapFabCluster extends StatelessWidget { Key? key, required this.isDark, this.is3D = false, - this.showAiSearch = true, - required this.allCategoryResultsCount, - required this.showCategoryList, - required this.hasLastSelectedResult, - required this.showRouteFields, - required this.hasRoute, required this.mapRotation, + this.isLocationActive = false, + this.isRecenterActive = false, required this.onShowLayersPanel, - required this.onShowAiSearch, - required this.onToggleCategoryList, - required this.onReopenDetail, - required this.onClearRoute, required this.onRecenterOnView, required this.onRecenterOnUser, required this.onSnapToNorth, }) : super(key: key); + static const _activeColor = Color(0xFFC69214); + static const _size = 48.0; + static const _btnHeight = 44.0; + @override Widget build(BuildContext context) { final bgColor = isDark ? Colors.grey[800]! : Colors.white; final fgColor = isDark ? Colors.white : Colors.grey[800]!; - final accent = Theme.of(context).colorScheme.primary; return Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (!is3D) ...[ - FloatingActionButton.small( - heroTag: 'aiSearchBtn', - backgroundColor: accent, - foregroundColor: Colors.white, - onPressed: onShowAiSearch, - child: const Icon(Icons.auto_awesome), - ), - const SizedBox(height: 10), - ], - FloatingActionButton.small( - heroTag: 'layersBtn', - backgroundColor: bgColor, - foregroundColor: fgColor, - onPressed: onShowLayersPanel, - child: const Icon(Icons.layers_outlined), - ), - const SizedBox(height: 10), - // List view button — only when a category search is active and not in 3D - if (!is3D && allCategoryResultsCount > 0) ...[ - FloatingActionButton.small( - heroTag: 'listBtn', - onPressed: onToggleCategoryList, - backgroundColor: bgColor, - foregroundColor: fgColor, - child: const Icon(Icons.list), - ), - const SizedBox(height: 10), - ], - if (!is3D && hasLastSelectedResult) ...[ - FloatingActionButton.small( - heroTag: 'infoBtn', - backgroundColor: bgColor, - foregroundColor: fgColor, - onPressed: onReopenDetail, - child: const Icon(Icons.info_outline), - ), - const SizedBox(height: 10), - ], - if (!is3D && (showRouteFields || hasRoute)) ...[ - FloatingActionButton.small( - heroTag: 'clearRouteBtn', - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - onPressed: onClearRoute, - child: const Icon(Icons.close), + // Compass — matches pill width + Material( + elevation: 4, + color: bgColor, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: GestureDetector( + onTap: onSnapToNorth, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: _size, + height: _size, + child: Center( + child: CustomPaint( + size: const Size(24, 24), + painter: _CompassNeedlePainter( + rotationDegrees: mapRotation, + southColor: fgColor.withValues(alpha: 0.35), + ), + ), + ), + ), ), - const SizedBox(height: 10), - ], - FloatingActionButton.small( - heroTag: 'recenterBtn', - backgroundColor: bgColor, - foregroundColor: fgColor, - onPressed: onRecenterOnView, - child: const Icon(Icons.center_focus_strong), ), const SizedBox(height: 10), - FloatingActionButton.small( - heroTag: 'locateBtn', - backgroundColor: bgColor, - foregroundColor: fgColor, - onPressed: onRecenterOnUser, - child: const Icon(Icons.my_location), - ), - const SizedBox(height: 10), - // Compass — rotates with the map, tapping snaps back to north - FloatingActionButton.small( - heroTag: 'compassBtn', - backgroundColor: bgColor, - foregroundColor: fgColor, - onPressed: onSnapToNorth, - child: CustomPaint( - size: const Size(22, 22), - painter: _CompassNeedlePainter( - rotationDegrees: mapRotation, - southColor: fgColor.withOpacity(0.35), + // Pill — my location, recenter, layers + Material( + elevation: 4, + color: bgColor, + borderRadius: BorderRadius.circular(_size / 2), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: _size, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!is3D) + _pillButton( + icon: Icons.my_location, + color: isLocationActive ? _activeColor : fgColor, + onTap: onRecenterOnUser, + ), + _pillButton( + icon: Icons.center_focus_strong, + color: isRecenterActive ? _activeColor : fgColor, + onTap: onRecenterOnView, + ), + _pillButton( + icon: Icons.layers_outlined, + color: fgColor, + onTap: onShowLayersPanel, + ), + ], + ), ), ), ), ], ); } + + Widget _pillButton({ + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: _btnHeight, + child: Center( + child: Icon(icon, size: 22, color: color), + ), + ), + ); + } } class _CompassNeedlePainter extends CustomPainter { @@ -155,14 +139,12 @@ class _CompassNeedlePainter extends CustomPainter { canvas.translate(cx, cy); canvas.rotate(-rotationDegrees * math.pi / 180); - // North (red) half — points up final northPath = Path() ..moveTo(0, -tipDist) ..lineTo(halfWidth, 0) ..lineTo(-halfWidth, 0) ..close(); - // South (muted) half — points down final southPath = Path() ..moveTo(0, tipDist) ..lineTo(halfWidth, 0) diff --git a/lib/ui/esrimap/esrimap_layers_panel.dart b/lib/ui/esrimap/esrimap_layers_panel.dart index a12e20f2b..7f25c7466 100644 --- a/lib/ui/esrimap/esrimap_layers_panel.dart +++ b/lib/ui/esrimap/esrimap_layers_panel.dart @@ -12,6 +12,7 @@ class EsriMapLayersPanel extends StatelessWidget { final void Function(String) onSetSceneMode; final void Function(String) onToggleLayer; final VoidCallback onClose; + final bool hideSceneSwitcher; const EsriMapLayersPanel({ Key? key, @@ -24,6 +25,7 @@ class EsriMapLayersPanel extends StatelessWidget { required this.onSetSceneMode, required this.onToggleLayer, required this.onClose, + this.hideSceneSwitcher = false, }) : super(key: key); @override @@ -250,8 +252,8 @@ class EsriMapLayersPanel extends StatelessWidget { ), ), - // Scene chips — gated by features flag - if (config.features.scenes) ...[ + // Scene chips — gated by features flag, hidden when slide-over is active + if (config.features.scenes && !hideSceneSwitcher) ...[ const Divider(height: 24, indent: 16, endIndent: 16), sectionLabel('SCENE'), Padding( diff --git a/lib/ui/esrimap/esrimap_scene.dart b/lib/ui/esrimap/esrimap_scene.dart index 8fd902bb5..5b4515b4c 100644 --- a/lib/ui/esrimap/esrimap_scene.dart +++ b/lib/ui/esrimap/esrimap_scene.dart @@ -21,6 +21,7 @@ class EsriSceneWidget extends StatefulWidget { class EsriSceneWidgetState extends State { late final ArcGISSceneViewController _sceneViewController; Camera? _initialCamera; + double _camToTargetLatOffset = 0.0; StreamSubscription? _viewpointSubscription; @override @@ -54,6 +55,19 @@ class EsriSceneWidgetState extends State { ); _sceneViewController.setViewpointCamera(_initialCamera!); + // Capture the lat offset between camera and look-at target once the + // viewpoint stabilizes — used by snapToNorth to rotate around target. + Future.delayed(const Duration(milliseconds: 500), () { + if (!mounted) return; + final vp = _sceneViewController.getCurrentViewpoint( + ViewpointType.centerAndScale, + ); + final pt = vp?.targetGeometry; + if (pt is ArcGISPoint) { + _camToTargetLatOffset = pt.y - _initialCamera!.location.y; + } + }); + _viewpointSubscription = _sceneViewController.onViewpointChanged.listen((_) { if (!mounted) return; final vp = _sceneViewController.getCurrentViewpoint( @@ -90,20 +104,30 @@ class EsriSceneWidgetState extends State { _sceneViewController.setViewpointCamera(_initialCamera!); } - /// Snaps heading to north while preserving current position and pitch. + /// Snaps heading to north by rotating the camera around the current + /// look-at target so the building/point stays centered on screen. void snapToNorth() { if (_initialCamera == null) return; final vp = _sceneViewController.getCurrentViewpoint( ViewpointType.centerAndScale, ); - final pt = vp?.targetGeometry; - final lat = (pt is ArcGISPoint) ? pt.y : _initialCamera!.location.y; - final lon = (pt is ArcGISPoint) ? pt.x : _initialCamera!.location.x; + final target = vp?.targetGeometry; + if (target is! ArcGISPoint) { + resetCamera(); + return; + } + + // Position camera due south of the current target at the same distance + // as the initial camera-to-target offset + final pivotLat = target.y; + final pivotLon = target.x; + final camAlt = _initialCamera!.location.z ?? 1062.871; + _sceneViewController.setViewpointCamera( Camera.withLatLong( - latitude: lat, - longitude: lon, - altitude: _initialCamera!.location.z ?? 1062.871, + latitude: pivotLat - _camToTargetLatOffset, + longitude: pivotLon, + altitude: camAlt, heading: 0, pitch: _initialCamera!.pitch, roll: 0, diff --git a/lib/ui/esrimap/mapConfig.json b/lib/ui/esrimap/mapConfig.json index 4f8c2ec23..7eda26b77 100644 --- a/lib/ui/esrimap/mapConfig.json +++ b/lib/ui/esrimap/mapConfig.json @@ -87,33 +87,13 @@ "layers": { "tritonTransit": { - "label": "Triton Transit", - "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/shuttles-thumbnail.png", - "sublayers": [ - { - "name": "transitRoutes", - "source": "url", - "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Route_Lines/FeatureServer/0", - "refreshInterval": 0, - "popup": false - }, - { - "name": "transitStops", - "source": "url", - "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Bus_Stops/FeatureServer/1", - "refreshInterval": 0, - "popup": true, - "popupTemplate": "Stop #${stop_id}: ${stop_name}\n${route_short_names}" - }, - { - "name": "transitShuttlePositions", - "source": "url", - "url": "https://services9.arcgis.com/mXNwDpiENQiMIzRv/arcgis/rest/services/Triton_Transit_Shuttle_Positions/FeatureServer/0", - "refreshInterval": 5, - "popup": true, - "popupTemplate": "${route_short_name} - ${route_long_name}\n${route_desc}" - } - ] + "label": "Transit", + "thumbnailAsset": "https://dvsbke0idzv4u.cloudfront.net/thumbnails/shuttles-thumbnail.png", + "source": "portalItem", + "portalKey": "ago", + "itemId": "ea5cdd8987414942b62178726120d5c7", + "refreshInterval": 0, + "popup": false }, "campusDistricts": { "label": "Districts", @@ -158,9 +138,29 @@ }, "searchCategories": [ - { "label": "Dining", "poiClass": "Dining", "icon": "restaurant" }, - { "label": "Library", "poiClass": "Library", "icon": "menu_book" }, - { "label": "Parking", "poiClass": "Parking", "icon": "local_parking" }, - { "label": "Recreation", "poiClass": "Recreation", "icon": "fitness_center" } + { + "label": "Dining", + "poiClass": "Dining and Beverage", + "icon": "restaurant", + "color": "#00C6D7" + }, + { + "label": "Library", + "poiClass": "Library", + "icon": "menu_book", + "color": "#D462AD" + }, + { + "label": "Parking", + "poiClass": "Parking", + "icon": "local_parking", + "color": "#6E963B" + }, + { + "label": "Recreation", + "poiClass": "Recreation Facilities", + "icon": "fitness_center", + "color": "#FC8900" + } ] } \ No newline at end of file