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