diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f1562cc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dart analyze *)" + ] + } +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5192400 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,59 @@ +name: CI +on: + pull_request: + branches: [navigation-hub, maizebus2, maizebus-2.1, maizebus3, main] + push: + branches: [] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.8' + - run: echo "GOOGLE_MAPS_API_KEY=fake-key-for-ci-build-only" >> android/local.properties + - name: Write dummy firebase_options.dart for CI build + run: | + cat > lib/firebase_options.dart << 'EOF' + import 'package:firebase_core/firebase_core.dart'; + class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform => const FirebaseOptions( + apiKey: 'fake-key-for-ci', + appId: 'fake-app-id', + messagingSenderId: 'fake-sender-id', + projectId: 'fake-project-id', + ); + } + EOF + - name: Write dummy google-services.json for CI build + run: | + cat > android/app/google-services.json << 'EOF' + { + "project_info": { + "project_number": "000000000000", + "project_id": "fake-project-id", + "storage_bucket": "fake-project-id.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "com.ishankumar.maizebus" + } + }, + "oauth_client": [], + "api_key": [{ "current_key": "fake-api-key" }], + "services": { "appinvite_service": { "other_platform_oauth_client": [] } } + } + ], + "configuration_version": "1" + } + EOF + - name: Install Flutter dependencies + run: flutter pub get + - name: Build app + run: flutter build apk --debug --no-pub + env: + GRADLE_OPTS: -Dorg.gradle.daemon=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c4ebdb..6b78d34 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,8 @@ app.*.symbols !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock local.properties + +# floorplan jsons +assets/floorplans/*.json +android/app/src/main/kotlin/com/ishankumar/bluebus/MainActivity.kt +.metadata diff --git a/.metadata b/.metadata index 7700a70..952556d 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "fcf2c11572af6f390246c056bc905eca609533a0" + revision: "6655482ec06e547f90abf8ae7590466f4415978d" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: fcf2c11572af6f390246c056bc905eca609533a0 - base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - - platform: ios - create_revision: fcf2c11572af6f390246c056bc905eca609533a0 - base_revision: fcf2c11572af6f390246c056bc905eca609533a0 + create_revision: 6655482ec06e547f90abf8ae7590466f4415978d + base_revision: 6655482ec06e547f90abf8ae7590466f4415978d + - platform: android + create_revision: 6655482ec06e547f90abf8ae7590466f4415978d + base_revision: 6655482ec06e547f90abf8ae7590466f4415978d # User provided section diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b3034..743e05a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4cc0d1f..85ea8ec 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,7 +29,7 @@ require(flutter.compileSdkVersion >= 35); android { namespace = "com.ishankumar.maizebus" compileSdk = flutter.compileSdkVersion - ndkVersion = "28.1.13356709" + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -38,9 +38,7 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } + // REMOVED compilerOptions from here because it was causing "Unresolved reference" defaultConfig { applicationId = "com.ishankumar.maizebus" @@ -73,15 +71,18 @@ android { } } +// BULLETPROOF FIX: Configure Kotlin compiler tasks directly at the bottom of the file +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { - // the following two might be needed if issues happen - // https://pub.dev/packages/flutter_local_notifications#-android-setup - // implementation("androidx.window:window:1.0.0") - // implementation("androidx.window:window-java:1.0.0") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") implementation(platform("com.google.firebase:firebase-bom:34.6.0")) } flutter { source = "../.." -} +} \ No newline at end of file diff --git a/android/bluebus_android.iml b/android/bluebus_android.iml index 1899969..4ec08e8 100644 --- a/android/bluebus_android.iml +++ b/android/bluebus_android.iml @@ -1,4 +1,4 @@ - +cla diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..71624e8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,8 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G android.useAndroidX=true android.enableJetifier=true +android.builtInKotlin=true +org.gradle.caching=true +org.gradle.parallel=true +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..e4ef43f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 5067194..ff0c397 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,13 +18,13 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false + id("com.android.application") version "8.11.1" apply false // START: FlutterFire Configuration id("com.google.gms.google-services") version("4.3.15") apply false // END: FlutterFire Configuration - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.3.10" apply false } include(":app") diff --git a/assets/destination.png b/assets/destination.png new file mode 100644 index 0000000..a4eb084 Binary files /dev/null and b/assets/destination.png differ diff --git a/assets/floorplans/demoPolygons.csv b/assets/floorplans/demoPolygons.csv new file mode 100644 index 0000000..f65d81d --- /dev/null +++ b/assets/floorplans/demoPolygons.csv @@ -0,0 +1,8 @@ +WKT,name,description +"POLYGON ((-83.7179505 42.2907676, -83.7178368 42.2907672, -83.717837 42.290684, -83.7174296 42.2906819, -83.7174316 42.2907679, -83.7173204 42.2907682, -83.7173181 42.2910521, -83.7172437 42.2910519, -83.7172444 42.2911439, -83.7172591 42.2911441, -83.7172585 42.2911897, -83.7171968 42.2911894, -83.7171959 42.2911361, -83.716998 42.2911366, -83.7170016 42.2915319, -83.7171089 42.2915331, -83.7171096 42.2915611, -83.7174528 42.2915593, -83.7174524 42.2915692, -83.7175505 42.2915527, -83.7175374 42.2915149, -83.7175687 42.2915097, -83.7175581 42.2914755, -83.7175668 42.291477, -83.717568 42.2912842, -83.7177161 42.291285, -83.7177176 42.2911558, -83.717842 42.291156, -83.7178427 42.2910645, -83.7179475 42.2910649, -83.7179505 42.2907676))",pierpont, +"POLYGON ((-83.7147542 42.2918063, -83.7132155 42.2918073, -83.7132157 42.2919972, -83.7132989 42.2919976, -83.7132999 42.2920885, -83.7133423 42.2920887, -83.7133413 42.2919983, -83.71389 42.2920001, -83.7138905 42.2920138, -83.7139065 42.2920251, -83.7139528 42.2919992, -83.7139974 42.2919994, -83.7139974 42.2919894, -83.7140467 42.2919897, -83.7140469 42.2919411, -83.7145945 42.2919397, -83.7145955 42.2920263, -83.7147542 42.2920251, -83.7147542 42.2918063))",automotive, +"POLYGON ((-83.714808 42.2918341, -83.7147568 42.2918341, -83.7147542 42.2920873, -83.7145899 42.2920885, -83.7145895 42.2922035, -83.7145333 42.2922035, -83.7145331 42.2921393, -83.71428 42.2921384, -83.7142803 42.2922043, -83.7135359 42.2921989, -83.7135276 42.2921671, -83.7129846 42.2921609, -83.7129831 42.2924532, -83.7129563 42.2924528, -83.7129564 42.2925063, -83.7136995 42.2925108, -83.7137001 42.2925576, -83.7137698 42.2925592, -83.7137699 42.292673, -83.7140233 42.2926753, -83.7140247 42.2927248, -83.7142782 42.2927249, -83.7142789 42.2927756, -83.7145256 42.2927738, -83.714525 42.2929738, -83.7147366 42.2929734, -83.714734 42.2925703, -83.7148829 42.2925699, -83.7148821 42.2924971, -83.7148098 42.2924978, -83.714808 42.2918341))",EECS, +"POLYGON ((-83.7150477 42.2933338, -83.7150459 42.2931093, -83.7147693 42.29311, -83.7147675 42.2931668, -83.714735 42.2931676, -83.7147366 42.292959, -83.7140091 42.292958, -83.7140093 42.2928236, -83.7137687 42.2928232, -83.7137708 42.2929555, -83.7129022 42.292957, -83.7129027 42.2937633, -83.7132097 42.2937616, -83.7132211 42.2933339, -83.7141449 42.2933314, -83.7141449 42.2933946, -83.714101 42.2933941, -83.7140548 42.2936234, -83.7147719 42.2936235, -83.7147709 42.2933329, -83.7150477 42.2933338))",ggbrown, +"POLYGON ((-83.7157614 42.2927035, -83.7156747 42.2927043, -83.7156758 42.2926751, -83.7155593 42.2926761, -83.7155613 42.2926912, -83.7152971 42.2926907, -83.7152974 42.292679, -83.7151823 42.2926796, -83.7151821 42.292707, -83.7150989 42.2927075, -83.7150992 42.2927847, -83.7150483 42.2927853, -83.7150443 42.2929149, -83.7150934 42.2929152, -83.7150965 42.2932096, -83.715028 42.2932096, -83.7150287 42.2933242, -83.7150785 42.2933241, -83.7150802 42.2933521, -83.7157629 42.2933472, -83.7157622 42.2932974, -83.7158133 42.2932979, -83.7158111 42.2931715, -83.7157641 42.2931726, -83.7157623 42.2929206, -83.7158125 42.2929201, -83.7158126 42.2928041, -83.715759 42.2928046, -83.7157614 42.2927035))",dow, +"POLYGON ((-83.7165555 42.2929131, -83.7165555 42.2928408, -83.7165469 42.2928408, -83.7165476 42.2926725, -83.7163208 42.2926712, -83.7163191 42.2927209, -83.7162154 42.2927196, -83.716215 42.2926711, -83.7160936 42.2926714, -83.7160914 42.2927951, -83.7158982 42.2927916, -83.7158982 42.2926736, -83.7157616 42.2926728, -83.715759 42.2928046, -83.7158126 42.2928041, -83.7158125 42.2929201, -83.7161157 42.2929163, -83.7161117 42.2931352, -83.7160937 42.2931354, -83.7160937 42.2931213, -83.716031 42.2931213, -83.7160278 42.2932999, -83.7160873 42.2933009, -83.71609 42.2932888, -83.7161094 42.2932888, -83.7161093 42.2933581, -83.71617 42.2933575, -83.716174 42.2931805, -83.7163856 42.2931823, -83.7163827 42.2933417, -83.7165336 42.2933441, -83.7165356 42.2931835, -83.7165561 42.2931837, -83.7165561 42.2931107, -83.7165205 42.2931094, -83.716523 42.2929144, -83.7165555 42.2929131))",bbb, +"POLYGON ((-83.7167709 42.2930397, -83.7167754 42.2932264, -83.7174562 42.2932244, -83.7174512 42.2930225, -83.7176489 42.2930222, -83.7176476 42.292981, -83.7177653 42.2928964, -83.7177838 42.2928951, -83.7177827 42.2928594, -83.7178137 42.2928595, -83.7178135 42.2927044, -83.7167602 42.2926916, -83.7167617 42.2927275, -83.716678 42.2927284, -83.7166789 42.2927906, -83.7165739 42.2927939, -83.7165735 42.2929955, -83.7166894 42.2929957, -83.7166911 42.2930385, -83.7167709 42.2930397))",lwbr, diff --git a/assets/floorplans/icons/bathroomF.png b/assets/floorplans/icons/bathroomF.png new file mode 100644 index 0000000..574718d Binary files /dev/null and b/assets/floorplans/icons/bathroomF.png differ diff --git a/assets/floorplans/icons/bathroomM.png b/assets/floorplans/icons/bathroomM.png new file mode 100644 index 0000000..3c9d017 Binary files /dev/null and b/assets/floorplans/icons/bathroomM.png differ diff --git a/assets/floorplans/icons/bathroomN.png b/assets/floorplans/icons/bathroomN.png new file mode 100644 index 0000000..3266260 Binary files /dev/null and b/assets/floorplans/icons/bathroomN.png differ diff --git a/assets/floorplans/icons/elevator.png b/assets/floorplans/icons/elevator.png new file mode 100644 index 0000000..0eef4d7 Binary files /dev/null and b/assets/floorplans/icons/elevator.png differ diff --git a/assets/floorplans/icons/escalator.png b/assets/floorplans/icons/escalator.png new file mode 100644 index 0000000..9fe35c4 Binary files /dev/null and b/assets/floorplans/icons/escalator.png differ diff --git a/assets/floorplans/icons/food.png b/assets/floorplans/icons/food.png new file mode 100644 index 0000000..b3b1a66 Binary files /dev/null and b/assets/floorplans/icons/food.png differ diff --git a/assets/floorplans/icons/info.png b/assets/floorplans/icons/info.png new file mode 100644 index 0000000..b4061c9 Binary files /dev/null and b/assets/floorplans/icons/info.png differ diff --git a/assets/floorplans/icons/stairs.png b/assets/floorplans/icons/stairs.png new file mode 100644 index 0000000..7adc405 Binary files /dev/null and b/assets/floorplans/icons/stairs.png differ diff --git a/assets/start.png b/assets/start.png new file mode 100644 index 0000000..fa26cd9 Binary files /dev/null and b/assets/start.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index de15986..2d469f7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ A8EEDB4981E5AA63E4E4C07B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94DE6146E472E140247FE59F /* Pods_RunnerTests.framework */; }; BC0BD3FB94B8CB1046A5467C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94659E00AEE75CC5EC4BA816 /* Pods_Runner.framework */; }; F744833D8FEFED288923CBE2 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AF4F5C49CB3C179F84A2420E /* GoogleService-Info.plist */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -68,6 +69,7 @@ C60037B3B3142A630549E808 /* 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 = ""; }; CA7A894FFC34F078300536C6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; F4AE1E38C01F7F4541B7025A /* 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 = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,6 +77,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, BC0BD3FB94B8CB1046A5467C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -123,6 +126,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -192,6 +196,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -218,6 +225,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -782,6 +792,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..37a7930 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "33a468adfdb75b53f05a37e7c886ca7c962b5c17", + "version" : "12.17.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "fceaffa07d22dcd5624d3639fd970351a4a5ad8c", + "version" : "12.17.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + []; - final stops = []; + final stops = <(int, BusStop)>[]; // Cast to list to be able to be able to get different elements final pointList = subroute['pt'] as List; for (int i = 0; i < pointList.length; i++) { final point = pointList[i]; - final isLast = i == pointList.length - 1; // bool to check if last points.add( LatLng( point['lat']?.toDouble() ?? 0, @@ -63,28 +41,8 @@ class BlueBusApi { ), ); if (point['typ'] == 'S') { - // get rotation of stop - if (isLast){ - // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( - pointList[i - 2]['lat']?.toDouble() ?? 0, - pointList[i - 2]['lon']?.toDouble() ?? 0, - pointList[i - 1]['lat']?.toDouble() ?? 0, - pointList[i - 1]['lon']?.toDouble() ?? 0, - ); - stops.add(BusStop.fromJson(point, routeId, stopRotation, false)); - - } else { - // use the next 2 points to calculate rotation - double stopRotation = pointRotation( - pointList[i + 1]['lat']?.toDouble() ?? 0, - pointList[i + 1]['lon']?.toDouble() ?? 0, - pointList[i + 2]['lat']?.toDouble() ?? 0, - pointList[i + 2]['lon']?.toDouble() ?? 0, - ); - stops.add(BusStop.fromJson(point, routeId, stopRotation, false)); - } - + final stopRotation = routeStopRotation(pointList, i); + stops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } @@ -105,15 +63,13 @@ class BlueBusApi { // Handle detour points if present if (subroute.containsKey('dtrpt')) { final detourPoints = []; - final detourStops = []; + final detourStops = <(int, BusStop)>[]; // Cast to list to be able to be able to get different elements final detourPointList = subroute['dtrpt'] as List; for (int i = 0; i < detourPointList.length; i++) { final point = detourPointList[i]; - final isLast = i == detourPointList.length - 1; // bool to check if last - detourPoints.add( LatLng( point['lat']?.toDouble() ?? 0, @@ -121,27 +77,8 @@ class BlueBusApi { ), ); if (point['typ'] == 'S') { - // get rotation of stop - if (isLast){ - // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( - detourPointList[i - 2]['lat']?.toDouble() ?? 0, - detourPointList[i - 2]['lon']?.toDouble() ?? 0, - detourPointList[i - 1]['lat']?.toDouble() ?? 0, - detourPointList[i - 1]['lon']?.toDouble() ?? 0, - ); - detourStops.add(BusStop.fromJson(point, routeId, stopRotation, false)); - - } else { - // use the next 2 points to calculate rotation - double stopRotation = pointRotation( - detourPointList[i + 1]['lat']?.toDouble() ?? 0, - detourPointList[i + 1]['lon']?.toDouble() ?? 0, - detourPointList[i + 2]['lat']?.toDouble() ?? 0, - detourPointList[i + 2]['lon']?.toDouble() ?? 0, - ); - detourStops.add(BusStop.fromJson(point, routeId, stopRotation, false)); - } + final stopRotation = routeStopRotation(detourPointList, i); + detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } @@ -166,7 +103,9 @@ class BlueBusApi { // Fetch all buses and their positions static Future> fetchBuses() async { try { - final response = await http.get(Uri.parse('$baseUrl/getVehiclePositions')); + final response = await http.get( + Uri.parse('$baseUrl/getVehiclePositions'), + ); if (response.statusCode != 200) throw Exception('Failed to load buses'); final data = jsonDecode(response.body); final buses = []; @@ -191,10 +130,11 @@ class BlueBusApi { } return buses; - } catch (e){ - + } catch (e) { // on error return a blank list return []; } } } + +// TODO: Make bus routes have better fallback, so if one route fails to be processed it doesn't tank the rest of them diff --git a/lib/constants.dart b/lib/constants.dart index 9d2d9e6..b3d3eb5 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -5,13 +5,15 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; final String currentVersion = '2.0.2'; bool isCurrentVersionEqualOrHigher(String otherVersion) { - final List currentParts = - currentVersion.split('.').map(int.parse).toList(); - final List otherParts = - otherVersion.split('.').map(int.parse).toList(); + final List currentParts = currentVersion + .split('.') + .map(int.parse) + .toList(); + final List otherParts = otherVersion.split('.').map(int.parse).toList(); - final int length = - (currentParts.length < otherParts.length) ? currentParts.length : otherParts.length; + final int length = (currentParts.length < otherParts.length) + ? currentParts.length + : otherParts.length; for (int i = 0; i < length; i++) { if (currentParts[i] > otherParts[i]) { @@ -26,10 +28,10 @@ bool isCurrentVersionEqualOrHigher(String otherVersion) { } // Backend url for the api -// const String BACKEND_URL = 'https://mbus-310c2b44573c.herokuapp.com/mbus/api/v3'; +// const String BACKEND_URL = 'https://mbus-310c2b44573c.herokuapp.com/mbus/api/v3'; const String BACKEND_URL = String.fromEnvironment( 'BACKEND_URL', - defaultValue: 'https://busapi.maizebus.com/mbus/api/v3' + defaultValue: 'https://busapi.maizebus.com/mbus/api/v3', ); //const String BACKEND_URL = String.fromEnvironment('BACKEND_URL', defaultValue: 'https://www.efeakinci.host/mbus/api/v3'); //const String BACKEND_URL = String.fromEnvironment("BACKEND_URL", defaultValue: "http://10.0.2.2:3000/mbus/api/v3/"); @@ -50,6 +52,16 @@ const Map fallback_code_to_name = { 'NES': 'North-East Shuttle', }; +final _whitespacePattern = RegExp(r'\s+'); + +String normalizeStopName(String rawStopName) { + // Remove random characters (add them to list if needed), collapse whitespace to a single space, and trim edges. + return rawStopName + .replaceAll('%', '') + .replaceAll(_whitespacePattern, ' ') + .trim(); +} + String getPrettyRouteName(String code) { for (Map route in globalAvailableRoutes) { if (route['id'] == code) { @@ -71,24 +83,40 @@ const Color maizeBusBlueDarkMode = Color.fromARGB(255, 80, 150, 210); const Color maizeBusBlue = Color.fromARGB(255, 11, 83, 148); enum ColorType { - primary, secondary, opposite, background, backgroundGradientStart, - - mapButtonPrimary, mapButtonSecondary, - mapButtonIcon, mapButtonShadow, - - inputBackground, inputText, - - highlighted, dim, error, + primary, + secondary, + opposite, + background, + backgroundGradientStart, + + mapButtonPrimary, + mapButtonSecondary, + mapButtonIcon, + mapButtonShadow, + + inputBackground, + inputText, + + highlighted, + dim, + error, shadow, - - sliderBackground, sliderButton, + + sliderBackground, + sliderButton, // info card colors (in route selector, favorites sheet, etc.) - infoCardColor, infoCardHighlighted, + infoCardColor, + infoCardHighlighted, // all the buttons except for the main map buttons - importantButtonBackground, importantButtonText, - secondaryButtonBackground, secondaryButtonText, + importantButtonBackground, + importantButtonText, + secondaryButtonBackground, + secondaryButtonText, + + mapWalkingLine, // Color for the walking line on the map + navigationStepsGray } const Map lightColors = { @@ -96,24 +124,29 @@ const Map lightColors = { ColorType.secondary: Color.fromARGB(255, 226, 231, 236), ColorType.opposite: Colors.black, ColorType.background: Colors.white, - ColorType.backgroundGradientStart: Color.fromARGB(0, 255, 255, 255), // same as background but transparent - - ColorType.mapButtonPrimary: maizeBusBlue, + ColorType.backgroundGradientStart: Color.fromARGB( + 0, + 255, + 255, + 255, + ), // same as background but transparent + + ColorType.mapButtonPrimary: Color.fromARGB(255, 11, 83, 148), ColorType.mapButtonSecondary: Color.fromARGB(190, 255, 255, 255), ColorType.mapButtonIcon: Colors.white, - ColorType.mapButtonShadow: Color.fromARGB(77, 133, 133, 133), + ColorType.mapButtonShadow: Color.fromARGB(77, 133, 133, 133), ColorType.highlighted: Color.fromARGB(255, 120, 192, 255), ColorType.dim: Color.fromARGB(255, 215, 228, 241), ColorType.error: Color.fromARGB(255, 242, 41, 41), ColorType.shadow: Color.fromARGB(95, 187, 187, 187), - + ColorType.sliderButton: Colors.white, - ColorType.sliderBackground: Color.fromARGB(255, 200, 228, 255), + ColorType.sliderBackground: Color.fromARGB(255, 200, 228, 255), - ColorType.infoCardColor: Color.fromARGB(255, 255, 255, 255), - ColorType.infoCardHighlighted: Color.fromARGB(255, 200, 228, 255), + ColorType.infoCardColor: Color.fromARGB(255, 255, 255, 255), + ColorType.infoCardHighlighted: Color.fromARGB(255, 200, 228, 255), ColorType.inputBackground: Color.fromARGB(255, 227, 227, 227), ColorType.inputText: Colors.black, @@ -122,6 +155,9 @@ const Map lightColors = { ColorType.importantButtonText: Colors.white, ColorType.secondaryButtonBackground: Color.fromARGB(255, 215, 228, 241), ColorType.secondaryButtonText: maizeBusBlue, + + ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97), + ColorType.navigationStepsGray: Color.fromARGB(255, 219, 228, 237) }; const Map darkColors = { @@ -129,32 +165,40 @@ const Map darkColors = { ColorType.secondary: Color.fromARGB(255, 40, 54, 72), ColorType.opposite: Colors.white, ColorType.background: Color.fromARGB(255, 32, 33, 34), - ColorType.backgroundGradientStart: Color.fromARGB(0, 32, 33, 34), // same as background but transparent + ColorType.backgroundGradientStart: Color.fromARGB( + 0, + 32, + 33, + 34, + ), // same as background but transparent ColorType.mapButtonPrimary: Color.fromARGB(255, 255, 255, 255), ColorType.mapButtonSecondary: Color.fromARGB(187, 104, 104, 134), ColorType.mapButtonIcon: maizeBusBlue, - ColorType.mapButtonShadow: Color.fromARGB(95, 68, 68, 68), + ColorType.mapButtonShadow: Color.fromARGB(95, 68, 68, 68), ColorType.highlighted: Color.fromARGB(255, 49, 129, 199), ColorType.dim: Color.fromARGB(255, 47, 54, 60), ColorType.error: Color.fromARGB(255, 255, 114, 114), ColorType.shadow: Color.fromARGB(95, 68, 68, 68), - + ColorType.sliderButton: Color.fromARGB(255, 32, 33, 34), ColorType.sliderBackground: Color.fromARGB(255, 33, 71, 105), ColorType.infoCardColor: Color.fromARGB(255, 47, 54, 60), ColorType.infoCardHighlighted: Color.fromARGB(255, 33, 71, 105), - ColorType.inputBackground:Color.fromARGB(255, 47, 54, 60), + ColorType.inputBackground: Color.fromARGB(255, 47, 54, 60), ColorType.inputText: Colors.white, ColorType.importantButtonBackground: Color.fromARGB(255, 49, 129, 199), ColorType.importantButtonText: Colors.white, ColorType.secondaryButtonBackground: Color.fromARGB(255, 47, 54, 60), ColorType.secondaryButtonText: Color.fromARGB(255, 49, 129, 199), + + ColorType.mapWalkingLine: Color.fromARGB(255, 178, 219, 255), + ColorType.navigationStepsGray: Color.fromARGB(255, 219, 228, 237) }; // returns true if the current theme is dark mode @@ -171,6 +215,12 @@ Color getColor(BuildContext context, ColorType type) { return isDarkMode(context) ? darkColors[type]! : lightColors[type]!; } +/// Convert a Color to a BitmapDescriptor hue value +double colorToHue(Color color) { + final hsl = HSLColor.fromColor(color); + return hsl.hue; +} + BoxShadow infoCardShadowLight = BoxShadow( color: Color.fromARGB(80, 38, 114, 181), blurRadius: 5, @@ -184,7 +234,7 @@ BoxShadow infoCardShadowDark = BoxShadow( offset: Offset(0, 3), ); -// Gets the correct shadow depending on +// Gets the correct shadow depending on BoxShadow getInfoCardShadow(BuildContext context) { return isDarkMode(context) ? infoCardShadowDark : infoCardShadowLight; } @@ -199,12 +249,12 @@ Color getGradientLerpColor(BuildContext context, double percentage) { return Color.lerp( getColor(context, ColorType.backgroundGradientStart), getColor(context, ColorType.background), - percentage + percentage, )!; } LinearGradient getStopHeroImageGradient(BuildContext context) { - // A slightly smoother gradient than sRGB + // A slightly smoother gradient than sRGB return LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -213,13 +263,13 @@ LinearGradient getStopHeroImageGradient(BuildContext context) { getGradientLerpColor(context, 0.15), getGradientLerpColor(context, 0.5), getGradientLerpColor(context, 0.75), - getGradientLerpColor(context, 1) + getGradientLerpColor(context, 1), ], // original stops by Isaac // stops: [0.6, 0.65, 0.74, 0.85, 1] // adjusted stops by Ishan - using the same ratios but less tall - stops: [0.67, 0.71, 0.79, 0.88, 1] + stops: [0.67, 0.71, 0.79, 0.88, 1], ); } @@ -229,38 +279,39 @@ class TrapezoidClip extends CustomClipper { @override Path getClip(Size size) { Path path = Path(); - path.lineTo(size.width, 0); - path.lineTo(size.width - size.height, size.height); + path.lineTo(size.width, 0); + path.lineTo(size.width - size.height, size.height); path.lineTo(0, size.height); - path.close(); - return path; + path.close(); + return path; } + @override bool shouldReclip(CustomClipper oldClipper) { - return false; + return false; } } + class TrapezoidClipReversed extends CustomClipper { @override Path getClip(Size size) { Path path = Path(); - path.moveTo(size.width, 0); - path.lineTo(size.width, size.height); + path.moveTo(size.width, 0); + path.lineTo(size.width, size.height); path.lineTo(0, size.height); path.lineTo(size.height, 0); - path.close(); - return path; + path.close(); + return path; } + @override bool shouldReclip(CustomClipper oldClipper) { - return false; + return false; } } // TEXT -enum TextType { - modalHeader, logo, bold, normal, small, sectionHeader -} +enum TextType { modalHeader, logo, bold, normal, small, sectionHeader } TextStyle getTextStyle(TextType type, Color? color) { double size, height; @@ -291,7 +342,13 @@ TextStyle getTextStyle(TextType type, Color? color) { weight = FontWeight.w700; height = 26.4; } - return TextStyle(color: color, fontFamily: 'Urbanist', fontSize: size, fontWeight: weight, height: height / size); + return TextStyle( + color: color, + fontFamily: 'Urbanist', + fontSize: size, + fontWeight: weight, + height: height / size, + ); } // THEMES @@ -304,15 +361,13 @@ ThemeData lightMode = ThemeData( // Default button themes floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: lightColors[ColorType.mapButtonPrimary], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(56), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(56)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: lightColors[ColorType.mapButtonPrimary], - ) + ), ), dividerTheme: DividerThemeData( @@ -322,43 +377,35 @@ ThemeData lightMode = ThemeData( // set default text color textTheme: TextTheme( - bodyMedium: TextStyle( - color: Colors.black, - fontFamily: 'Urbanist' - ) - ) + bodyMedium: TextStyle(color: Colors.black, fontFamily: 'Urbanist'), + ), ); ThemeData darkMode = ThemeData( brightness: Brightness.dark, fontFamily: 'Urbanist', - + // Default button themes floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: darkColors[ColorType.mapButtonPrimary], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(56), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(56)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: darkColors[ColorType.mapButtonPrimary], - ) + ), ), - + dividerTheme: DividerThemeData( thickness: 2, color: darkColors[ColorType.dim], ), - + // set default text color textTheme: TextTheme( - bodyMedium: TextStyle( - color: Colors.white, - fontFamily: 'Urbanist' - ) - ) + bodyMedium: TextStyle(color: Colors.white, fontFamily: 'Urbanist'), + ), ); //data types @@ -387,17 +434,15 @@ class Location { class ArrivalTimeLocation extends Location { final String arrivalTime; - ArrivalTimeLocation( - this.arrivalTime, - Location loc, - ) : super( - loc.name, - loc.abbrev, - loc.aliases, - loc.isBusStop, - stopId: loc.stopId, - latlng: loc.latlng, - ); + ArrivalTimeLocation(this.arrivalTime, Location loc) + : super( + loc.name, + loc.abbrev, + loc.aliases, + loc.isBusStop, + stopId: loc.stopId, + latlng: loc.latlng, + ); } class StartupDataHolder { @@ -406,7 +451,13 @@ class StartupDataHolder { String updateMessage; String persistantMessageTitle; String persistantMessage; - StartupDataHolder(this.version, this.updateTitle, this.updateMessage, this.persistantMessageTitle, this.persistantMessage); + StartupDataHolder( + this.version, + this.updateTitle, + this.updateMessage, + this.persistantMessageTitle, + this.persistantMessage, + ); } class Loadpoint { @@ -417,10 +468,7 @@ class Loadpoint { const SheetBoxShadow = BoxShadow( color: Color.fromRGBO(0, 0, 0, 0.2), - offset: const Offset( - 0.0, - 0.0, - ), + offset: const Offset(0.0, 0.0), blurRadius: 100.0, spreadRadius: 40.0, ); diff --git a/lib/globals.dart b/lib/globals.dart index 87e317a..faf6c4f 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -3,6 +3,9 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; List globalStopLocs = []; +double globalFollowDistanceThresholdMeters = 8.0; +int globalGpsUpdateDistanceFilterMeters = 5; + // the global app padding // don't modify these here, instead use the helper function in map_screen.dart that sets these based on phone type and safe area insets bool globallPaddingHasBeenSet = false; @@ -10,6 +13,10 @@ double globalBottomPadding = 0; double globalTopPadding = 0; double globalLeftRightPadding = 0; +// the physical corner radius of the screen's bottom corners, loaded by map_screen.dart +// use this to keep rounded UI at the bottom of the screen concentric with the phone +double globalScreenBottomRadius = 0; + // helper function String getStopNameFromID (String id){ if (id == "VIRTUAL_DESTINATION"){ diff --git a/lib/main.dart b/lib/main.dart index 4d99735..cd3ca4f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'screens/onboarding_screen.dart'; import 'services/bus_repository.dart'; import 'providers/bus_provider.dart'; import 'providers/theme_provider.dart'; +import 'package:flutter/services.dart'; + // This function initializes the Flutter app and runs the MainApp widget void main() async { @@ -15,6 +17,15 @@ void main() async { await NotificationService.initPlugin(); await IncomingBusReminderService.start(); + // make navigation bar transparent. Looks really nice on Android + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + ), + ); + // make flutter draw behind navigation bar + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + runApp( MultiProvider( providers: [ @@ -40,6 +51,7 @@ class MainApp extends StatelessWidget { return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, ), child: Consumer( // rebuilds when ThemeProvider changes builder: (context, themeObj, child) => MaterialApp( diff --git a/lib/models/bus_route_line.dart b/lib/models/bus_route_line.dart index 0bdee50..ee3266c 100644 --- a/lib/models/bus_route_line.dart +++ b/lib/models/bus_route_line.dart @@ -5,15 +5,18 @@ import 'bus_stop.dart'; class BusRouteLine { final String routeId; final List points; - final List stops; + + /// bus stops along with the index of the associated point + // INVARIANT: indicies are in ascending order + final List<(int, BusStop)> stops; final Color? color; final String? imageUrl; BusRouteLine({ - required this.routeId, - required this.points, + required this.routeId, + required this.points, required this.stops, this.color, this.imageUrl, }); -} \ No newline at end of file +} diff --git a/lib/models/bus_stop.dart b/lib/models/bus_stop.dart index 6bad99d..5e46c0a 100644 --- a/lib/models/bus_stop.dart +++ b/lib/models/bus_stop.dart @@ -1,4 +1,5 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; +import '../constants.dart'; class BusStop { final String id; @@ -13,7 +14,7 @@ class BusStop { factory BusStop.fromJson(Map json, String routeId, double rotation, bool isRide) { return BusStop( id: json['stpid'] ?? '', - name: json['stpnm'] ?? '', + name: normalizeStopName(json['stpnm'] ?? ''), location: LatLng(json['lat']?.toDouble() ?? 0, json['lon']?.toDouble() ?? 0), routeId: routeId, rotation: rotation, @@ -33,7 +34,7 @@ class BusStopWithPrediction { factory BusStopWithPrediction.fromJson(Map json) { return BusStopWithPrediction( id: json['stpid'] ?? '', - name: json['stpnm'] ?? '', + name: normalizeStopName(json['stpnm'] ?? ''), prediction: json['prdctdn'] as String, busRouteCode: json['rt'] ?? '' ); @@ -52,10 +53,10 @@ class BusWithPrediction { factory BusWithPrediction.fromJson(Map json) { return BusWithPrediction( id: json['rt'] ?? '', - destination: json['des'] ?? '', + destination: normalizeStopName(json['des'] ?? ''), prediction: json['prdctdn'] as String, direction: json['rtdir'] as String, vehicleId: json['vid'] ?? 'none' ); } -} \ No newline at end of file +} diff --git a/lib/models/floorplan.dart b/lib/models/floorplan.dart new file mode 100644 index 0000000..21f8353 --- /dev/null +++ b/lib/models/floorplan.dart @@ -0,0 +1,270 @@ +import 'dart:ui' show Offset; + +/// Data model for an indoor floorplan. +/// +/// Coordinates in this file are *plan pixels* -- the arbitrary coordinate space +/// the floorplan was authored in. They are NOT latitude/longitude. Use a +/// [FloorplanProjection] to convert them into real world coordinates before +/// handing them to the map. + +/// Convenience for the `[x, y]` pairs the floorplan JSON uses everywhere. +Offset _pointFromJson(dynamic json) { + final pair = json as List; + return Offset((pair[0] as num).toDouble(), (pair[1] as num).toDouble()); +} + +List _polygonFromJson(dynamic json) => + (json as List? ?? const []).map(_pointFromJson).toList(); + +/// POI and room `type` values we care about. These come from the backend as +/// free-form strings, so treat this as a list of known values rather than an +/// exhaustive one -- anything unrecognized should degrade gracefully. +abstract final class FloorplanTypes { + static const String room = 'room'; + static const String door = 'door'; + static const String elevator = 'elevator'; + static const String escalator = 'escalator'; + static const String stairway = 'stairway'; + static const String inaccessible = 'inaccessible'; + static const String information = 'information'; + static const String food = 'food'; + static const String vendingMachine = 'vending_machine'; + static const String femaleBathroom = 'female_bathroom'; + static const String maleBathroom = 'male_bathroom'; + static const String neutralBathroom = 'neutral_bathroom'; + + /// The two georeferencing markers a floor carries so we can line its plan + /// pixels up with the real world. See [FloorplanProjection]. + static const String waypoint1 = 'waypoint1'; + static const String waypoint2 = 'waypoint2'; +} + +/// One building's floorplan, covering every floor we have data for. +class Floorplan { + final String building; + final List floors; + + const Floorplan({required this.building, required this.floors}); + + factory Floorplan.fromJson(Map json) => Floorplan( + building: json['building'] as String? ?? 'Unknown Building', + floors: (json['floors'] as List? ?? const []) + .map((floor) => FloorplanFloor.fromJson(floor as Map)) + .toList(), + ); +} + +/// A single floor: everything needed to draw it and (eventually) route through it. +class FloorplanFloor { + final String id; + final String name; + + /// Plan pixels per real-world meter. Kept for reference; the drawn scale is + /// derived from the waypoints instead, since those are what we georeference against. + final double pxPerMeter; + final double width; + final double height; + + /// Individual wall segments, drawn as lines. + final List walls; + + /// Closed room polygons, drawn filled. + final List rooms; + + /// Points of interest: room labels, bathrooms, elevators, waypoints, etc. + final List pois; + + /// A single closed polygon tracing the outside of the whole building. + final List outline; + + /// Pathfinding graph. Not drawn -- incomplete in the current data. + final FloorplanNavGraph nav; + + const FloorplanFloor({ + required this.id, + required this.name, + required this.pxPerMeter, + required this.width, + required this.height, + required this.walls, + required this.rooms, + required this.pois, + required this.outline, + required this.nav, + }); + + factory FloorplanFloor.fromJson(Map json) => FloorplanFloor( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + pxPerMeter: (json['pxPerMeter'] as num?)?.toDouble() ?? 1.0, + width: (json['width'] as num?)?.toDouble() ?? 0.0, + height: (json['height'] as num?)?.toDouble() ?? 0.0, + walls: (json['walls'] as List? ?? const []) + .map((wall) => FloorplanWall.fromJson(wall as Map)) + .toList(), + rooms: (json['rooms'] as List? ?? const []) + .map((room) => FloorplanRoom.fromJson(room as Map)) + .toList(), + pois: (json['pois'] as List? ?? const []) + .map((poi) => FloorplanPoi.fromJson(poi as Map)) + .toList(), + outline: _polygonFromJson(json['outline']), + nav: FloorplanNavGraph.fromJson(json['nav'] as Map?), + ); + + /// The first POI with the given type, or null if this floor has none. + FloorplanPoi? findPoiByType(String type) { + for (final poi in pois) { + if (poi.type == type) return poi; + } + return null; + } + + /// The first number in the floor's name, e.g. "Floor 3" -> 3. + static final RegExp _numberInName = RegExp(r'\d+'); + + /// Where this floor sits in the building, used to order the floor picker. + /// + /// The data carries no explicit level, so this reads one out of [name]: + /// numbered floors use their number and basements count downward from the + /// ground. Best effort -- a name we can't read lands at 0. + int get level { + final Match? match = _numberInName.firstMatch(name); + final int? number = match == null ? null : int.parse(match[0]!); + if (_isBasement) return -(number ?? 1); + return number ?? 0; + } + + /// Short label for the floor picker, which only has room for a character or + /// two: "Floor 3" -> "3", "Basement" -> "B". + String get shortName { + if (_isBasement) return 'B'; + final Match? match = _numberInName.firstMatch(name); + if (match != null) return match[0]!; + return name.isEmpty ? '?' : name[0].toUpperCase(); + } + + bool get _isBasement => name.toLowerCase().startsWith('b'); +} + +/// A straight wall segment in plan pixels. +class FloorplanWall { + final Offset start; + final Offset end; + + const FloorplanWall({required this.start, required this.end}); + + factory FloorplanWall.fromJson(Map json) => FloorplanWall( + start: _pointFromJson(json['start']), + end: _pointFromJson(json['end']), + ); +} + +/// A closed room polygon in plan pixels. +class FloorplanRoom { + final String id; + final String name; + + /// One of [FloorplanTypes]; decides how the room is filled. + final String type; + final List polygon; + + /// The POI that labels this room, if any. + final String? poiId; + + const FloorplanRoom({ + required this.id, + required this.name, + required this.type, + required this.polygon, + required this.poiId, + }); + + factory FloorplanRoom.fromJson(Map json) => FloorplanRoom( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + type: json['type'] as String? ?? '', + polygon: _polygonFromJson(json['polygon']), + poiId: json['poiId'] as String?, + ); +} + +/// A point of interest in plan pixels. +class FloorplanPoi { + final String id; + final Offset position; + + /// One of [FloorplanTypes]; decides which label and/or icon we draw. + final String type; + final String? name; + final String? roomId; + final String? navNodeId; + + const FloorplanPoi({ + required this.id, + required this.position, + required this.type, + required this.name, + required this.roomId, + required this.navNodeId, + }); + + factory FloorplanPoi.fromJson(Map json) => FloorplanPoi( + id: json['id'] as String? ?? '', + position: Offset( + (json['x'] as num?)?.toDouble() ?? 0.0, + (json['y'] as num?)?.toDouble() ?? 0.0, + ), + type: json['type'] as String? ?? '', + name: json['name'] as String?, + roomId: json['roomId'] as String?, + navNodeId: json['navNodeId'] as String?, + ); +} + +/// The (currently incomplete) walkable graph used for indoor pathfinding. +class FloorplanNavGraph { + final List nodes; + final List edges; + + const FloorplanNavGraph({required this.nodes, required this.edges}); + + factory FloorplanNavGraph.fromJson(Map? json) => + FloorplanNavGraph( + nodes: (json?['nodes'] as List? ?? const []) + .map((node) => FloorplanNavNode.fromJson(node as Map)) + .toList(), + edges: (json?['edges'] as List? ?? const []) + .map((edge) => FloorplanNavEdge.fromJson(edge as Map)) + .toList(), + ); +} + +class FloorplanNavNode { + final String id; + final Offset position; + + const FloorplanNavNode({required this.id, required this.position}); + + factory FloorplanNavNode.fromJson(Map json) => + FloorplanNavNode( + id: json['id'] as String? ?? '', + position: Offset( + (json['x'] as num?)?.toDouble() ?? 0.0, + (json['y'] as num?)?.toDouble() ?? 0.0, + ), + ); +} + +class FloorplanNavEdge { + final String from; + final String to; + + const FloorplanNavEdge({required this.from, required this.to}); + + factory FloorplanNavEdge.fromJson(Map json) => + FloorplanNavEdge( + from: json['from'] as String? ?? '', + to: json['to'] as String? ?? '', + ); +} diff --git a/lib/models/journey.dart b/lib/models/journey.dart index 1742bb1..3dba255 100644 --- a/lib/models/journey.dart +++ b/lib/models/journey.dart @@ -16,6 +16,9 @@ class Journey { } } +enum LegMode { walk, bus } + +// I really want to turn this into a sum type... (sealed class + two subclasses) class Leg { final String origin; final String destination; @@ -28,6 +31,8 @@ class Leg { final String originID; final String destinationID; final List? pathCoords; + final Map? directions; + final LegMode mode; Leg({ required this.origin, @@ -41,6 +46,8 @@ class Leg { required this.originID, required this.destinationID, this.pathCoords, + this.directions, + required this.mode }); factory Leg.fromJson(Map json) { @@ -66,10 +73,21 @@ class Leg { ); }).toList() : null, + directions: json['directions'] != null ? + { + for (var x in json['directions'] as List) + (x['path_index'] as num).toInt(): + (degree: (x['turn']['degrees'] as num).toDouble(), + landmark: x['turn']['landmark'] as String) + } + : null, + mode: (json['mode'] == "bus" ? LegMode.bus : LegMode.walk) ); } } +typedef Turn = ({double degree, String landmark}); + class StopTime { final String stop; final int arrivalTime; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 41fa536..26bc22d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,18 +1,30 @@ import 'dart:io' show Platform; import 'dart:async'; import 'dart:convert'; -import 'dart:math' as Math; import 'dart:ui' as ui; -import 'dart:math' as math; +import 'dart:developer'; import 'package:bluebus/globals.dart'; +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:bluebus/models/bus_stop.dart'; import 'package:bluebus/providers/theme_provider.dart'; import 'package:bluebus/screens/new_features_screen.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/map_layers/base_routes_layer.dart'; +import 'package:bluebus/services/map_layers/demo_buildings_layer.dart'; // DEMO BUILDINGS +import 'package:bluebus/services/map_layers/floorplans_layer.dart'; +import 'package:bluebus/services/map_layers/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_layer.dart'; +import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:bluebus/services/navigation/navigation_manager.dart'; import 'package:bluebus/widgets/building_sheet.dart'; import 'package:bluebus/widgets/bus_sheet.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; import 'package:bluebus/widgets/dialog.dart'; import 'package:bluebus/widgets/directions_sheet.dart'; +import 'package:bluebus/widgets/floorplan_overlay_widget.dart'; import 'package:bluebus/widgets/journey_results_widget.dart'; import 'package:bluebus/widgets/loading_screen.dart'; +import 'package:bluebus/widgets/navigation_overlay_widget.dart'; import 'package:bluebus/widgets/reminder_widgets.dart'; import 'package:bluebus/widgets/search_sheet_main.dart'; import 'package:bluebus/widgets/stop_sheet.dart'; @@ -26,6 +38,7 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:vector_math/vector_math_64.dart' as vec_math; import '../widgets/map_widget.dart'; import '../widgets/route_selector_modal.dart'; import '../widgets/favorites_sheet.dart'; @@ -38,47 +51,11 @@ import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; -//import 'dart:convert'; +import 'package:screen_corner_radius/screen_corner_radius.dart'; final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-16 00:00:00Z"); final NEW_BUTTON_HIDE_TIME = DateTime.parse("2026-03-24 00:00:00Z"); -// Function to calculate rotation angle between two geographical points -// (used for bus stop icon orientation) -double pointRotation(double lat1, double lon1, double lat2, double lon2) { - const double degToRad = 0.017453292519943295; // π / 180 - const double radToDeg = 57.29577951308232; // 180 / π - - double dLat = lat2 - lat1; - double dLon = lon2 - lon1; - - // Scale longitude by cos(lat) to correct for east-west distance - double x = dLon * (Math.cos(lat1 * degToRad)); - double y = dLat; - - double angle = Math.atan2(x, y) * radToDeg; - - // Normalize to [0, 360) - if (angle < 0) angle += 360; - - return angle; -} - -Future resizeImage(ByteData image) async { - // Load and resize stop icon - final stopBytes = image; - final stopCodec = await ui.instantiateImageCodec( - stopBytes.buffer.asUint8List(), - targetWidth: 65, - targetHeight: 65, - ); - final stopFrame = await stopCodec.getNextFrame(); - final stopData = await stopFrame.image.toByteData( - format: ui.ImageByteFormat.png, - ); - return BitmapDescriptor.fromBytes(stopData!.buffer.asUint8List()); -} - class MaizeBusCore extends StatefulWidget { const MaizeBusCore({super.key}); @@ -87,20 +64,35 @@ class MaizeBusCore extends StatefulWidget { } class _MaizeBusCoreState extends State { - late bool canVibrate; + late bool canVibrate = false; late Journey currDisplayed; + ScreenRadius? screenRadius; + bool screenRadiusLoaded = false; + StreamSubscription? _posSub; + // TODO: Follow-mode state. When true, the map recenters on location updates. + Position? _lastCenteredPos; + final ValueNotifier _userHasInteractedWithMap = ValueNotifier( + false, + ); + bool _isProgrammaticCameraMove = true; + + bool _followUser = true; + NavigationManager navigationManager = NavigationManager(); Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( Loadpoint("Initializing...", 0), ); GoogleMapController? _mapController; - CameraPosition? _currentCameraPos; + final ValueNotifier _currentCameraPos = + ValueNotifier(null); bool? _userLocVisible; - static const LatLng _defaultCenter = LatLng(42.276463, -83.7374598); + static const _defaultCenter = LatLng(42.276463, -83.7374598); + static LatLng startLatLng = _defaultCenter; Set _displayedPolylines = {}; - Set _displayedStopMarkers = {}; + Map _displayedStopMarkers = {}; // maps from stopID to marker + Map _displayedFavoriteStopMarkers = {}; Set _displayedBusMarkers = {}; // Journey overlays for search results Set _displayedJourneyPolylines = {}; @@ -113,12 +105,16 @@ class _MaizeBusCoreState extends State { // Union of _displayedStopMarkers, _displayedBusMarkers, _displayedJourneyMarkers, // and _searchLocationMarker. Stored here so build() has better performance + // In memory cache of favorited stop ids for quick lookup and immediate UI updates + final Set _favoriteStops = {}; + Marker? _searchLocationMarker; final Set _selectedRoutes = {}; List> _availableRoutes = []; + Map _stopIsRide = {}; // Custom marker icons - BitmapDescriptor? _busIcon; + // BitmapDescriptor? _busIcon; BitmapDescriptor? _stopIcon; BitmapDescriptor? _rideStopIcon; BitmapDescriptor? _favStopIcon; @@ -126,16 +122,19 @@ class _MaizeBusCoreState extends State { BitmapDescriptor? _getOn; BitmapDescriptor? _getOff; - // Route specific bus icons - final Map _routeBusIcons = {}; + // // Route specific bus icons + // final Map _routeBusIcons = {}; // Memoization caches final Map _routePolylines = {}; - final Map> _routeStopMarkers = {}; + final Map> _routeStopMarkers = + {}; // maps from route to a map of stopID to marker // Whether a journey search overlay is currently active (shows only journey path) bool _journeyOverlayActive = false; + bool _navigationOverlayEnabled = false; + bool _floorplanOverlayEnabled = false; // maximum allowed distance (meters) from a stop to a candidate polyline point - static const double _maxMatchDistanceMeters = 150.0; + // static const double _maxMatchDistanceMeters = 150.0; // route ids that are part of the active journey final Set _activeJourneyBusIds = {}; // route ids of routes used in the active journey @@ -154,6 +153,13 @@ class _MaizeBusCoreState extends State { // store persistent bottom sheet controller PersistentBottomSheetController? _bottomSheetController; + final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); + final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); + final JourneyLayer journeyLayer = JourneyLayer(); + final NavigationLayer navigationLayer = NavigationLayer(); + final FloorplansLayer floorplansLayer = FloorplansLayer(); + final DemoBuildingsLayer demoBuildingsLayer = DemoBuildingsLayer(); // DEMO BUILDINGS + // GoogleMaps styles String _darkMapStyle = "{}"; String _lightMapStyle = "{}"; @@ -170,16 +176,45 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); + // debugPrint("MAP SCREEN INITSTATE==================="); + navigationManager.init(); + + baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); + floorplansLayer.load(); + journeyLayer.init( + _showBusSheet, + _activeJourneyBusIds, + _activeJourneyRoutes, + context, + ); + + navigationManager.setMapLayer(navigationLayer); + navigationLayer.init(); + navigationLayer.isVisible = + false; // Hide the navigation layer until we're ready to show it + + hideJourney(); // Hide the journey layer until we're ready to use it + WidgetsBinding.instance.addPostFrameCallback((_) { try { _busProviderRef = Provider.of(context, listen: false); _busProviderListener = () { + liveBusesLayer.init( + _busProviderRef?.buses ?? [], + _selectedRoutes, + onBusClicked, + ); // TODO: Should this init be somewhere else? I need it to have access to the busProvider I think + final routes = _busProviderRef?.routes ?? []; final newFp = _computeRoutesFingerprint(routes); if (newFp != _routesFingerprint) { _routesFingerprint = newFp; _handleRoutesUpdated(routes); } + + if (_busProviderRef!.buses.isNotEmpty) { + _updateDisplayedBuses(_busProviderRef!.buses); + } }; _busProviderRef?.addListener(_busProviderListener!); } catch (e, stackTrace) { @@ -191,6 +226,25 @@ class _MaizeBusCoreState extends State { }); } + void onStopClicked(BusStop stop) { + try { + Haptics.vibrate(HapticsType.light); + } catch (e) { + debugPrint("Haptics error: $e"); + } + + _showStopSheet( + stop.id, + stop.name, + stop.location.latitude, + stop.location.longitude, + ); + } + + void onBusClicked(Bus b) { + _showBusSheet(b.id); + } + Future _setupConnectivityMonitoring() async { final connectivity = Connectivity(); @@ -237,7 +291,35 @@ class _MaizeBusCoreState extends State { Future _loadAllData() async { ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); - await theme.loadTheme(); // load user theme data + await theme.loadTheme(); + + final prefs = await SharedPreferences.getInstance(); + globalFollowDistanceThresholdMeters = + prefs.getDouble('follow_distance_threshold_meters') ?? + globalFollowDistanceThresholdMeters; + globalGpsUpdateDistanceFilterMeters = + prefs.getInt('gps_update_distance_filter_meters') ?? + globalGpsUpdateDistanceFilterMeters; + + screenRadius = await ScreenCornerRadius.get(); // load screen radius + screenRadiusLoaded = true; + globalScreenBottomRadius = screenRadius?.bottomLeft ?? 0; + + //Trying to find the location of the user to set initial position. If not found, defaults to _defaultCenter + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.whileInUse || + permission == LocationPermission.always) { + // permission = await Geolocator.requestPermission(); + Position? pos = await Geolocator.getLastKnownPosition(); + if (pos != null) { + startLatLng = LatLng(pos.latitude, pos.longitude); + _currentCameraPos.value = CameraPosition( + target: startLatLng, + zoom: 15.0, + bearing: 0.0, + ); + } + } canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -268,21 +350,21 @@ class _MaizeBusCoreState extends State { if (startupData.persistantMessageTitle != '') { showMaizebusOKDialog( contextIn: context, - title: Text(startupData.persistantMessageTitle), - content: Text(startupData.persistantMessage), + title: startupData.persistantMessageTitle, + content: startupData.persistantMessage, ); } - void onBusError(String route, String error) => - showMaizebusOKDialog( - contextIn: context, - title: Text("Error loading route $route. We are aware of the issue, and it will be fixed shortly."), - content: Text(error) - ); + void onBusError(String route, String error) => showMaizebusOKDialog( + contextIn: context, + title: + "Error loading route $route. We are aware of the issue, and it will be fixed shortly.", + content: error, + ); // loading all this data in parallel await Future.wait([ - _loadCustomMarkers(), + // _loadCustomMarkers(), busProvider.loadRoutes(onBusError), _loadSelectedRoutes(), _loadFavoriteStops(), @@ -290,10 +372,14 @@ class _MaizeBusCoreState extends State { // actions that depend on the data loaded earlier _loadingMessageNotifier.value = Loadpoint('Loading bus images...', 2); - await _loadRouteSpecificBusIcons(); + await MapImageService.loadData(); + // await _loadRouteSpecificBusIcons(); _updateAvailableRoutes(busProvider.routes); _cacheRouteOverlays(busProvider.routes); + debugPrint("******* Caching routes"); + baseRoutesLayer.cacheRoutes(busProvider.routes); + // update the map with previously selected routes. if (_selectedRoutes.isNotEmpty) { _updateDisplayedRoutes(); @@ -312,9 +398,124 @@ class _MaizeBusCoreState extends State { _loadingMessageNotifier.value = Loadpoint('Starting app...', 5); busProvider.startBusUpdates(); busProvider.startRouteUpdates(); + // Start location updates in the background so startup doesn't block on + // permission dialogs or stream initialization. + startLocationUpdates(); await Future.delayed(const Duration(milliseconds: 180)); } + Future startLocationUpdates() async { + if (!await Geolocator.isLocationServiceEnabled()) return; + + LocationPermission perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.deniedForever) return; + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + } + if (perm != LocationPermission.whileInUse && + perm != LocationPermission.always) { + return; + } + + await _posSub?.cancel(); + + final settings = LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: globalGpsUpdateDistanceFilterMeters, + ); + var isFirstLocationUpdate = true; + + _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( + Position p, + ) async { + navigationManager.receiveLocationUpdate(p); + // log("Received location update: ${p.latitude}, ${p.longitude}"); + if (isFirstLocationUpdate) { + isFirstLocationUpdate = false; + // log("Ignoring first location update"); + return; + } + + // Keep this lightweight; do a minimal amount of work here and defer heavy updates. + if (!mounted || _mapController == null) { + if (!mounted) log("Ignoring location update: widget not mounted"); + if (_mapController == null) + // log("Ignoring location update: map controller not initialized"); + return; + } + + // If follow mode is disabled, don't recenter automatically. + if (!_followUser) return; + if (_userHasInteractedWithMap.value) { + // log("Ignoring location update: user has interacted with map"); + return; + } + final lastCentered = _lastCenteredPos; + + final cameraTarget = _currentCameraPos.value?.target; + + // Only move camera if user has moved more than threshold to avoid jitter. + final shouldMove = + lastCentered == null || + Geolocator.distanceBetween( + lastCentered.latitude, + lastCentered.longitude, + p.latitude, + p.longitude, + ) > + globalFollowDistanceThresholdMeters; + + final userMoved = lastCentered == null + ? false + : cameraTarget != null && + Geolocator.distanceBetween( + lastCentered.latitude, + lastCentered.longitude, + cameraTarget.latitude, + cameraTarget.longitude, + ) == + 0; + if (cameraTarget == null) { + // log("null camera target"); + return; + } + + if (shouldMove && !userMoved) { + // log("Centering map on new location: ${p.latitude}, ${p.longitude}"); + } else { + // log("Ignoring location update: ${p.latitude}, ${p.longitude}"); + // log("Camera Position: ${cameraTarget.latitude}, ${cameraTarget.longitude}",); + } + if (!(shouldMove && !userMoved)) return; + + _lastCenteredPos = p; + + // Center on the new streamed position while preserving the current camera view. + await _centerOnLocation( + false, + lat: p.latitude, + long: p.longitude, + zoom: _currentCameraPos.value?.zoom, + bearing: _currentCameraPos.value?.bearing, + ); + + // TODO: Update any navigation manager / UI that depends on live position here. + }); + + // TODO: Consider throttling updates or using a timer if animateCamera is too frequent. + } + + // Call to programmatically enable/disable follow mode. Wire this to your location FAB. + void _setFollowMode(bool enabled) { + setState(() { + _followUser = enabled; + if (!enabled) return; + // When enabling follow mode, reset last-centered so next position recenters immediately. + _lastCenteredPos = null; + _userHasInteractedWithMap.value = false; + }); + } + // need this to make sure that the stop names exist in the cache Future _loadStopsForLaunch() async { // LOADS BOTH STOP TYPES @@ -336,7 +537,7 @@ class _MaizeBusCoreState extends State { final stopList = jsonDecode(response.body) as List; return stopList.map((stop) { - final name = stop['name'] as String; + final name = normalizeStopName(stop['name'] as String); final aliases = [ name.split(' ').map((w) => w.isNotEmpty ? w[0] : '').join(), ]; @@ -399,126 +600,6 @@ class _MaizeBusCoreState extends State { ); } - Future _loadCustomMarkers() async { - try { - // Load stop icons - _stopIcon = await resizeImage( - await rootBundle.load('assets/busStop.png'), - ); - _rideStopIcon = await resizeImage( - await rootBundle.load('assets/busStopRide.png'), - ); - _favStopIcon = await resizeImage( - await rootBundle.load('assets/favbusStop.png'), - ); - _favRideStopIcon = await resizeImage( - await rootBundle.load('assets/favbusStopRide.png'), - ); - _getOn = await resizeImage(await rootBundle.load('assets/getOn.png')); - _getOff = await resizeImage(await rootBundle.load('assets/getOff.png')); - - // Load route specific bus icons - await _loadRouteSpecificBusIcons(); - - // Refresh markers with new icons - if (mounted) { - _refreshAllMarkers(); - } - } catch (e) { - // Fallback to default markers if custom loading fails - _stopIcon = BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ); - _rideStopIcon = BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ); - _favStopIcon = BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ); - _favRideStopIcon = BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ); - } - } - - // Load route specific bus icons from the backend - Future _loadRouteSpecificBusIcons() async { - try { - if (!RouteColorService.isInitialized) { - await RouteColorService.initialize(); - } - - // Check if we need to update cached assets based on version - final shouldRefreshAssets = await _shouldRefreshCachedAssets(); - - final routeIds = RouteColorService.definedRouteIds; - - for (final routeId in routeIds) { - // Try to load from cache first if not forcing refresh - if (!shouldRefreshAssets) { - final cachedIcon = await _loadCachedBusIcon(routeId); - if (cachedIcon != null) { - _routeBusIcons[routeId] = cachedIcon; - continue; - } - } - - // Load from backend if cache miss or forcing refresh - final imageUrl = RouteColorService.getRouteImageUrl(routeId); - if (imageUrl != null) { - await _loadRouteBusIcon(routeId, imageUrl); - } else { - _setFallbackBusIcon(routeId); - } - } - } catch (e) { - // Fallback to default bus icon - _busIcon = BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueYellow, - ); - } - } - - Future getFrontEndImageVer() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - - final int counter = prefs.getInt('imageVer') ?? 0; - - // if null, save the default value - if (prefs.getInt('imageVer') == null) { - await prefs.setInt('imageVer', counter); - } - - return counter; - } - - Future setFrontEndImageVer(int a) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt('imageVer', a); - } - - // Check if cached assets need to be refreshed based on backend version - Future _shouldRefreshCachedAssets() async { - int frontEndVer; - frontEndVer = await getFrontEndImageVer(); - - try { - final backendImageVersion = await _getBackendImageVersion(); - if (backendImageVersion == null) { - return true; // if you can't reach the server give up - } - if (int.parse(backendImageVersion) == frontEndVer) { - return false; - } else { - await setFrontEndImageVer(int.parse(backendImageVersion)); - return true; - } - } catch (e) { - // On error, assume refresh needed - return true; - } - } - // Get minimum supported version from backend Future _getStartupData() async { try { @@ -549,107 +630,6 @@ class _MaizeBusCoreState extends State { return null; } - // Get minimum supported version from backend - Future _getBackendImageVersion() async { - try { - final response = await http.get( - Uri.parse('${BACKEND_URL}/getStartupInfo'), - ); - if (response.statusCode == 200) { - final data = json.decode(response.body); - return data['bus_image_version'] as String?; - } - } catch (e) { - // Return null on error - will trigger refresh - } - return null; - } - - // Load cached bus icon from SharedPreferences - Future _loadCachedBusIcon(String routeId) async { - try { - final prefs = await SharedPreferences.getInstance(); - final cachedBytes = prefs.getString('bus_icon_$routeId'); - if (cachedBytes != null) { - final bytes = base64.decode(cachedBytes); - return BitmapDescriptor.fromBytes(bytes); - } - } catch (e) { - // Return null on error - } - return null; - } - - // Save bus icon to cache - Future _cacheBusIcon(String routeId, Uint8List bytes) async { - try { - final prefs = await SharedPreferences.getInstance(); - final base64String = base64.encode(bytes); - await prefs.setString('bus_icon_$routeId', base64String); - } catch (e) { - // Ignore cache save errors - } - } - - // Load a specific route's bus icon - Future _loadRouteBusIcon(String routeId, String imageUrl) async { - try { - final response = await http.get(Uri.parse(imageUrl)); - - if (response.statusCode == 200) { - final imageBytes = response.bodyBytes; - - // Adjust bus icon size here - try { - final codec = await ui.instantiateImageCodec( - imageBytes, - targetWidth: 125, - targetHeight: 125, - ); - final frame = await codec.getNextFrame(); - final data = await frame.image.toByteData( - format: ui.ImageByteFormat.png, - ); - - if (data != null) { - final processedBytes = data.buffer.asUint8List(); - _routeBusIcons[routeId] = BitmapDescriptor.fromBytes( - processedBytes, - ); - - // Cache the processed icon for future use - await _cacheBusIcon(routeId, processedBytes); - } else { - _setFallbackBusIcon(routeId); - } - } catch (codecError) { - _setFallbackBusIcon(routeId); - } - } else { - // Set fallback icon for this route - _setFallbackBusIcon(routeId); - } - } catch (e) { - // Set fallback icon for this route - _setFallbackBusIcon(routeId); - } - } - - // Set a fallback bus icon for a route - void _setFallbackBusIcon(String routeId) { - try { - final routeColor = RouteColorService.getRouteColor(routeId); - _routeBusIcons[routeId] = BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(routeColor), - ); - } catch (e) { - // error handling - } - } - - // In memory cache of favorited stop ids for quick lookup and immediate UI updates - final Set _favoriteStops = {}; - Future _loadFavoriteStops() async { try { final prefs = await SharedPreferences.getInstance(); @@ -664,6 +644,8 @@ class _MaizeBusCoreState extends State { @override void dispose() { _loadingMessageNotifier.dispose(); + _currentCameraPos.dispose(); + _userHasInteractedWithMap.dispose(); _connectivitySubscription?.cancel(); Provider.of(context, listen: false).stopBusUpdates(); @@ -682,6 +664,17 @@ class _MaizeBusCoreState extends State { super.dispose(); } + void hideNavigation() { + _navigationOverlayEnabled = false; + + navigationLayer.isVisible = false; + journeyLayer.isVisible = false; + baseRoutesLayer.isVisible = true; + liveBusesLayer.isVisible = true; + + navigationLayer.reload(); + } + // Compute a lightweight fingerprint of the routes list to detect changes int _computeRoutesFingerprint(List routes) { int h = 1; @@ -702,6 +695,8 @@ class _MaizeBusCoreState extends State { .toSet(); final newRouteIds = routes.map((r) => r.routeId).toSet(); + journeyLayer.setRoutesCache(routes); + _routePolylines.removeWhere((key, _) { for (final id in newRouteIds) { if (key.startsWith('${id}_') && !newKeys.contains(key)) { @@ -738,13 +733,7 @@ class _MaizeBusCoreState extends State { final name = RouteColorService.getRouteName(r.routeId); routeIdToName[r.routeId] = name; - // Load bus icon for this route if not already loaded - if (!_routeBusIcons.containsKey(r.routeId)) { - final imageUrl = RouteColorService.getRouteImageUrl(r.routeId); - if (imageUrl != null) { - _loadRouteBusIcon(r.routeId, imageUrl); - } - } + MapImageService.ensureRouteIconIsLoaded(r.routeId); } } setState(() { @@ -771,51 +760,59 @@ class _MaizeBusCoreState extends State { ); } if (!_routeStopMarkers.containsKey(routeKey)) { - _routeStopMarkers[routeKey] = r.stops - .map( - (stop) => Marker( - markerId: MarkerId( - 'stop_${stop.id}_${Object.hashAll(r.points)}', - ), - position: stop.location, - flat: true, - icon: _favoriteStops.contains(stop.id) - ? (stop.isRide - ? _favRideStopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ) - : _favStopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )) - : (stop.isRide - ? _rideStopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ) - : _stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )), - consumeTapEvents: true, - onTap: () { - try { - Haptics.vibrate(HapticsType.light); - } catch (e) {} - - _showStopSheet( - stop.id, - stop.name, - stop.location.latitude, - stop.location.longitude, - ); - }, - rotation: stop.rotation, - anchor: Offset(0.5, 0.5), - ), - ) - .toSet(); + _routeStopMarkers[routeKey] = {}; + for (final (_, stop) in r.stops) { + // iterate through all stops in this route + final isFavorite = _favoriteStops.contains(stop.id); + + final marker = Marker( + markerId: MarkerId('stop_${stop.id}_${Object.hashAll(r.points)}'), + position: stop.location, + flat: true, + icon: isFavorite + ? (stop.isRide + ? _favRideStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ) + : _favStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + )) + : (stop.isRide + ? _rideStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ) + : _stopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + )), + consumeTapEvents: true, + onTap: () { + try { + Haptics.vibrate(HapticsType.light); + } catch (e) {} + + _showStopSheet( + stop.id, + stop.name, + stop.location.latitude, + stop.location.longitude, + ); + }, + rotation: stop.rotation, + anchor: Offset(0.5, 0.5), + ); + _routeStopMarkers[routeKey]?[stop.id] = marker; + + // gets first marker of this stop and adds it to the favorited stop markers + if (isFavorite && + !_displayedFavoriteStopMarkers.containsKey(stop.id)) { + _displayedFavoriteStopMarkers[stop.id] = marker; + } + _stopIsRide[stop.id] = stop.isRide; + } } } } @@ -829,6 +826,8 @@ class _MaizeBusCoreState extends State { // update in memory cache and marker icons setState(() { _favoriteStops.add(stpid); + baseRoutesLayer + .reload(); // Reload the markers to include the new favorite }); _setStopFavorited(stpid, true); } else {} @@ -843,6 +842,8 @@ class _MaizeBusCoreState extends State { // update in memory cache and marker icons setState(() { _favoriteStops.remove(stpid); + baseRoutesLayer + .reload(); // Reload the markers to include the new favorite }); _setStopFavorited(stpid, false); } @@ -851,55 +852,81 @@ class _MaizeBusCoreState extends State { // Update cached markers for a specific stop id to reflect favorite/unfavorite void _setStopFavorited(String stpid, bool favored) { // Update all routeStopMarkers entries that match this stop id + final isRide = _stopIsRide[stpid] ?? false; _routeStopMarkers.forEach((routeKey, markers) { - final updated = markers.map((m) { - if (m.markerId.value.startsWith('stop_${stpid}_')) { - return Marker( - flat: true, - markerId: m.markerId, - position: m.position, - icon: favored - ? (_favStopIcon ?? - _stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )) - : (_stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )), - consumeTapEvents: m.consumeTapEvents, - onTap: m.onTap, - rotation: m.rotation, - anchor: m.anchor, - ); - } - return m; - }).toSet(); - _routeStopMarkers[routeKey] = updated; + // if marker does not exist in this route, return + if (!markers.containsKey(stpid)) return; + + final m = markers[stpid]!; // get old marker + final newMarker = Marker( + flat: true, + markerId: m.markerId, + position: m.position, + icon: favored + ? (isRide + ? _favRideStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ) + : _favStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + )) + : (isRide + ? _rideStopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ) + : _stopIcon ?? + BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + )), + consumeTapEvents: m.consumeTapEvents, + onTap: m.onTap, + rotation: m.rotation, + anchor: m.anchor, + ); + + // gets first marker of this stop id and adds it to the favorited stop markers + if (favored && !_displayedFavoriteStopMarkers.containsKey(stpid)) { + _displayedFavoriteStopMarkers[stpid] = newMarker; + } + + markers[stpid] = newMarker; // set as new marker }); + // remove favorite stop marker if not favored + if (!favored) { + _displayedFavoriteStopMarkers.remove(stpid); + } + // If displayed, update displayed markers as well setState(() { // Rebuild displayed stop markers based on current selected routes - final selectedStopMarkers = {}; + final selectedStopMarkers = {}; for (final routeId in _selectedRoutes) { final routeVariants = _routePolylines.keys.where( (key) => key.startsWith('${routeId}_'), ); for (final routeKey in routeVariants) { final stops = _routeStopMarkers[routeKey]; - if (stops != null) selectedStopMarkers.addAll(stops); + if (stops == null) continue; + + // iterate through and add the stop markers + // if they are not already in the selected stop markesr + stops.forEach((key, value) { + if (!selectedStopMarkers.containsKey(key)) { + selectedStopMarkers[key] = value; + } + }); } } - _displayedStopMarkers = selectedStopMarkers; - _updateAllDisplayedMarkers(); }); } void _updateDisplayedRoutes() { final selectedPolylines = {}; - final selectedStopMarkers = {}; + final selectedStopMarkers = {}; for (final routeId in _selectedRoutes) { // Find all variants of this route @@ -911,115 +938,27 @@ class _MaizeBusCoreState extends State { final polyline = _routePolylines[routeKey]; if (polyline != null) selectedPolylines.add(polyline); final stops = _routeStopMarkers[routeKey]; - if (stops != null) { - selectedStopMarkers.addAll(stops); - } + if (stops == null) continue; + + stops.forEach((key, value) { + if (!selectedStopMarkers.containsKey(key)) { + selectedStopMarkers[key] = value; + } + }); } } - setState(() { - _displayedPolylines = selectedPolylines; - _displayedStopMarkers = selectedStopMarkers; - _updateAllDisplayedMarkers(); - }); + baseRoutesLayer.reload(); + liveBusesLayer.reload(); + _updateDisplayedBuses( Provider.of(context, listen: false).buses, ); } void _updateDisplayedBuses(List allBuses) { - // null case or error contacting server case - if (allBuses == []) return; - - final selectedBusMarkers = allBuses - .where((bus) => _selectedRoutes.contains(bus.routeId)) - .map((bus) { - // Use backend color if available, otherwise fallback to service - final routeColor = - bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId); - - // Use route specific bus icon if available, otherwise fallback to default - BitmapDescriptor? busIcon; - if (_routeBusIcons.containsKey(bus.routeId)) { - busIcon = _routeBusIcons[bus.routeId]; - } else if (_busIcon != null) { - busIcon = _busIcon; - } else { - busIcon = BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(routeColor), - ); - } - - return Marker( - flat: true, - markerId: MarkerId('bus_${bus.id}'), - consumeTapEvents: true, - position: bus.position, - icon: busIcon!, - rotation: bus.heading, - anchor: const Offset(0.5, 0.5), // Center the icon on the position - onTap: () { - try { - Haptics.vibrate(HapticsType.light); - } catch (e) {} - _showBusSheet(bus.id); - }, - ); - }) - .toSet(); - - // Update journey bus markers if journey is active - if (_journeyOverlayActive && _activeJourneyBusIds.isNotEmpty) { - _displayedJourneyBusMarkers.clear(); - for (final bus in allBuses) { - // Show buses that are on routes used in the journey - if (_activeJourneyBusIds.contains(bus.id)) { - final routeColor = - bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId); - BitmapDescriptor? busIcon; - if (_routeBusIcons.containsKey(bus.routeId)) { - busIcon = _routeBusIcons[bus.routeId]; - } else if (_busIcon != null) { - busIcon = _busIcon; - } else { - busIcon = BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(routeColor), - ); - } - - _displayedJourneyBusMarkers.add( - Marker( - flat: true, - markerId: MarkerId('journey_bus_${bus.id}'), - consumeTapEvents: true, - position: bus.position, - icon: busIcon!, - rotation: bus.heading, - anchor: const Offset(0.5, 0.5), - onTap: () => _showBusSheet(bus.id), - ), - ); - } - } - } - - setState(() { - _displayedBusMarkers = selectedBusMarkers; - _updateAllDisplayedMarkers(); - }); - } - - void _updateAllDisplayedMarkers() { - _allDisplayedStopMarkers = _displayedStopMarkers - .union(_displayedBusMarkers) - .union(_displayedJourneyMarkers) - .union(_searchLocationMarker != null ? {_searchLocationMarker!} : {}); - } - - /// Convert a Color to a BitmapDescriptor hue value - double _colorToHue(Color color) { - final hsl = HSLColor.fromColor(color); - return hsl.hue; + journeyLayer.refreshLiveBusMarkers(allBuses); + liveBusesLayer.reload(); } // Show a red pin marker at search location @@ -1027,7 +966,7 @@ class _MaizeBusCoreState extends State { _searchLocationMarker = Marker( markerId: const MarkerId('search_location'), position: LatLng(lat, lon), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + icon: MapImageService.getNavigationBusStop() ?? BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure,), consumeTapEvents: false, ); setState(() {}); @@ -1039,28 +978,6 @@ class _MaizeBusCoreState extends State { setState(() {}); } - void _refreshAllMarkers() { - final busProvider = Provider.of(context, listen: false); - _refreshCachedStopMarkers(); - _refreshRouteBusIcons(); - _updateDisplayedRoutes(); - _updateDisplayedBuses(busProvider.buses); - } - - // Refresh route specific bus icons - void _refreshRouteBusIcons() { - _routeBusIcons.clear(); - _loadRouteSpecificBusIcons(); - } - - // Check if a route has specific bus icon loaded - bool hasRouteBusIcon(String routeId) { - return _routeBusIcons.containsKey(routeId); - } - - // Get the number of route bus icons loaded - int get loadedBusIconCount => _routeBusIcons.length; - // Save selected routes to persistent storage Future _saveSelectedRoutes() async { final prefs = await SharedPreferences.getInstance(); @@ -1079,53 +996,14 @@ class _MaizeBusCoreState extends State { void _refreshCachedStopMarkers() { // Clear cached stop markers so they'll be recreated with the new icons _routeStopMarkers.clear(); + // also clear persistent favorited stop markers to be refreshed in _cacheRouteOverlays(..) + _displayedFavoriteStopMarkers.clear(); // Re-cache all route overlays with the new icons _cacheRouteOverlays( Provider.of(context, listen: false).routes, ); } - void _onMapCreated(GoogleMapController controller) { - _mapController = controller; - } - - void _onCameraMove(CameraPosition position) async { - _currentCameraPos = position; - } - - void _onCameraIdle() async { - // check if user location is within viewport bounds - LatLngBounds? viewportBounds = await _mapController?.getVisibleRegion(); - if (viewportBounds != null) { - Position? pos = await _getLastKnownLocation(); - if (pos != null) { - _userLocVisible = !viewportBounds.contains( - LatLng(pos.latitude, pos.longitude), - ); - } - } - } - - // Create a bus marker from a Bus model - Marker _createBusMarker(Bus bus) { - final routeColor = - bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId); - final icon = - _routeBusIcons[bus.routeId] ?? - _busIcon ?? - BitmapDescriptor.defaultMarkerWithHue(_colorToHue(routeColor)); - return Marker( - flat: true, - markerId: MarkerId('bus_${bus.id}'), - consumeTapEvents: true, - position: bus.position, - icon: icon, - rotation: bus.heading, - anchor: const Offset(0.5, 0.5), - onTap: () => _showBusSheet(bus.id), - ); - } - void _showBusRoutesModal(List allRouteLines) { showModalBottomSheet( context: context, @@ -1141,8 +1019,9 @@ class _MaizeBusCoreState extends State { setState(() { _selectedRoutes.clear(); _selectedRoutes.addAll(newSelection); + baseRoutesLayer.reload(); }); - _updateDisplayedRoutes(); + // _updateDisplayedRoutes(); // Save the new selection await _saveSelectedRoutes(); @@ -1172,8 +1051,8 @@ class _MaizeBusCoreState extends State { if (isBusStop) { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showStopSheet( stopID, @@ -1184,8 +1063,8 @@ class _MaizeBusCoreState extends State { } else { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showBuildingSheet(location); } @@ -1227,6 +1106,9 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } void _showDirectionsSheet( @@ -1301,10 +1183,19 @@ class _MaizeBusCoreState extends State { } }, onSelectJourney: (journey) { - _displayJourneyOnMap( + currDisplayed = journey; + showJourney(); + journeyLayer.setJourney( journey, getColor(context, ColorType.opposite), ); + + // TODO: Figure out how to change the visibility of the layers + + // _displayJourneyOnMap( + // journey, + // getColor(context, ColorType.opposite), + // ); }, onResolved: (orig, dest) { // Cache resolved coordinates for virtual origin/destination resolution @@ -1312,11 +1203,36 @@ class _MaizeBusCoreState extends State { _lastJourneyRequestDest = dest; }, scrollController: scrollController, + onStartNavigation: (Journey journey) { + // TODO: Pass in the Journey from here and give it to the navigation overlay + _bottomSheetController?.close(); + + baseRoutesLayer.isVisible = false; + liveBusesLayer.isVisible = false; + journeyLayer.isVisible = false; + navigationLayer.isVisible = true; + + navigationLayer.reload(); // This reloads the map + + navigationManager.initFromJourney( + journey, + getColor(context, ColorType.mapWalkingLine), + ); + + setState(() { + _navigationOverlayEnabled = true; + }); + + // TODO: Center the map to the start location + }, ); }, ); }, ); + // _bottomSheetController?.closed.then((_) { + // hideJourney(); + // }); } _showJourneySheetOnReopen() { @@ -1355,442 +1271,39 @@ class _MaizeBusCoreState extends State { }, ); }, - ); - } - - // Display a Journey on the map - void _displayJourneyOnMap(Journey journey, Color walkLineColor) async { - currDisplayed = journey; - - // clear previous journey overlay - _displayedJourneyPolylines.clear(); - _displayedJourneyMarkers.clear(); - _activeJourneyBusIds.clear(); - _activeJourneyRoutes.clear(); - - final allPoints = []; - - // First, analyze the journey to find which legs are bus and which are walking - - for (int legIndex = 0; legIndex < journey.legs.length; legIndex++) { - final leg = journey.legs[legIndex]; - - // Determine if this is a walking or bus leg - walking legs don't have rt or trip - final bool isBusLeg = leg.rt != null && leg.trip != null; - // Determine leg type for processing - - if (isBusLeg) { - // Add route ID and vehicle ID to active sets for bus filtering - if (leg.rt != null) { - _activeJourneyRoutes.add(leg.rt!); - } - if (leg.trip != null) { - _activeJourneyBusIds.add(leg.trip!.vid); - } // Try to find a cached route polyline segment that follows streets - final startLatLng = getLatLongFromStopID(leg.originID); - final endLatLng = getLatLongFromStopID(leg.destinationID); - - bool usedRouteGeometry = false; - if (startLatLng != null && endLatLng != null) { - final routeVariants = _routePolylines.keys.where( - (key) => key.startsWith('${leg.rt}_'), - ); - - List? bestSegment; - double? bestLength; - - for (final routeKey in routeVariants) { - final poly = _routePolylines[routeKey]; - if (poly == null) continue; - final ptsList = poly.points; - if (ptsList.length < 2) continue; - - final seg = _extractRouteSegment(ptsList, startLatLng, endLatLng); - if (seg != null && seg.length >= 2) { - // compute approximate length - double len = 0; - for (int i = 1; i < seg.length; i++) { - final a = seg[i - 1]; - final b = seg[i]; - final dx = a.latitude - b.latitude; - final dy = a.longitude - b.longitude; - len += dx * dx + dy * dy; - } - if (bestSegment == null || len < bestLength!) { - bestSegment = seg; - bestLength = len; - } - } - } - - if (bestSegment != null) { - final polyline = Polyline( - polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), - points: bestSegment, - color: RouteColorService.getRouteColor(leg.rt!), - width: 6, - ); - _displayedJourneyPolylines.add(polyline); - - // add stop markers at endpoints of the segment (boarding/getting off) - _displayedJourneyMarkers.addAll([ - Marker( - flat: true, - markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), - position: bestSegment.first, - icon: - _getOn ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - Marker( - flat: true, - markerId: MarkerId( - 'journey_stop_${leg.destinationID}_$legIndex', - ), - position: bestSegment.last, - icon: - _getOff ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - ]); - - allPoints.addAll(bestSegment); - usedRouteGeometry = true; - } - } - - if (!usedRouteGeometry) { - // Fallback to simple path - final pts = []; - bool started = false; - for (final st in leg.trip!.stopTimes) { - if (st.stop == leg.originID) started = true; - if (started) { - final latlng = getLatLongFromStopID(st.stop); - if (latlng != null) { - pts.add(latlng); - allPoints.add(latlng); - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId('journey_stop_${st.stop}_$legIndex'), - position: latlng, - icon: - _stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - ); - } - } - if (st.stop == leg.destinationID && started) break; - } - - if (pts.isNotEmpty) { - final poly = Polyline( - polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), - points: pts, - color: RouteColorService.getRouteColor(leg.rt!), - width: 6, - ); - _displayedJourneyPolylines.add(poly); - } - } - } else { - // Walking legs add a dotted line between origin and destination - // First try to get the locations from origin and destination IDs - LatLng? startLatLng = getLatLongFromStopID(leg.originID); - LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); - - // Walking leg information - - // Locations were not found, could be a building or custom location - // In this case, we need to look for coordinates in previous/next legs - // Also handle virtual origin/destination from the directions request - if (startLatLng == null) { - // resolve virtual origin - if (leg.originID == 'VIRTUAL_ORIGIN' && - _lastJourneyRequestOrigin != null) { - startLatLng = LatLng( - _lastJourneyRequestOrigin!['lat']!, - _lastJourneyRequestOrigin!['lon']!, - ); - } else if (leg.originID == 'VIRTUAL_DESTINATION' && - _lastJourneyRequestDest != null) { - startLatLng = LatLng( - _lastJourneyRequestDest!['lat']!, - _lastJourneyRequestDest!['lon']!, - ); - } - } - - // If still unresolved and this is a virtual origin, attempt to use device location - if (startLatLng == null && leg.originID == 'VIRTUAL_ORIGIN') { - try { - final pos = await Geolocator.getCurrentPosition().timeout( - Duration(seconds: 3), - ); - startLatLng = LatLng(pos.latitude, pos.longitude); - } catch (e) { - // ignore GPS resolution failure - } - } - - if (startLatLng == null && legIndex > 0) { - // Try to get end location from previous leg - final prevLeg = journey.legs[legIndex - 1]; - startLatLng = getLatLongFromStopID(prevLeg.destinationID); - } - - if (endLatLng == null) { - // resolve virtual destination - if (leg.destinationID == 'VIRTUAL_DESTINATION' && - _lastJourneyRequestDest != null) { - endLatLng = LatLng( - _lastJourneyRequestDest!['lat']!, - _lastJourneyRequestDest!['lon']!, - ); - } else if (leg.destinationID == 'VIRTUAL_ORIGIN' && - _lastJourneyRequestOrigin != null) { - endLatLng = LatLng( - _lastJourneyRequestOrigin!['lat']!, - _lastJourneyRequestOrigin!['lon']!, - ); - } - } - - // If still unresolved and this is a virtual destination, attempt device location fallback - if (endLatLng == null && leg.destinationID == 'VIRTUAL_DESTINATION') { - try { - final pos = await Geolocator.getCurrentPosition().timeout( - Duration(seconds: 3), - ); - endLatLng = LatLng(pos.latitude, pos.longitude); - } catch (e) { - print('Could not resolve VIRTUAL_DESTINATION via device GPS: $e'); - } - } - - if (endLatLng == null && legIndex < journey.legs.length - 1) { - // Try to get start location from next leg - final nextLeg = journey.legs[legIndex + 1]; - endLatLng = getLatLongFromStopID(nextLeg.originID); - } - - // Check if we have both coordinates before creating walking polyline - if (startLatLng != null && endLatLng != null) { - List pts = []; - if (leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { - pts = leg.pathCoords!; - } else { - pts = [startLatLng, endLatLng]; - } - - // Create a dotted line for walking segments - final walkingPolyline = Polyline( - polylineId: PolylineId('walking_${journey.hashCode}_$legIndex'), - points: pts, - color: walkLineColor, // Walk line color - width: 6, // line width - patterns: [ - PatternItem.dash(30), // Longer dashes - PatternItem.gap(15), // Longer gaps - ], - ); - - _displayedJourneyPolylines.add(walkingPolyline); - allPoints.addAll([startLatLng, endLatLng]); - - // Only add destination marker if this is the final leg of the journey - if (legIndex == journey.legs.length - 1) { - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId( - 'journey_final_destination_${journey.hashCode}', - ), - position: endLatLng, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueRed, - ), - ), - ); - } - - // Add starting marker if this is the first leg of the journey - if (legIndex == 0) { - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId('journey_start_${journey.hashCode}'), - position: startLatLng, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueGreen, - ), - ), - ); - } // doing this for now bc couldnt figure out marker stuff better - } - } - } - - // mark that a journey overlay is active (this will hide other route polylines) - _journeyOverlayActive = true; - - // Build bus markers for buses matching active journey routes - // Filter by route first, then optionally by specific vehicle ID if available - _displayedJourneyBusMarkers.clear(); - final busProvider = Provider.of(context, listen: false); - for (final bus in busProvider.buses) { - // Show buses that are on routes used in the journey - if (_activeJourneyRoutes.contains(bus.routeId)) { - _displayedJourneyBusMarkers.add(_createBusMarker(bus)); - } - } - - // Final debug check - // Journey display complete (silently updated internal state) - - setState(() { - _updateAllDisplayedMarkers(); + ).whenComplete(() { + hideJourney(); }); + } - // Trying to move camera to include the journey bounds - if (_mapController != null && allPoints.isNotEmpty) { - try { - double south = allPoints.first.latitude; - double north = allPoints.first.latitude; - double west = allPoints.first.longitude; - double east = allPoints.first.longitude; - for (final p in allPoints) { - south = p.latitude < south ? p.latitude : south; - north = p.latitude > north ? p.latitude : north; - west = p.longitude < west ? p.longitude : west; - east = p.longitude > east ? p.longitude : east; - } - - // Adjust bounds to position route in top 1/3 of screen (accounting for bottom sheet) - final latSpan = north - south; - final adjustedSouth = - south - (latSpan) * 2; // Much more padding to bottom - final adjustedNorth = north; // Less padding to top - - final bounds = LatLngBounds( - southwest: LatLng(adjustedSouth, west), - northeast: LatLng(adjustedNorth, east), - ); - - await _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), - ); - } catch (e) { - // fallback to center on first point higher up - if (allPoints.isNotEmpty) { - // Calculate center of route points - double centerLat = 0; - double centerLon = 0; - for (final p in allPoints) { - centerLat += p.latitude; - centerLon += p.longitude; - } - centerLat /= allPoints.length; - centerLon /= allPoints.length; - - // Offset the center significantly north to place in top 1/3 - final offsetLat = centerLat + 0.008; // Roughly 800m north - - await _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition(target: LatLng(offsetLat, centerLon), zoom: 13), - ), - ); - } - } - } + void showJourney() { + journeyLayer.isVisible = true; + baseRoutesLayer.isVisible = false; + liveBusesLayer.isVisible = false; } - // Clear/hide the currently displayed journey overlays and return to normal route view - void _clearJourneyOverlays() { - if (!_journeyOverlayActive) return; - _displayedJourneyPolylines.clear(); - _displayedJourneyMarkers.clear(); - _displayedJourneyBusMarkers.clear(); - _activeJourneyBusIds.clear(); - _activeJourneyRoutes.clear(); - _journeyOverlayActive = false; - // making sure to remove search location marker when clearing journey - _removeSearchLocationMarker(); - setState(() {}); + void hideJourney() { + journeyLayer.isVisible = false; + baseRoutesLayer.isVisible = true; + liveBusesLayer.isVisible = true; } - // Haversine distance between two LatLngs in meters - double _haversineDistanceMeters(LatLng a, LatLng b) { - const R = 6371000; // Earth radius in meters - final lat1 = a.latitude * math.pi / 180.0; - final lat2 = b.latitude * math.pi / 180.0; - final dLat = (b.latitude - a.latitude) * math.pi / 180.0; - final dLon = (b.longitude - a.longitude) * math.pi / 180.0; - - final sa = - math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(lat1) * - math.cos(lat2) * - math.sin(dLon / 2) * - math.sin(dLon / 2); - final c = 2 * math.atan2(math.sqrt(sa), math.sqrt(1 - sa)); - return R * c; + void _onMapCreated(GoogleMapController controller) { + _mapController = controller; } - // Find nearest index and its distance on polyline to target. Returns a pair [index, distanceMeters] - List _nearestIndexAndDistanceOnPolyline( - List poly, - LatLng target, - ) { - int bestIdx = 0; - double bestDist = double.infinity; - for (int i = 0; i < poly.length; i++) { - final p = poly[i]; - final d = _haversineDistanceMeters(p, target); - if (d < bestDist) { - bestDist = d; - bestIdx = i; - } + void _onCameraMove(CameraPosition position) { + if (!mounted) return; + _currentCameraPos.value = position; + if (!_isProgrammaticCameraMove) { + // log("noted nonprogrammatic camera move"); + _userHasInteractedWithMap.value = true; } - return [bestIdx, bestDist]; } - // Helper to extract a contiguous segment from polyline points between two latlngs - // Return null if indices are invalid or segment is too short. - List? _extractRouteSegment( - List poly, - LatLng start, - LatLng end, - ) { - final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); - final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); - final si = sRes[0] as int; - final ei = eRes[0] as int; - final sDist = sRes[1] as double; - final eDist = eRes[1] as double; - - // If either nearest point is too far from the stop, we consider this polyline not a match - if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) - return null; - - if (si == ei) return null; - - // Ensure start < end in index space, if reversed, flip the sublist - if (si < ei) { - return poly.sublist(si, ei + 1); - } else { - final seg = poly.sublist(ei, si + 1); - return seg.reversed.toList(); - } + void _onCameraIdle() async { + // The next camera movement is user-controlled unless a new animation starts. + _isProgrammaticCameraMove = false; } void _showBusSheet(String busID) { @@ -1817,8 +1330,8 @@ class _MaizeBusCoreState extends State { } else { showMaizebusOKDialog( contextIn: context, - title: const Text("Error"), - content: const Text("Couldn't load stop."), + title: "Error", + content: "Couldn't load stop.", ); } }, @@ -1843,8 +1356,8 @@ class _MaizeBusCoreState extends State { } else { showMaizebusOKDialog( contextIn: context, - title: const Text('Error'), - content: const Text('Couldn\'t load stop.'), + title: 'Error', + content: 'Couldn\'t load stop.', ); } }, @@ -1872,11 +1385,11 @@ class _MaizeBusCoreState extends State { return StopSheet( stopID: stopID, stopName: stopName, + isFavorite: _favoriteStops.contains(stopID), onFavorite: _addFavoriteStop, onUnFavorite: _removeFavoriteStop, showBusSheet: (busId) { // When someone clicks "See all stops for this bus" this callback runs - debugPrint("Got 'See all stops' click for Bus ${busId}"); Navigator.pop(context); // Close the current modal _showBusSheet(busId); }, @@ -1895,7 +1408,9 @@ class _MaizeBusCoreState extends State { }, ); }, - ).then((_) {}); + ).then((_) { + hideJourney(); + }); // Hide any displayed journey when the sheet is closed } // lighter function for when we need to get location @@ -1927,6 +1442,9 @@ class _MaizeBusCoreState extends State { ), ); return null; + } else { + //Center map once right after user grants location permissions + _centerOnLocation(true); } } @@ -1957,10 +1475,12 @@ class _MaizeBusCoreState extends State { } Future _centerOnLocation( - bool userLocation, [ + bool userLocation, { double lat = 0, double long = 0, - ]) async { + double? zoom, + double? bearing, + }) async { // at first create a default position. User location can overwrite later if needed Position position = Position( longitude: long, @@ -1982,24 +1502,33 @@ class _MaizeBusCoreState extends State { // Animate the map camera to the user's location if (_mapController != null) { - await _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: LatLng(position.latitude, position.longitude), - zoom: userLocation ? 15.0 : 17.0, + _isProgrammaticCameraMove = true; + try { + await _mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(position.latitude, position.longitude), + zoom: zoom ?? (userLocation ? 15.0 : 17.0), + bearing: bearing ?? 0.0, + ), ), - ), - ); + ); + } finally { + _isProgrammaticCameraMove = true; + _userHasInteractedWithMap.value = + false; // Reset user interaction flag after programmatic move + } } } Future _setMapToNorth() async { - if (_mapController != null && _currentCameraPos != null) { + final cameraPosition = _currentCameraPos.value; + if (_mapController != null && cameraPosition != null) { await _mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( - target: _currentCameraPos!.target, // current position - zoom: _currentCameraPos!.zoom, + target: cameraPosition.target, // current position + zoom: cameraPosition.zoom, bearing: 0, // face north ), ), @@ -2009,56 +1538,43 @@ class _MaizeBusCoreState extends State { @override Widget build(BuildContext context) { - // Only update bus markers when buses change - final busProvider = Provider.of(context); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (busProvider.buses.isNotEmpty) { - _updateDisplayedBuses(busProvider.buses); - } - }); - if (!globallPaddingHasBeenSet) { // set all padding // first, getting all the padding values final mediaQueryData = MediaQuery.of(context); final double flutterSafeAreaTop = mediaQueryData.padding.top; final double flutterSafeAreaBottom = mediaQueryData.padding.bottom; - // then, changing them based on phone - if (Platform.isIOS) { - if (flutterSafeAreaBottom == 0) { - // rectangle iphone - globalBottomPadding = 10; - globalLeftRightPadding = 10; - globalTopPadding = 20; - } else { - // round iphone - globalBottomPadding = 30; - globalLeftRightPadding = 30; - globalTopPadding = flutterSafeAreaTop; - } - } else { - // andoird - if (flutterSafeAreaBottom < 30) { - // in this case, 30 from the bottom is fine because - // it's over the safe area. this usually works - // for round bottom phones like the google pixel - - globalBottomPadding = 30; - globalLeftRightPadding = 30; - globalTopPadding = flutterSafeAreaTop; - } else { - // this case, it's over 30. probably means - // a rectangle android. so no need to make - // it like 30 - - globalBottomPadding = flutterSafeAreaBottom + 15; - globalLeftRightPadding = 15; - globalTopPadding = flutterSafeAreaTop; - } + // screen buttons are 45 by 45 (diameter) + // so they have a radius of 45/2 = 22.5 + // so for perfectly spaced buttons, we + // need to do screen radius - 22.5 + double perfectPadding = (screenRadius?.bottomLeft ?? 0) - 22.5; + + if (Platform.isIOS) + perfectPadding -= 9; // the -9 just makes it look more pretty on ios + + globalTopPadding = flutterSafeAreaTop; + + // if we're padding less than 3 then its too rectangle. + // default to just keeping it out of the safe area + if (perfectPadding < 3) { + globalBottomPadding = flutterSafeAreaBottom + 10; + globalLeftRightPadding = 10; + } else if ((perfectPadding < flutterSafeAreaBottom) && !Platform.isIOS) { + // if the buttons are in the safe area, act rectangular + // but not for iOS, because safe area isn't real on iOS + globalBottomPadding = flutterSafeAreaBottom + 10; + globalLeftRightPadding = 10; + } else { + // perfect padding is perfect! it keeps the buttons + // out of the safe area so we'll just use them + globalBottomPadding = perfectPadding; + globalLeftRightPadding = perfectPadding; } - globallPaddingHasBeenSet = true; + // only set this to true if we've loaded the screen radius + globallPaddingHasBeenSet = screenRadiusLoaded; } return FutureBuilder( @@ -2077,11 +1593,14 @@ class _MaizeBusCoreState extends State { // lets us prevent back button on map page canPop: false, onPopInvokedWithResult: (didPop, result) { - // when journey is showing and pop was attempted, clear journey - if (_journeyOverlayActive) { - _clearJourneyOverlays(); + hideJourney(); // Hide the journey if it's showing right now + + if (_navigationOverlayEnabled) { + hideNavigation(); } + _floorplanOverlayEnabled = false; + // If showing a persistent bottom sheet, close it. // Fix android back button for buildings sheet and journey sheet (doesn't work without this) if (_bottomSheetController != null) { @@ -2089,71 +1608,27 @@ class _MaizeBusCoreState extends State { _bottomSheetController = null; _removeSearchLocationMarker(); } + + setState(() {}); // Make sure the widgets reload }, child: Stack( children: [ - // underlying map layer (different ios and android) - Platform.isIOS - ? MapWidget( - initialCenter: _defaultCenter, - polylines: _journeyOverlayActive - ? _displayedJourneyPolylines - : _displayedPolylines.union( - _displayedJourneyPolylines, - ), - markers: _journeyOverlayActive - ? _displayedJourneyMarkers - .union(_displayedJourneyBusMarkers) - .union( - _searchLocationMarker != null - ? {_searchLocationMarker!} - : {}, - ) - : _allDisplayedStopMarkers, - darkMapStyle: _darkMapStyle, - lightMapStyle: _lightMapStyle, - onMapCreated: _onMapCreated, - onCameraMove: _onCameraMove, - onCameraIdle: _onCameraIdle, - myLocationEnabled: true, - myLocationButtonEnabled: false, - zoomControlsEnabled: true, - mapToolbarEnabled: true, - ) - : AndroidMap( - initialCenter: _defaultCenter, - polylines: _journeyOverlayActive - ? _displayedJourneyPolylines - : _displayedPolylines.union( - _displayedJourneyPolylines, - ), - staticMarkers: _journeyOverlayActive - ? _displayedJourneyMarkers.union( - _searchLocationMarker != null - ? {_searchLocationMarker!} - : {}, - ) - : _displayedStopMarkers - .union(_displayedJourneyMarkers) - .union( - _searchLocationMarker != null - ? {_searchLocationMarker!} - : {}, - ), - darkMapStyle: _darkMapStyle, - lightMapStyle: _lightMapStyle, - dynamicMarkers: _journeyOverlayActive - ? _displayedJourneyBusMarkers - : _displayedBusMarkers, - onMapCreated: _onMapCreated, - onCameraMove: _onCameraMove, - onCameraIdle: _onCameraIdle, - //myLocationEnabled: true, - myLocationButtonEnabled: false, - //zoomControlsEnabled: true, - //mapToolbarEnabled: true, - ), - + RepaintBoundary( + child: CompositeMapWidget( + initialCenter: startLatLng, + mapLayers: [ + demoBuildingsLayer, // DEMO BUILDINGS + floorplansLayer, + baseRoutesLayer, + liveBusesLayer, + journeyLayer, + navigationLayer, + ], + onMapCreated: _onMapCreated, + onCameraMove: _onCameraMove, + onCameraIdle: _onCameraIdle, + ), + ), Padding( padding: EdgeInsets.only( top: globalTopPadding, @@ -2322,9 +1797,6 @@ class _MaizeBusCoreState extends State { ), ); }, - - // final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-10 0:00:00Z"); - // final NEW_BUTTON_HIDE_TIME = DateTime.parse("2026-03-16 0:00:00Z"); heroTag: 'new_fab', elevation: 0, child: Text( @@ -2432,130 +1904,78 @@ class _MaizeBusCoreState extends State { Spacer(), - // temp row (might add settings button to it later) - (!_journeyOverlayActive) - ? Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Column( - spacing: 10, + ValueListenableBuilder( + valueListenable: _currentCameraPos, + builder: (context, cameraPosition, child) { + return (!_journeyOverlayActive) + ? Padding( + padding: const EdgeInsets.only( + bottom: 20, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.end, children: [ - // face north button is only visible when not facing north - Visibility( - visible: - _currentCameraPos != null && - _currentCameraPos!.bearing != - 0, - child: DecoratedBox( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: getColor( - context, - ColorType - .mapButtonShadow, - ).withAlpha(50), - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - borderRadius: - BorderRadius.circular(25), - ), - child: FloatingActionButton.small( - onPressed: _setMapToNorth, - heroTag: 'north_fab', - backgroundColor: getColor( - context, - ColorType - .mapButtonSecondary, - ), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular( - 56, + Column( + spacing: 10, + children: [ + // face north button is only visible when not facing north + Visibility( + visible: + _currentCameraPos.value != + null && + _currentCameraPos + .value! + .bearing != + 0, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType + .mapButtonShadow, + ).withAlpha(50), + blurRadius: 4, + offset: Offset(0, 2), ), - ), - child: Transform.rotate( - angle: - _currentCameraPos != - null - ? (-_currentCameraPos! - .bearing - - 45) * - (math.pi / 180) - : 0, - child: Icon( - FontAwesomeIcons.compass, - color: getColor( + ], + borderRadius: + BorderRadius.circular( + 25, + ), + ), + child: FloatingActionButton.small( + onPressed: _setMapToNorth, + heroTag: 'north_fab', + backgroundColor: getColor( context, ColorType - .mapButtonPrimary, + .mapButtonSecondary, ), - ), - ), - ), - ), - ), - - // location button - AnimatedSwitcher( - duration: const Duration( - milliseconds: 250, - ), - child: - !(_userLocVisible == null || - _userLocVisible!) - ? - // if not needed, sized box - SizedBox.shrink() - : - // otherwise, normal button - DecoratedBox( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: getColor( - context, - ColorType - .mapButtonShadow, - ).withAlpha(50), - blurRadius: 4, - offset: Offset( - 0, - 2, - ), - ), - ], + elevation: 0, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( - 25, + 56, ), ), - child: FloatingActionButton.small( - onPressed: () { - _centerOnLocation( - true, - ); - }, - heroTag: 'location_fab', - backgroundColor: getColor( - context, - ColorType - .mapButtonSecondary, - ), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular( - 56, - ), - ), - child: Icon( - Icons.my_location, + child: Transform.rotate( + angle: + _currentCameraPos + .value != + null + ? (-_currentCameraPos + .value! + .bearing - + 45) * + vec_math + .degrees2Radians + : 0, + child: FaIcon( + FontAwesomeIcons + .compass, color: getColor( context, ColorType @@ -2564,13 +1984,89 @@ class _MaizeBusCoreState extends State { ), ), ), + ), + ), + + // location button + ValueListenableBuilder( + valueListenable: + _userHasInteractedWithMap, + builder: (context, userMoved, child) { + return AnimatedSwitcher( + duration: const Duration( + milliseconds: 250, + ), + child: userMoved + ? DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType + .mapButtonShadow, + ).withAlpha(50), + blurRadius: + 4, + offset: + Offset( + 0, + 2, + ), + ), + ], + borderRadius: + BorderRadius.circular( + 25, + ), + ), + child: FloatingActionButton.small( + onPressed: () { + _setFollowMode( + true, + ); + _centerOnLocation( + true, + ); + }, + heroTag: + 'location_fab', + backgroundColor: + getColor( + context, + ColorType + .mapButtonSecondary, + ), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 56, + ), + ), + child: Icon( + Icons + .my_location, + color: getColor( + context, + ColorType + .mapButtonPrimary, + ), + ), + ), + ) + : const SizedBox.shrink(), + ); + }, + ), + ], ), ], ), - ], - ), - ) - : SizedBox.shrink(), + ) + : SizedBox.shrink(); + }, + ), // if showing journey, show close and reopen button (_journeyOverlayActive) @@ -2632,7 +2128,10 @@ class _MaizeBusCoreState extends State { ), ), child: ElevatedButton.icon( - onPressed: _clearJourneyOverlays, + onPressed: () { + hideJourney(); + // _clearJourneyOverlays + }, style: ElevatedButton.styleFrom( backgroundColor: getColor( context, @@ -2706,7 +2205,7 @@ class _MaizeBusCoreState extends State { ); } _showBusRoutesModal( - busProvider.routes, + _busProviderRef!.routes, ); }, heroTag: 'routes_fab', @@ -2834,11 +2333,43 @@ class _MaizeBusCoreState extends State { ), ), ), + + FilledButton( + onPressed: () { + setState(() { + _floorplanOverlayEnabled = true; + }); + }, + child: Text("Floorplan"), + ), ], ), ], ), ), + _floorplanOverlayEnabled + ? Positioned.fill( + child: RepaintBoundary( + child: FloorplanOverlay( + floorplansLayer: floorplansLayer, + onClosed: () { + setState(() { + _floorplanOverlayEnabled = false; + }); + }, + ), + ), + ) + : SizedBox.shrink(), + _navigationOverlayEnabled + ? Positioned.fill( + child: RepaintBoundary( + child: NavigationOverlay( + navigationManager: navigationManager, + ), + ), + ) + : SizedBox.shrink(), ], ), ) diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 022ba42..411e94a 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,3 +1,4 @@ +import 'package:bluebus/globals.dart'; import 'package:bluebus/widgets/custom_sliding_segmented_control.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/widgets.dart'; @@ -6,6 +7,7 @@ import 'package:bluebus/constants.dart'; import 'package:bluebus/providers/theme_provider.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class Settings extends StatefulWidget { const Settings({super.key}); @@ -15,6 +17,59 @@ class Settings extends StatefulWidget { } class _SettingsState extends State { + late final TextEditingController _followThresholdController; + final GlobalKey _followThresholdFormKey = GlobalKey(); + late final TextEditingController _gpsUpdateDistanceController; + final GlobalKey _gpsUpdateDistanceFormKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _followThresholdController = TextEditingController( + text: globalFollowDistanceThresholdMeters.toStringAsFixed(1), + ); + _gpsUpdateDistanceController = TextEditingController( + text: globalGpsUpdateDistanceFilterMeters.toString(), + ); + } + + @override + void dispose() { + _followThresholdController.dispose(); + _gpsUpdateDistanceController.dispose(); + super.dispose(); + } + + Future _saveFollowThreshold() async { + final parsed = double.tryParse(_followThresholdController.text.trim()); + if (parsed == null || parsed < 0) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('follow_distance_threshold_meters', parsed); + + if (!mounted) return; + setState(() { + globalFollowDistanceThresholdMeters = parsed; + }); + } + + Future _saveGpsUpdateDistanceFilter() async { + final parsed = int.tryParse(_gpsUpdateDistanceController.text.trim()); + if (parsed == null || parsed < 0) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('gps_update_distance_filter_meters', parsed); + + if (!mounted) return; + setState(() { + globalGpsUpdateDistanceFilterMeters = parsed; + }); + } + @override Widget build(BuildContext context) { ThemeProvider themeProvider = Provider.of(context, listen: false); @@ -100,6 +155,200 @@ class _SettingsState extends State { const Divider(), const SizedBox(height: 20), + const Text( + 'Map Follow Distance', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w600, + fontSize: 24, + ), + textAlign: TextAlign.left, + ), + + const SizedBox(height: 10), + + const Text( + 'How far you need to move before the map recenters while follow mode is enabled.', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w400, + fontSize: 16, + ), + ), + + const SizedBox(height: 12), + + Form( + key: _followThresholdFormKey, + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _followThresholdController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: 'Threshold in meters', + hintText: '8.0', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + validator: (value) { + final parsed = double.tryParse((value ?? '').trim()); + if (parsed == null) { + return 'Enter a valid number'; + } + if (parsed < 0) { + return 'Enter a value of 0 or higher'; + } + return null; + }, + onFieldSubmitted: (_) async { + final isValid = _followThresholdFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveFollowThreshold(); + } + }, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final isValid = _followThresholdFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveFollowThreshold(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + getColor(context, ColorType.importantButtonBackground), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + elevation: 0, + ), + child: Text( + 'Apply', + style: TextStyle( + color: getColor(context, ColorType.importantButtonText), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), + + const Text( + 'GPS Update Distance', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w600, + fontSize: 24, + ), + textAlign: TextAlign.left, + ), + + const SizedBox(height: 10), + + const Text( + 'How far you need to move (dead reckoning) before the GPS requests updated location', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w400, + fontSize: 16, + ), + ), + + const SizedBox(height: 12), + + Form( + key: _gpsUpdateDistanceFormKey, + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _gpsUpdateDistanceController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Distance in meters', + hintText: '5', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + validator: (value) { + final parsed = int.tryParse((value ?? '').trim()); + if (parsed == null) { + return 'Enter a valid number'; + } + if (parsed < 0) { + return 'Enter a value of 0 or higher'; + } + return null; + }, + onFieldSubmitted: (_) async { + final isValid = _gpsUpdateDistanceFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveGpsUpdateDistanceFilter(); + } + }, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final isValid = _gpsUpdateDistanceFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveGpsUpdateDistanceFilter(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + getColor(context, ColorType.importantButtonBackground), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + elevation: 0, + ), + child: Text( + 'Apply', + style: TextStyle( + color: getColor(context, ColorType.importantButtonText), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), + Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/services/bus_info_service.dart b/lib/services/bus_info_service.dart index bc53017..30c1d87 100644 --- a/lib/services/bus_info_service.dart +++ b/lib/services/bus_info_service.dart @@ -32,12 +32,7 @@ Future> fetchNextBusStops(String busID) async { } // for bus stops -Future<(List, bool)> fetchStopData(String stopID) async { - - final prefs = await SharedPreferences.getInstance(); - final list = prefs.getStringList('favorite_stops') ?? []; - bool toReturn = list.contains(stopID); - +Future> fetchStopData(String stopID) async { Uri url; if (int.tryParse(stopID) != null) { @@ -53,8 +48,8 @@ Future<(List, bool)> fetchStopData(String stopID) async { if (response.statusCode == 200) { final Map data = json.decode(response.body); final List predictions = data['bustime-response']['prd']; - return (predictions.map((json) => BusWithPrediction.fromJson(json)).toList(), toReturn); + return predictions.map((json) => BusWithPrediction.fromJson(json)).toList(); } else { throw Exception('Failed to load bus stops'); } -} +} \ No newline at end of file diff --git a/lib/services/floorplan_marker_service.dart b/lib/services/floorplan_marker_service.dart new file mode 100644 index 0000000..28157ee --- /dev/null +++ b/lib/services/floorplan_marker_service.dart @@ -0,0 +1,117 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bluebus/services/floorplan_style.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Builds and caches the bitmaps used by the floorplan layer's markers: POI +/// icons loaded from assets, and room labels rasterized from text. +/// +/// Both caches are static and keyed by content, so the same label or icon is +/// only ever built once no matter how many floors or buildings ask for it. +class FloorplanMarkerService { + static final Map _iconCache = {}; + static final Map _labelCache = {}; + + /// The icon for a POI type, or null if that type doesn't have one. + static Future icon(String poiType) async { + final cached = _iconCache[poiType]; + if (cached != null) return cached; + + final String? assetPath = FLOORPLAN_POI_ICONS[poiType]; + if (assetPath == null) return null; + + try { + final BitmapDescriptor descriptor = await BitmapDescriptor.asset( + ImageConfiguration.empty, + assetPath, + width: FLOORPLAN_ICON_SIZE, + height: FLOORPLAN_ICON_SIZE, + ); + _iconCache[poiType] = descriptor; + return descriptor; + } catch (err) { + debugPrint('FloorplanMarkerService: could not load $assetPath ($err)'); + return null; + } + } + + /// Rasterizes [text] into a marker bitmap. + /// + /// Markers are positioned by their center, so to push a label off center the + /// bitmap is grown asymmetrically: padding added above the text moves the + /// bitmap's center up, which pushes the text down relative to the anchor + /// point. [belowIcon] uses that to drop the label clear of a POI icon drawn + /// at the same position. + static Future label( + String text, { + bool belowIcon = false, + }) async { + final String cacheKey = '$text|$belowIcon'; + final cached = _labelCache[cacheKey]; + if (cached != null) return cached; + + const double scale = FLOORPLAN_LABEL_PIXEL_RATIO; + final double haloWidth = FLOORPLAN_LABEL_HALO_WIDTH * scale; + + // The same text painted twice: a thick stroke for the halo, then the fill. + TextPainter paintedText(Paint? stroke) => TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: FLOORPLAN_LABEL_FONT_SIZE * scale, + fontWeight: FontWeight.w600, + fontFamily: 'Urbanist', + color: stroke == null ? FLOORPLAN_LABEL_COLOR : null, + foreground: stroke, + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(); + + final TextPainter halo = paintedText( + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = haloWidth + ..strokeJoin = StrokeJoin.round + ..color = FLOORPLAN_LABEL_HALO_COLOR, + ); + final TextPainter fill = paintedText(null); + + // Enough margin that the halo stroke isn't clipped at the bitmap edges. + final double margin = haloWidth; + + // How far below the anchor point the text should end up. + final double dropBelowAnchor = belowIcon + ? (FLOORPLAN_ICON_SIZE / 2 + FLOORPLAN_ICON_LABEL_GAP) * scale + + fill.height / 2 + : 0.0; + + final double width = fill.width + margin * 2; + final double height = fill.height + margin * 2 + dropBelowAnchor * 2; + final Offset textOrigin = Offset(margin, margin + dropBelowAnchor * 2); + + final ui.PictureRecorder recorder = ui.PictureRecorder(); + final Canvas canvas = Canvas(recorder); + halo.paint(canvas, textOrigin); + fill.paint(canvas, textOrigin); + + final ui.Image image = await recorder.endRecording().toImage( + width.ceil(), + height.ceil(), + ); + final ByteData? bytes = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + image.dispose(); + + final BitmapDescriptor descriptor = BitmapDescriptor.bytes( + bytes!.buffer.asUint8List(), + imagePixelRatio: scale, + ); + _labelCache[cacheKey] = descriptor; + return descriptor; + } +} diff --git a/lib/services/floorplan_service.dart b/lib/services/floorplan_service.dart new file mode 100644 index 0000000..8e19ccb --- /dev/null +++ b/lib/services/floorplan_service.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:bluebus/models/floorplan.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// A floorplan together with the real world positions of the two waypoint POIs +/// it carries, which is everything needed to place it on the map. +class GeoreferencedFloorplan { + final Floorplan floorplan; + + /// Real world position of the floor's `waypoint1` POI. + final LatLng waypoint1; + + /// Real world position of the floor's `waypoint2` POI. + final LatLng waypoint2; + + const GeoreferencedFloorplan({ + required this.floorplan, + required this.waypoint1, + required this.waypoint2, + }); +} + +/// Loads floorplans. +/// +/// Right now floorplans ship with the app as a bundled asset. Eventually the +/// backend will serve them (behind a security check), at which point only the +/// loading in here has to change -- everything downstream works off +/// [GeoreferencedFloorplan]. +class FloorplanService { + static const String _duderstadtAsset = 'assets/floorplans/dudeMap.json'; + + // TODO: The backend should send these alongside the floorplan once floorplans + // are served rather than bundled. They're the surveyed real world positions + // of the `waypoint1` / `waypoint2` POIs in the Duderstadt plan. + static const LatLng _duderstadtWaypoint1 = LatLng( + 42.29127928001248, + -83.71682770735084, + ); + static const LatLng _duderstadtWaypoint2 = LatLng( + 42.29127928001248, + -83.7166406232747, + ); + + static Future? _duderstadt; + + /// The one floorplan we currently have. Parsed once and shared; repeat calls + /// get the same future back. + static Future loadDuderstadt() { + return _duderstadt ??= _loadFromAsset( + _duderstadtAsset, + waypoint1: _duderstadtWaypoint1, + waypoint2: _duderstadtWaypoint2, + ); + } + + static Future _loadFromAsset( + String assetPath, { + required LatLng waypoint1, + required LatLng waypoint2, + }) async { + final String raw = await rootBundle.loadString(assetPath); + final Map json = jsonDecode(raw) as Map; + return GeoreferencedFloorplan( + floorplan: Floorplan.fromJson(json), + waypoint1: waypoint1, + waypoint2: waypoint2, + ); + } +} diff --git a/lib/services/floorplan_style.dart b/lib/services/floorplan_style.dart new file mode 100644 index 0000000..85d1f8d --- /dev/null +++ b/lib/services/floorplan_style.dart @@ -0,0 +1,136 @@ +import 'package:bluebus/models/floorplan.dart'; +import 'package:flutter/material.dart'; + +/// Everything about how a floorplan *looks*, kept in one place so the design +/// can be retuned without touching the drawing code. + +/// How much of a floorplan we draw, chosen from the current camera zoom. +/// +/// The levels are ordered: each one draws everything the previous one did, +/// plus more detail. +enum FloorplanDetailLevel { + /// Too far out for the building to be meaningful -- draw nothing. + hidden, + + /// A single filled blob showing the building's footprint. + outline, + + /// The full plan: base footprint, walls and rooms. + full, + + /// The full plan plus room labels and POI icons. + labeled, +} + +/// Zoom at which the building footprint starts being drawn at all. +const double FLOORPLAN_OUTLINE_ZOOM = 14.0; + +/// Zoom at which the plain footprint is swapped for the detailed floorplan. +const double FLOORPLAN_DETAIL_ZOOM = 17.5; + +/// Zoom at which room labels and POI icons appear on top of the floorplan. +const double FLOORPLAN_LABEL_ZOOM = 18.75; + +/// Picks the detail level for a camera zoom. +FloorplanDetailLevel floorplanDetailLevelForZoom(double zoom) { + if (zoom >= FLOORPLAN_LABEL_ZOOM) return FloorplanDetailLevel.labeled; + if (zoom >= FLOORPLAN_DETAIL_ZOOM) return FloorplanDetailLevel.full; + if (zoom >= FLOORPLAN_OUTLINE_ZOOM) return FloorplanDetailLevel.outline; + return FloorplanDetailLevel.hidden; +} + +// --- Colors ----------------------------------------------------------------- + +/// Fill of the zoomed-out building footprint. +const Color FLOORPLAN_FAR_OUTLINE_FILL = Color(0xFFFFE594); + +/// Stroke of the zoomed-out building footprint. +const Color FLOORPLAN_FAR_OUTLINE_STROKE = Color(0xFFCDBC8A); + +/// Fill of the footprint once the detailed plan is showing. Everything else is +/// drawn on top of this. +const Color FLOORPLAN_BASE_FILL = Color(0xFFEAE9F4); + +/// The one dark blue used for every stroke in the detailed plan: the footprint, +/// the walls and the room outlines. +const Color FLOORPLAN_STROKE = Color(0xFF133E65); + +/// Fill used for room types we don't have a specific color for. +const Color FLOORPLAN_DEFAULT_ROOM_FILL = Color(0xFF90B8D5); + +/// Room fill per room type. Types missing from this map fall back to +/// [FLOORPLAN_DEFAULT_ROOM_FILL]. +const Map FLOORPLAN_ROOM_FILLS = { + FloorplanTypes.room: Color(0xFF90B8D5), + FloorplanTypes.elevator: Color(0xFF5E96B3), + FloorplanTypes.stairway: Color(0xFF5E96B3), + FloorplanTypes.escalator: Color(0xFF5E96B3), + FloorplanTypes.inaccessible: Color(0xFF133E65), + FloorplanTypes.information: Color(0xFF72BD9B), + FloorplanTypes.food: Color(0xFF72BD9B), + FloorplanTypes.femaleBathroom: Color(0xFF8973B9), + FloorplanTypes.maleBathroom: Color(0xFF8973B9), + FloorplanTypes.neutralBathroom: Color(0xFF8973B9), +}; + +Color floorplanRoomFill(String roomType) => + FLOORPLAN_ROOM_FILLS[roomType] ?? FLOORPLAN_DEFAULT_ROOM_FILL; + +// --- Stroke widths ---------------------------------------------------------- + +const int FLOORPLAN_FAR_OUTLINE_STROKE_WIDTH = 2; +const int FLOORPLAN_BASE_STROKE_WIDTH = 3; +const int FLOORPLAN_WALL_STROKE_WIDTH = 2; +const int FLOORPLAN_ROOM_STROKE_WIDTH = 1; + +// --- Draw order ------------------------------------------------------------- +// +// Google Maps shares one z-index space across polygons and polylines (markers +// always sit above both), so these values order the whole detailed plan. +// Walls are drawn last, over the room fills, so no wall is ever painted over. + +const int FLOORPLAN_Z_BASE = 0; +const int FLOORPLAN_Z_ROOMS = 1; +const int FLOORPLAN_Z_WALLS = 2; +const int FLOORPLAN_Z_MARKERS = 1000; + +// --- Labels ----------------------------------------------------------------- + +/// Font size of a room label, in logical pixels. +const double FLOORPLAN_LABEL_FONT_SIZE = 11.0; + +const Color FLOORPLAN_LABEL_COLOR = Color(0xFF133E65); + +/// Halo drawn behind label text so it stays readable over any room fill. +const Color FLOORPLAN_LABEL_HALO_COLOR = Color(0xCCFFFFFF); +const double FLOORPLAN_LABEL_HALO_WIDTH = 3.0; + +/// Resolution multiplier used when rasterizing label text, so labels stay +/// crisp on high density screens. +const double FLOORPLAN_LABEL_PIXEL_RATIO = 3.0; + +/// POI types that get a text label drawn at their position. +const Set FLOORPLAN_LABELED_POI_TYPES = { + FloorplanTypes.room, + FloorplanTypes.food, +}; + +// --- Icons ------------------------------------------------------------------ + +/// Rendered size of a POI icon, in logical pixels. +const double FLOORPLAN_ICON_SIZE = 22.0; + +/// Gap between a POI icon and the label underneath it, in logical pixels. +const double FLOORPLAN_ICON_LABEL_GAP = 2.0; + +/// Icon asset per POI type. Types missing from this map get no icon. +const Map FLOORPLAN_POI_ICONS = { + FloorplanTypes.elevator: 'assets/floorplans/icons/elevator.png', + FloorplanTypes.stairway: 'assets/floorplans/icons/stairs.png', + FloorplanTypes.escalator: 'assets/floorplans/icons/escalator.png', + FloorplanTypes.information: 'assets/floorplans/icons/info.png', + FloorplanTypes.food: 'assets/floorplans/icons/food.png', + FloorplanTypes.femaleBathroom: 'assets/floorplans/icons/bathroomF.png', + FloorplanTypes.maleBathroom: 'assets/floorplans/icons/bathroomM.png', + FloorplanTypes.neutralBathroom: 'assets/floorplans/icons/bathroomN.png', +}; diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart new file mode 100644 index 0000000..505f1b2 --- /dev/null +++ b/lib/services/map_image_service.dart @@ -0,0 +1,695 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:vector_math/vector_math_64.dart' hide Colors; + +const STOP_ICON_WIDTH = 65; +const STOP_ICON_HEIGHT = 65; + +const FANCY_STOP_ICON_XHEADROOM = 20; +const FANCY_STOP_ICON_YHEADROOM = 20; + +const FANCY_STOP_ICON_MARGIN = 10; +const FANCY_STOP_ICON_SMALLMARGIN = 5; + +const ROW_ICON_SIZE = + (STOP_ICON_HEIGHT - + FANCY_STOP_ICON_SMALLMARGIN + + FANCY_STOP_ICON_YHEADROOM * 2) / + 2; + +const FANCY_STOP_ICON_WIDTH = + FANCY_STOP_ICON_XHEADROOM + + STOP_ICON_WIDTH + + FANCY_STOP_ICON_MARGIN + + (ROW_ICON_SIZE + FANCY_STOP_ICON_SMALLMARGIN) * + 3; // Add enough space for the stop icon, margins, and 3 route icons + +const FANCY_STOP_ICON_HEIGHT = 65 + FANCY_STOP_ICON_XHEADROOM * 2; + +class MapImageService { + // Route specific bus icons + static Map _routeBusIcons = {}; + static BitmapDescriptor? _busIcon; + + static Map _fancyStopIconsCache = + {}; // Cache for fancy stop icons. Key format is "[rotation],[buscode],[buscode],...", such as "274,NW,CS,CX,BB" + static Map _normalStopIconsCache = {}; + + // TODO: Maybe make this manage stop icons too? + + static BitmapDescriptor stopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + static BitmapDescriptor rideStopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + static BitmapDescriptor favStopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + static BitmapDescriptor favRideStopIcon = + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure); + static BitmapDescriptor? _navigationBusStopIcon; + + static ui.Image? _stopIconImage; + static ui.Image? _rideStopIconImage; + static ui.Image? _favStopIconImage; + static ui.Image? _favRideStopIconImage; + static ui.Image? _navigationBusStopImage; + + static ByteData? _stopIconBytes; + static ByteData? _rideStopIconBytes; + static ByteData? _favStopIconBytes; + static ByteData? _favRideStopIconBytes; + static ByteData? _navigationBusStopBytes; + + static bool _stopIconsInitialized = false; + + static Future getFrontEndImageVer() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + + final int counter = prefs.getInt('imageVer') ?? 0; + + // if null, save the default value + if (prefs.getInt('imageVer') == null) { + await prefs.setInt('imageVer', counter); + } + + return counter; + } + + static Future setFrontEndImageVer(int a) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setInt('imageVer', a); + } + + // Check if cached assets need to be refreshed based on backend version + static Future _shouldRefreshCachedAssets() async { + int frontEndVer; + frontEndVer = await getFrontEndImageVer(); + + try { + final backendImageVersion = await _getBackendImageVersion(); + if (backendImageVersion == null) { + return true; // if you can't reach the server give up + } + if (int.parse(backendImageVersion) == frontEndVer) { + return false; + } else { + await setFrontEndImageVer(int.parse(backendImageVersion)); + return true; + } + } catch (e) { + // On error, assume refresh needed + return true; + } + } + + // Get minimum supported version from backend + static Future _getBackendImageVersion() async { + try { + final response = await http.get( + Uri.parse('${BACKEND_URL}/getStartupInfo'), + ); + if (response.statusCode == 200) { + final data = json.decode(response.body); + return data['bus_image_version'] as String?; + } + } catch (e) { + // Return null on error - will trigger refresh + } + return null; + } + + // Load cached bus icon from SharedPreferences + static Future _loadCachedBusIcon(String routeId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedBytes = prefs.getString('bus_icon_$routeId'); + if (cachedBytes != null) { + final bytes = base64.decode(cachedBytes); + return BitmapDescriptor.fromBytes(bytes); + } + } catch (e) { + // Return null on error + } + return null; + } + + // Save bus icon to cache + static Future _cacheBusIcon(String routeId, Uint8List bytes) async { + try { + final prefs = await SharedPreferences.getInstance(); + final base64String = base64.encode(bytes); + await prefs.setString('bus_icon_$routeId', base64String); + } catch (e) { + // Ignore cache save errors + } + } + + // Set a fallback bus icon for a route + static void _setFallbackBusIcon(String routeId) { + try { + final routeColor = RouteColorService.getRouteColor(routeId); + _routeBusIcons[routeId] = BitmapDescriptor.defaultMarkerWithHue( + colorToHue(routeColor), + ); + } catch (e) { + // error handling + } + } + + // Load a specific route's bus icon + static Future _loadRouteBusIcon(String routeId, String imageUrl) async { + try { + final response = await http.get(Uri.parse(imageUrl)); + + if (response.statusCode == 200) { + final imageBytes = response.bodyBytes; + + // Adjust bus icon size here + try { + final codec = await ui.instantiateImageCodec( + imageBytes, + targetWidth: 125, + targetHeight: 125, + ); + final frame = await codec.getNextFrame(); + final data = await frame.image.toByteData( + format: ui.ImageByteFormat.png, + ); + + if (data != null) { + final processedBytes = data.buffer.asUint8List(); + _routeBusIcons[routeId] = BitmapDescriptor.fromBytes( + processedBytes, + ); + + // Cache the processed icon for future use + await _cacheBusIcon(routeId, processedBytes); + } else { + _setFallbackBusIcon(routeId); + } + } catch (codecError) { + _setFallbackBusIcon(routeId); + } + } else { + // Set fallback icon for this route + _setFallbackBusIcon(routeId); + } + } catch (e) { + // Set fallback icon for this route + _setFallbackBusIcon(routeId); + } + } + + // Load route specific bus icons from the backend + static Future _loadRouteSpecificBusIcons() async { + try { + if (!RouteColorService.isInitialized) { + await RouteColorService.initialize(); + } + + // Check if we need to update cached assets based on version + final shouldRefreshAssets = await _shouldRefreshCachedAssets(); + + final routeIds = RouteColorService.definedRouteIds; + + for (final routeId in routeIds) { + // Try to load from cache first if not forcing refresh + if (!shouldRefreshAssets) { + final cachedIcon = await _loadCachedBusIcon(routeId); + if (cachedIcon != null) { + _routeBusIcons[routeId] = cachedIcon; + continue; + } + } + + // Load from backend if cache miss or forcing refresh + final imageUrl = RouteColorService.getRouteImageUrl(routeId); + if (imageUrl != null) { + await _loadRouteBusIcon(routeId, imageUrl); + } else { + _setFallbackBusIcon(routeId); + } + } + } catch (e) { + // Fallback to default bus icon + _busIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueYellow, + ); + } + } + + static Future _loadStopIcons() async { + try { + _stopIconBytes = await rootBundle.load('assets/busStop.png'); + _rideStopIconBytes = await rootBundle.load('assets/busStopRide.png'); + _favStopIconBytes = await rootBundle.load('assets/favbusStop.png'); + _favRideStopIconBytes = await rootBundle.load( + 'assets/favbusStopRide.png', + ); + _navigationBusStopBytes = await rootBundle.load('assets/getOff.png'); + + _stopIconImage = await _decode(_stopIconBytes!); + _rideStopIconImage = await _decode(_rideStopIconBytes!); + _favStopIconImage = await _decode(_favStopIconBytes!); + _favRideStopIconImage = await _decode(_favRideStopIconBytes!); + _navigationBusStopImage = await _decode(_navigationBusStopBytes!); + + // Load stop icons + stopIcon = await MapImageService.resizeImage(_stopIconBytes!); + rideStopIcon = await MapImageService.resizeImage(_rideStopIconBytes!); + favStopIcon = await MapImageService.resizeImage(_favStopIconBytes!); + favRideStopIcon = await MapImageService.resizeImage( + _favRideStopIconBytes!, + ); + _navigationBusStopIcon = await MapImageService.resizeImage( + _navigationBusStopBytes!, + ); + + _stopIconsInitialized = true; + } catch (e) { + debugPrint("Error! $e"); + // Fallback to default markers if custom loading fails + // These are now set as initial values + } + } + + static Future ensureRouteIconIsLoaded( + String routeId, + ) async { + if (_routeBusIcons.containsKey(routeId)) + return _routeBusIcons[routeId]; // Already in cache, no need to do anything else + + final prefs = await SharedPreferences.getInstance(); + final cachedBytes = prefs.getString('bus_icon_$routeId'); + if (cachedBytes != null) { + final bytes = base64.decode(cachedBytes); + _routeBusIcons[routeId] = BitmapDescriptor.fromBytes(bytes); + return _routeBusIcons[routeId]; // Icon is already cached! + } + + // Load bus icon for this route if not already loaded + // if (!_routeBusIcons.containsKey(routeId)) { + final imageUrl = RouteColorService.getRouteImageUrl(routeId); + if (imageUrl != null) { + await _loadRouteBusIcon(routeId, imageUrl); + return _routeBusIcons[routeId]; + } + // } + } + + // Check if a route has specific bus icon loaded + bool hasRouteBusIcon(String routeId) { + return _routeBusIcons.containsKey(routeId); + } + + // Get the number of route bus icons loaded + int get loadedBusIconCount => _routeBusIcons.length; + + // Refresh route specific bus icons + static void refreshRouteBusIcons() { + _routeBusIcons.clear(); + _loadRouteSpecificBusIcons(); + } + + // NEXT STEPS TODO: Figure out how to create a Canvas that's the right size, add the stop image to it, and then add extra stuff (e.g. rectangles) just to show we can + static Future resizeImage(ByteData image) async { + // Load and resize stop icon + final stopBytes = image; + final stopCodec = await ui.instantiateImageCodec( + stopBytes.buffer.asUint8List(), + targetWidth: 65, + targetHeight: 65, + ); + final stopFrame = await stopCodec.getNextFrame(); + final stopData = await stopFrame.image.toByteData( + format: ui.ImageByteFormat.png, + ); + return BitmapDescriptor.fromBytes(stopData!.buffer.asUint8List()); + } + + static bool isBusIconAvailable(Bus bus) { + return _routeBusIcons.containsKey(bus.routeId) || + _busIcon != + null; // TODO: Should this include a check for _busIcon like in getBusIcon()? + } + + static BitmapDescriptor getBusIcon(Bus bus) { + final routeColor = + bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId); + + if (_routeBusIcons.containsKey(bus.routeId)) { + return _routeBusIcons[bus.routeId]!; + } else if (_busIcon != null) { + return _busIcon!; + } else { + debugPrint( + "WARN: getBusIcon found no icon currently loaded, returning defaultMarkerWithHue", + ); + return BitmapDescriptor.defaultMarkerWithHue(colorToHue(routeColor)); + } + } + + static Future _decode(ByteData data) async { + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + return frame.image; + } + + static void drawRouteIconOntoCanvas( + Canvas canvas, + int x, + int y, + int width, + int height, + String routeId, + bool isRide, + ) { + final paint = Paint() + ..color = RouteColorService.getRouteColor(routeId) + ..style = PaintingStyle.fill; + + final textPainter = TextPainter( + text: TextSpan( + text: routeId, + style: TextStyle( + fontSize: width / 2, + fontWeight: FontWeight.w900, + letterSpacing: -1, + fontFamily: 'Urbanist', + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(minWidth: 0, maxWidth: width.toDouble()); + + if (isRide) { + double rideIconHeight = height.toDouble() * 0.75; + double marginTop = (height - rideIconHeight) / 2; + final rrect = RRect.fromRectAndRadius( + Rect.fromLTWH( + x.toDouble(), + y.toDouble() + marginTop, + width.toDouble(), + rideIconHeight, + ), + Radius.circular(rideIconHeight / 2), + ); + canvas.drawRRect(rrect, paint); + } else { + canvas.drawCircle( + Offset(x + width / 2, y + height / 2), + width / 2, + paint, + ); + } + textPainter.paint( + canvas, + Offset( + x + width / 2 - (textPainter.width / 2), + y + height / 2 - (textPainter.height / 2), + ), + ); + // textPainter.paint(canvas, Offset(0,0)); + } + + static void drawRouteOverflowIconOntoCanvas( + Canvas canvas, + int x, + int y, + int width, + int height, + int numOverflowed, + ) { + final textPainter = TextPainter( + text: TextSpan( + text: "+$numOverflowed", + style: TextStyle( + fontSize: width * 0.65, + fontWeight: FontWeight.w600, + letterSpacing: -1, + fontFamily: 'Urbanist', + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(minWidth: 0, maxWidth: width.toDouble()); + + textPainter.paint( + canvas, + Offset( + x + width / 2 - (textPainter.width / 2), + y + height / 2 - (textPainter.height / 2), + ), + ); + } + + static void drawRotatedImage( + Canvas canvas, + ui.Image image, + Offset center, + double angleRadians, + ) { + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(angleRadians); + canvas.drawImage( + image, + Offset( + -image.width / 2, + -image.height / 2, + ), // shift so `center` is the pivot + Paint(), + ); + canvas.restore(); + } + + static double degreesToRadians(double degrees) => degrees * math.pi / 180; + + // NEXT STEPS TODO: Pass in a hardcoded list of bus stops and get the circles rendering nicely (as well as the arrow for the bus stop). Also get anchoring and zoom level switching working properly + // + // *** Cache NOT based on stop ID, but based on the routes in the given list (to make our cache more resilient/flexible). Sort the list alphabetically each time + static Future getFancyStopIcon( + String stopId, + bool isFavorite, + bool isRide, + double rotation, + List routesServed, + ) async { + // TODO: Pass in a list of bus route codes here later + + // String cacheKey = rotation.round().toString() + "," + routesServed.join(","); + // String cacheKey = routesServed.join(","); // Temporary, for testing + String cacheKey = "fancyicon_${stopId}_$isFavorite"; + + if (_fancyStopIconsCache.containsKey(cacheKey)) { + return _fancyStopIconsCache[cacheKey]!; + } + + // TODO: Add the bus stop icon type (favorite, nonfavorite, TheRide favorite, etc) + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // debugPrint("Generating icon for ${routesServed.join(",")}"); + + // int total_width = STOP_ICON_WIDTH * 2 + STOP_ICON_WIDTH; + // int total_height = STOP_ICON_HEIGHT; + + final paint = Paint() + ..color = Colors.green + ..style = PaintingStyle.fill; + + try { + if (!_stopIconsInitialized) { + // debugPrint("Stop icons not initialized, loading..."); + await _loadStopIcons(); + } + + ui.Image? targetImage = isFavorite + ? (isRide ? _favRideStopIconImage : _favStopIconImage) + : (isRide ? _rideStopIconImage : _stopIconImage); + + drawRotatedImage( + canvas, + targetImage!, + Offset( + FANCY_STOP_ICON_XHEADROOM.toDouble() + + (STOP_ICON_WIDTH.toDouble() / 2), + FANCY_STOP_ICON_YHEADROOM.toDouble() + + (STOP_ICON_HEIGHT.toDouble() / 2), + ), + degreesToRadians(rotation), + ); + + // canvas.drawImage(_stopIconImage!, Offset(FANCY_STOP_ICON_XHEADROOM.toDouble(), FANCY_STOP_ICON_YHEADROOM.toDouble()), Paint()); // 1 pixel to 1 canvas unit. I'm treating canvas units as pixels here + } catch (err) {} + + // canvas.drawRect(Rect.fromLTWH(STOP_ICON_WIDTH.toDouble(), 0, (FANCY_STOP_ICON_WIDTH - STOP_ICON_WIDTH).toDouble(), STOP_ICON_HEIGHT.toDouble()), paint); + + int maxRouteIconsPerRow = + ((FANCY_STOP_ICON_WIDTH - + FANCY_STOP_ICON_XHEADROOM - + STOP_ICON_WIDTH - + FANCY_STOP_ICON_MARGIN) / + (ROW_ICON_SIZE + FANCY_STOP_ICON_SMALLMARGIN)) + .floor() + .toInt(); + + int xDrawPos = + STOP_ICON_WIDTH + FANCY_STOP_ICON_XHEADROOM + FANCY_STOP_ICON_MARGIN; + int yDrawPos = 0; + + if (routesServed.length <= maxRouteIconsPerRow) { + yDrawPos = (FANCY_STOP_ICON_HEIGHT / 2 - ROW_ICON_SIZE / 2).floor(); + } + + for (int i = 0; i < routesServed.length; i++) { + if (xDrawPos + ROW_ICON_SIZE > FANCY_STOP_ICON_WIDTH) { + // If the route icon is going to get clipped, wrap to the next row + yDrawPos += ROW_ICON_SIZE.toInt() + FANCY_STOP_ICON_SMALLMARGIN; + xDrawPos = + FANCY_STOP_ICON_XHEADROOM + + STOP_ICON_WIDTH + + FANCY_STOP_ICON_MARGIN; + } + + if (i == 5 && routesServed.length > 6) { + // if (i == 5) { + // We're on the last element and there will be overflow + debugPrint("DRAWING OVERFLOW ICON!! $stopId"); + drawRouteOverflowIconOntoCanvas( + canvas, + xDrawPos, + yDrawPos, + ROW_ICON_SIZE.toInt(), + ROW_ICON_SIZE.toInt(), + (routesServed.length - 6) + 1, + ); + break; + } + + String routeId = routesServed[i]; + drawRouteIconOntoCanvas( + canvas, + xDrawPos, // x + yDrawPos, // y + ROW_ICON_SIZE.toInt(), // width + ROW_ICON_SIZE.toInt(), // height + routeId, + isRide, + ); + + xDrawPos += ROW_ICON_SIZE.toInt() + FANCY_STOP_ICON_SMALLMARGIN; + } + + final picture = recorder.endRecording(); + final img = await picture.toImage( + FANCY_STOP_ICON_WIDTH.toInt(), + FANCY_STOP_ICON_HEIGHT, + ); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + + BitmapDescriptor output = BitmapDescriptor.fromBytes( + byteData!.buffer.asUint8List(), + ); + _fancyStopIconsCache[cacheKey] = output; + return output; + } + + static Future getNormalStopIcon( + String stopId, + bool isFavorite, + bool isRide, + double rotation, + ) async { + // String cacheKey = rotation.round().toString() + "," + routesServed.join(","); + // String cacheKey = routesServed.join(","); // Temporary, for testing + String cacheKey = "normalicon_${stopId}_$isFavorite"; + + if (_normalStopIconsCache.containsKey(cacheKey)) { + return _normalStopIconsCache[cacheKey]!; + } + + // TODO: Add the bus stop icon type (favorite, nonfavorite, TheRide favorite, etc) + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // debugPrint("Generating icon for ${routesServed.join(",")}"); + + // int total_width = STOP_ICON_WIDTH * 2 + STOP_ICON_WIDTH; + // int total_height = STOP_ICON_HEIGHT; + + try { + if (!_stopIconsInitialized) { + // debugPrint("Stop icons not initialized, loading..."); + await _loadStopIcons(); + } + + ui.Image? targetImage = isFavorite + ? (isRide ? _favRideStopIconImage : _favStopIconImage) + : (isRide ? _rideStopIconImage : _stopIconImage); + + drawRotatedImage( + canvas, + targetImage!, + Offset( + (STOP_ICON_WIDTH.toDouble() / 2), + (STOP_ICON_HEIGHT.toDouble() / 2), + ), + degreesToRadians(rotation), + ); + + // canvas.drawImage(_stopIconImage!, Offset(FANCY_STOP_ICON_XHEADROOM.toDouble(), FANCY_STOP_ICON_YHEADROOM.toDouble()), Paint()); // 1 pixel to 1 canvas unit. I'm treating canvas units as pixels here + } catch (err) {} + + // canvas.drawRect(Rect.fromLTWH(STOP_ICON_WIDTH.toDouble(), 0, (FANCY_STOP_ICON_WIDTH - STOP_ICON_WIDTH).toDouble(), STOP_ICON_HEIGHT.toDouble()), paint); + + final picture = recorder.endRecording(); + final img = await picture.toImage( + STOP_ICON_WIDTH.toInt(), + STOP_ICON_HEIGHT, + ); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + + BitmapDescriptor output = BitmapDescriptor.fromBytes( + byteData!.buffer.asUint8List(), + ); + _normalStopIconsCache[cacheKey] = output; + return output; + } + + static Offset getFancyStopIconOffset() { + double offsetX = + (STOP_ICON_WIDTH.toDouble() / 2 + FANCY_STOP_ICON_XHEADROOM) / + FANCY_STOP_ICON_WIDTH.toDouble(); + double offsetY = 0.5; + // debugPrint("Offset X: $offsetX, Y: $offsetY"); + return Offset(offsetX, offsetY); + // return Offset(0.5, 0.5); + } + + static BitmapDescriptor? getNavigationBusStop() { + return _navigationBusStopIcon; + } + + static Future loadData() async { + await _loadRouteSpecificBusIcons(); + + await _loadStopIcons(); + } +} diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart new file mode 100644 index 0000000..41ff45d --- /dev/null +++ b/lib/services/map_layers/base_routes_layer.dart @@ -0,0 +1,360 @@ +import 'dart:math' as math; + +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/bus_stop.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +const double FANCY_ICONS_ZOOM_THRESHOLD = 17.55; +const int STAGGERED_RELOAD_CHUNK_SIZE = 50; // Load this many markers at a time when doing staggered reloads + +class StopReloadEntry { + final BusStop stop; + final String routeKey; + final Color routeColor; + + StopReloadEntry({required this.stop, required this.routeKey, required this.routeColor}); +} + +class BaseRoutesLayer extends CompositeMapLayer { + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + @override + Function(LatLng) showRipple = (LatLng location) { + debugPrint("Warning! showRipple called but no callback was registered"); + }; + Function(BusStop) onStopClicked = (BusStop s) { + debugPrint("Warning! onStopClicked called but no callback was registered"); + }; + + bool displayFancyIcons = false; // Whether we're zoomed in far enough to show fancy stop icons + + List routesCache = []; + Map> stopIdToRouteIds = {}; + Map stopIdToStop = {}; + + Set favoriteStops = {}; + Set selectedRoutes = {}; + + BitmapDescriptor _stopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + BitmapDescriptor _rideStopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + BitmapDescriptor _favStopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + BitmapDescriptor _favRideStopIcon = BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ); + + LatLng viewportLocation = LatLng(42.280427, -83.736522); // Default/dummy coordinates we expect to get overwritten as soon as the user pans the map + + // Map> markersCache = {}; + + int _markerGeneration = 0; // This increments each time all the markers are regenerated + Map markersCache = {}; // New mapping scheme: Stop ID -> Marker. Prevents duplicates + Map _markerBuiltAtGeneration = {}; // This is used to prevent duplicate stop markers while ensuring every marker gets refreshed. This maps (Stop ID) -> (_markerGeneration value when the marker was created). Used to check if a particular marker needs to be refreshed + + Map polylinesCache = {}; + + void cacheRoutes(List routes) async { + // Called from inside _loadAllData() inside map_screen.dart + routesCache = routes; + + for (final r in routesCache) { + for ((int, BusStop) stopInfo in r.stops) { + stopInfo.$2; + // stopIdToRouteId["hello"].add("Hi"); + stopIdToRouteIds.putIfAbsent(stopInfo.$2.id, () => {}).add(r.routeId); + + if (!stopIdToStop.containsKey(stopInfo.$2.id)) { + stopIdToStop[stopInfo.$2.id] = stopInfo.$2; + } + } + } + + // debugPrint("Pre-generating fancy stop icons"); + for (MapEntry entry in stopIdToRouteIds.entries) { + try { + await MapImageService.getFancyStopIcon( + entry.key, + favoriteStops.contains(entry.key), + stopIdToStop[entry.key]?.isRide ?? false, + stopIdToStop![entry.key]!.rotation, + entry.value.toList() + ); // Pre-cache each icon so it's faster later! + await MapImageService.getNormalStopIcon( + entry.key, + favoriteStops.contains(entry.key), + stopIdToStop[entry.key]?.isRide ?? false, + stopIdToStop![entry.key]!.rotation + ); + } catch (err) {} + } + + // TODO: Sort the routeIDs for each key? Do we need to do this or is it already sorted? (It might be already sorted since we're going through the same ordering of routes each time) + + await reloadAllMarkers(); + reloadPolylines(); + + if (isVisible) onUpdate(); + } + + void init( + Set favoriteStops_in, + Set selectedRoutes_in, + Function(BusStop) onStopClicked_in, + ) { + favoriteStops = favoriteStops_in; + selectedRoutes = selectedRoutes_in; + onStopClicked = onStopClicked_in; + _loadCustomMarkers(); + } + + void reload() async { + await reloadAllMarkers(); + reloadPolylines(); + if (isVisible) onUpdate(); + } + + // TODO: Add caching so this doesn't have to recompute markers for each stop each time + + @override + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) async { + + + viewportLocation = newPosition.target; + + if (oldPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD) { + displayFancyIcons = true; + reloadAllMarkersStaggered(); + } else if (oldPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD) { + displayFancyIcons = false; + reloadAllMarkersStaggered(); + } + } + + double getSquaredDistanceBetween(LatLng a, LatLng b) { + double lat_delta = a.latitude - b.latitude; + double lon_delta = a.longitude - b.longitude; + return lat_delta * lat_delta + lon_delta * lon_delta; // a^2 + b^2: Pythagorean theorem, sans square root (to make the calculation a little faster) + } + + + + List stopsToReload = []; + int stopsToReloadCursor = 0; + + void preprocessStopsToReload(List routes) { + // Fills the stopsToReload List and sorts them by distance to the viewport + stopsToReload.clear(); + stopsToReloadCursor = 0; + + for (final r in routes) { // used to be routesCache + if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes + + + + // TODO: VVV Save these two variables in some data structure somewhere, or maybe alongside the Stop in the stopsToReload + + // Create unique key for each route variant (content-based hash) + final routeKey = '${r.routeId}_${Object.hashAll(r.points)}'; + + // Use backend color if available, otherwise fallback to service + final routeColor = r.color ?? RouteColorService.getRouteColor(r.routeId); + + for (final (_, stop) in r.stops) { + // iterate through all stops in this route + + if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { + continue; // This is a duplicate marker that has already been updated. Skip it + } + _markerBuiltAtGeneration[stop.id] = _markerGeneration; + stopsToReload.add(StopReloadEntry(stop: stop, routeKey: routeKey, routeColor: routeColor)); + + } + } + + stopsToReload.sort((a, b) => getSquaredDistanceBetween(a.stop.location, viewportLocation).compareTo(getSquaredDistanceBetween(b.stop.location, viewportLocation))); // Sort by distance to the viewport + + } + + Future reloadPreprocessedMarkersSegment(int markersToReload) async { + + int stopIndex = stopsToReloadCursor + markersToReload; + + for (; stopsToReloadCursor < math.min(stopsToReload.length, stopIndex); stopsToReloadCursor++) { + + + // iterate through all stops in this route + StopReloadEntry entry = stopsToReload[stopsToReloadCursor]; + + // List routesServed = ["BB", "CS", "CN", "CSX"]; + Set routesServed = stopIdToRouteIds[entry.stop.id] ?? {}; + + final marker = Marker( + zIndexInt: + 2000, // Put bus stops on top of buses, since all bus Z-indexes are between 0 and 999 + markerId: MarkerId('stop_${entry.stop.id}_${entry.routeKey}'), + position: entry.stop.location, + flat: true, + icon: + // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + (displayFancyIcons) + ? ( + await MapImageService.getFancyStopIcon( + entry.stop.id, + favoriteStops.contains(entry.stop.id), + entry.stop.isRide, + entry.stop.rotation, + routesServed.toList() + ) + ) : ( + + await MapImageService.getNormalStopIcon( + entry.stop.id, + favoriteStops.contains(entry.stop.id), + entry.stop.isRide, + entry.stop.rotation + ) + // favoriteStops.contains(entry.stop.id) // Used to be isFavorite + // ? (entry.stop.isRide ? _favRideStopIcon : _favStopIcon) + // : (entry.stop.isRide ? _rideStopIcon : _stopIcon) + ), + consumeTapEvents: true, + onTap: () { + showRipple(entry.stop.location); + onStopClicked(entry.stop); + }, + rotation: 0.0, + // rotation: displayFancyIcons ? 0.0 : entry.stop.rotation, + anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + ); + + markersCache[entry.stop.id] = marker; + + } + markers = markersCache.values.toSet(); // Update global markers list + } + + + Future reloadAllMarkers() async { + try { + markersCache.clear(); + _markerGeneration++; + + preprocessStopsToReload(routesCache); + await reloadPreprocessedMarkersSegment(stopsToReload.length); // Reload ALL the markers at once. This also updates the markers variable + + } catch (err) { + debugPrint("Error: $err"); + } + } + + Future reloadAllMarkersStaggered() async { + // Accomplishes the same function as reloadAllMarkers(), but for big marker changes (i.e. adding fancy stop icons) where reloading everything on one frame causes lots of stuttering. It spreads the work across several frames to reduce jank + _markerGeneration++; + + int initialMarkerGeneration = _markerGeneration; + + preprocessStopsToReload(routesCache); + + while (stopsToReloadCursor < stopsToReload.length) { + + if (initialMarkerGeneration < _markerGeneration) break; // Race condition prevention--if _markerGeneration is newer, then some future call to reloadAllMarkersStaggered() is already happening and this one should stop + + await reloadPreprocessedMarkersSegment(STAGGERED_RELOAD_CHUNK_SIZE); + if (isVisible) onUpdate(); + await SchedulerBinding.instance.endOfFrame; + await Future.delayed(Duration(milliseconds: 200)); + } + + if (isVisible) onUpdate(); + + } + + void reloadPolylines() { + polylinesCache.clear(); + + for (final r in routesCache) { + if (!selectedRoutes.contains(r.routeId)) + continue; // Skip deselected routes + + // Create unique key for each route variant (content-based hash) + final routeKey = '${r.routeId}_${Object.hashAll(r.points)}'; + // Use backend color if available, otherwise fallback to service + final routeColor = r.color ?? RouteColorService.getRouteColor(r.routeId); + + if (!polylinesCache.containsKey(routeKey)) { + polylinesCache[routeKey] = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId(routeKey), + points: r.points, + color: routeColor, + width: 4, + ); + } + } + + polylines = polylinesCache.values.toSet(); + } + + @override + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + @override + void setShowRipple(Function(LatLng) callback) { + showRipple = callback; + } + + Future _loadCustomMarkers() async { + try { + // Load stop icons + _stopIcon = await MapImageService.resizeImage( + await rootBundle.load('assets/busStop.png'), + ); + _rideStopIcon = await MapImageService.resizeImage( + await rootBundle.load('assets/busStopRide.png'), + ); + _favStopIcon = await MapImageService.resizeImage( + await rootBundle.load('assets/favbusStop.png'), + ); + _favRideStopIcon = await MapImageService.resizeImage( + await rootBundle.load('assets/favbusStopRide.png'), + ); + + } catch (e) { + // Fallback to default markers if custom loading fails + // These are now set as initial values + // _stopIcon = BitmapDescriptor.defaultMarkerWithHue( + // BitmapDescriptor.hueAzure, + // ); + // _rideStopIcon = BitmapDescriptor.defaultMarkerWithHue( + // BitmapDescriptor.hueAzure, + // ); + // _favStopIcon = BitmapDescriptor.defaultMarkerWithHue( + // BitmapDescriptor.hueAzure, + // ); + // _favRideStopIcon = BitmapDescriptor.defaultMarkerWithHue( + // BitmapDescriptor.hueAzure, + // ); + } + } +} diff --git a/lib/services/map_layers/demo_buildings_layer.dart b/lib/services/map_layers/demo_buildings_layer.dart new file mode 100644 index 0000000..7949c72 --- /dev/null +++ b/lib/services/map_layers/demo_buildings_layer.dart @@ -0,0 +1,204 @@ +// --------------------------------------------------------------------------- +// DEMO ONLY -- REMOVE AFTER THE DEMO. +// +// We only have real floorplan data for one building (the Duderstadt), but the +// demo wants the map to look like it knows about a whole campus. This layer +// fakes that by drawing hand-traced building footprints from a CSV, styled to +// match the zoomed-out footprint the real FloorplansLayer draws. They never +// gain any detail when you zoom in -- they're outlines and nothing more. +// +// To rip it out, in this order: +// 1. delete this file +// 2. delete assets/floorplans/demoPolygons.csv +// 3. delete the three lines tagged `// DEMO BUILDINGS` in +// lib/screens/map_screen.dart (grep for the tag) +// +// Nothing else in the app references any of it. +// --------------------------------------------------------------------------- + +import 'dart:convert'; + +import 'package:bluebus/services/floorplan_style.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Draws static building footprints parsed from a CSV of WKT polygons. +/// +/// Loads itself on construction, so wiring it up is just adding it to the +/// map's layer list. Follows the same zoom cutoff as the real floorplans +/// ([FLOORPLAN_OUTLINE_ZOOM]) so the fake buildings appear and disappear +/// alongside the real one. +class DemoBuildingsLayer extends CompositeMapLayer { + static const String _asset = 'assets/floorplans/demoPolygons.csv'; + + @override + bool isVisible = true; + @override + Set polygons = const {}; + @override + Set polylines = const {}; + @override + Set markers = const {}; + @override + Function() onUpdate = () {}; + + /// Every footprint from the CSV, built once at load. + Set _footprints = const {}; + + /// Matches the map's initial camera, since [onCameraMove] only fires once + /// the user actually moves. + FloorplanDetailLevel _detailLevel = floorplanDetailLevelForZoom( + INITIAL_MAP_ZOOM, + ); + + Future? _loading; + + DemoBuildingsLayer() { + load(); + } + + /// Parses the CSV and builds the footprints. Only happens once, however many + /// times this is called. + Future load() => _loading ??= _load(); + + Future _load() async { + final String raw; + try { + raw = await rootBundle.loadString(_asset); + } catch (err) { + debugPrint('DemoBuildingsLayer: failed to load $_asset ($err)'); + return; + } + + _footprints = _buildFootprints(raw); + _applyDetailLevel(); + if (isVisible) onUpdate(); + } + + @override + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) { + final FloorplanDetailLevel level = floorplanDetailLevelForZoom( + newPosition.zoom, + ); + if (level == _detailLevel) return; + + _detailLevel = level; + _applyDetailLevel(); + if (isVisible) onUpdate(); + } + + @override + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + /// These buildings have no detailed plan to swap in, so the only thing the + /// zoom decides is whether they're drawn at all. + void _applyDetailLevel() { + polygons = _detailLevel == FloorplanDetailLevel.hidden + ? const {} + : _footprints; + } + + // --- Parsing ------------------------------------------------------------- + + /// Turns the CSV into polygons. Rows we can't make sense of are skipped + /// rather than thrown, since a bad row should cost us one building and not + /// the whole layer. + static Set _buildFootprints(String csv) { + final Set result = {}; + final List lines = const LineSplitter().convert(csv); + + // Row 0 is the `WKT,name,description` header. + for (int i = 1; i < lines.length; i++) { + if (lines[i].trim().isEmpty) continue; + + final List fields = _splitCsvLine(lines[i]); + if (fields.isEmpty) continue; + + final List outline = _parseWktPolygon(fields[0]); + if (outline.length < 3) { + debugPrint('DemoBuildingsLayer: skipping unparseable row $i'); + continue; + } + + final String name = fields.length > 1 && fields[1].trim().isNotEmpty + ? fields[1].trim() + : 'row$i'; + + result.add( + Polygon( + polygonId: PolygonId('demo_building_$name'), + points: outline, + fillColor: FLOORPLAN_FAR_OUTLINE_FILL, + strokeColor: FLOORPLAN_FAR_OUTLINE_STROKE, + strokeWidth: FLOORPLAN_FAR_OUTLINE_STROKE_WIDTH, + zIndex: FLOORPLAN_Z_BASE, + ), + ); + } + + return result; + } + + /// Splits one CSV row on commas, ignoring the ones inside quotes -- which + /// the WKT column is full of. `""` inside a quoted field is a literal quote. + static List _splitCsvLine(String line) { + final List fields = []; + final StringBuffer field = StringBuffer(); + bool inQuotes = false; + + for (int i = 0; i < line.length; i++) { + final String char = line[i]; + + if (inQuotes) { + if (char != '"') { + field.write(char); + } else if (i + 1 < line.length && line[i + 1] == '"') { + field.write('"'); + i++; + } else { + inQuotes = false; + } + } else if (char == '"') { + inQuotes = true; + } else if (char == ',') { + fields.add(field.toString()); + field.clear(); + } else { + field.write(char); + } + } + + fields.add(field.toString()); + return fields; + } + + /// Reads the outer ring out of a `POLYGON ((lon lat, lon lat, ...))` string. + /// + /// Any inner rings (holes) are ignored -- Google Maps polygons take holes + /// separately, and none of the demo buildings have any. + static List _parseWktPolygon(String wkt) { + final int start = wkt.indexOf('(('); + if (start < 0) return const []; + final int end = wkt.indexOf(')', start + 2); + if (end < 0) return const []; + + final List points = []; + for (final String pair in wkt.substring(start + 2, end).split(',')) { + // WKT is x then y, so longitude comes first. + final List parts = pair.trim().split(RegExp(r'\s+')); + if (parts.length < 2) continue; + + final double? lng = double.tryParse(parts[0]); + final double? lat = double.tryParse(parts[1]); + if (lng == null || lat == null) continue; + + points.add(LatLng(lat, lng)); + } + + return points; + } +} diff --git a/lib/services/map_layers/floorplans_layer.dart b/lib/services/map_layers/floorplans_layer.dart new file mode 100644 index 0000000..151f6fb --- /dev/null +++ b/lib/services/map_layers/floorplans_layer.dart @@ -0,0 +1,356 @@ + +import 'package:bluebus/models/floorplan.dart'; +import 'package:bluebus/services/floorplan_marker_service.dart'; +import 'package:bluebus/services/floorplan_service.dart'; +import 'package:bluebus/services/floorplan_style.dart'; +import 'package:bluebus/utils/floorplan_projection.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Draws indoor floorplans on top of the map. +/// +/// The layer swaps between three depths of detail as the camera zooms (see +/// [FloorplanDetailLevel]). All of the geometry for a floor is projected and +/// built once when the floor is loaded, and a zoom change only swaps which +/// prebuilt sets are exposed to the map -- no re-projection happens while the +/// user is panning around. +class FloorplansLayer extends CompositeMapLayer { + @override + bool isVisible = true; + @override + Set polygons = const {}; + @override + Set polylines = const {}; + @override + Set markers = const {}; + @override + Function() onUpdate = () {}; + + GeoreferencedFloorplan? _source; + int _floorIndex = 0; + + /// Starts out matching the map's initial camera, since [onCameraMove] only + /// fires once the user actually moves. + FloorplanDetailLevel _detailLevel = floorplanDetailLevelForZoom( + INITIAL_MAP_ZOOM, + ); + + /// Bumped whenever a rebuild starts, so a slow rebuild that has been + /// superseded (by a floor change, say) can bail out instead of overwriting + /// newer geometry. + int _buildGeneration = 0; + + // Prebuilt geometry for the active floor, one set per thing we draw. + Set _footprintPolygons = const {}; + Set _detailedPolygons = const {}; + Set _wallPolylines = const {}; + Set _poiMarkers = const {}; + + Floorplan? get floorplan => _source?.floorplan; + + List get floors => _source?.floorplan.floors ?? const []; + + FloorplanFloor? get activeFloor { + final List all = floors; + if (_floorIndex < 0 || _floorIndex >= all.length) return null; + return all[_floorIndex]; + } + + Future? _loading; + + /// Loads the floorplan and builds the first floor's geometry. Awaiting this + /// more than once is free -- the work only happens on the first call, so + /// anything that needs the floors can just await it. + Future load() => _loading ??= _load(); + + Future _load() async { + try { + _source = await FloorplanService.loadDuderstadt(); + } catch (err) { + debugPrint('FloorplansLayer: failed to load floorplan ($err)'); + return; + } + await _rebuild(); + } + + /// Switches which floor is drawn. Safe to call before [load] finishes. + Future setFloorIndex(int index) async { + if (index == _floorIndex) return; + _floorIndex = index; + await _rebuild(); + } + + @override + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) { + final FloorplanDetailLevel level = floorplanDetailLevelForZoom( + newPosition.zoom, + ); + if (level == _detailLevel) return; + + _detailLevel = level; + _applyDetailLevel(); + if (isVisible) onUpdate(); + } + + @override + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + // --- Geometry ------------------------------------------------------------ + + Future _rebuild() async { + final int generation = ++_buildGeneration; + + _footprintPolygons = const {}; + _detailedPolygons = const {}; + _wallPolylines = const {}; + _poiMarkers = const {}; + + final FloorplanFloor? floor = activeFloor; + final GeoreferencedFloorplan? source = _source; + + if (floor != null && source != null) { + final FloorplanProjection? projection = FloorplanProjection.forFloor( + floor, + waypoint1: source.waypoint1, + waypoint2: source.waypoint2, + ); + + if (projection == null) { + // Without both waypoints we can't know where the floor sits in the + // world, so there's nothing safe to draw. + debugPrint( + 'FloorplansLayer: floor "${floor.id}" is missing its waypoints', + ); + } else { + final List outline = projection.toLatLngList(floor.outline); + + _footprintPolygons = {_buildFootprintPolygon(floor, outline)}; + _detailedPolygons = { + _buildBasePolygon(floor, outline), + ..._buildRoomPolygons(floor, projection), + }; + _wallPolylines = _buildWallPolylines(floor, projection); + + final Set poiMarkers = await _buildPoiMarkers(floor, projection); + if (generation != _buildGeneration) return; // Superseded mid-build. + _poiMarkers = poiMarkers; + } + } + + _applyDetailLevel(); + if (isVisible) onUpdate(); + } + + /// The plain filled blob shown when we're zoomed too far out for detail. + Polygon _buildFootprintPolygon(FloorplanFloor floor, List outline) { + return Polygon( + polygonId: PolygonId('floorplan_footprint_${floor.id}'), + points: outline, + fillColor: FLOORPLAN_FAR_OUTLINE_FILL, + strokeColor: FLOORPLAN_FAR_OUTLINE_STROKE, + strokeWidth: FLOORPLAN_FAR_OUTLINE_STROKE_WIDTH, + zIndex: FLOORPLAN_Z_BASE, + ); + } + + /// The same outline again, this time as the backdrop the detailed plan is + /// drawn on top of. + Polygon _buildBasePolygon(FloorplanFloor floor, List outline) { + return Polygon( + polygonId: PolygonId('floorplan_base_${floor.id}'), + points: outline, + fillColor: FLOORPLAN_BASE_FILL, + strokeColor: FLOORPLAN_STROKE, + strokeWidth: FLOORPLAN_BASE_STROKE_WIDTH, + zIndex: FLOORPLAN_Z_BASE, + ); + } + + Set _buildRoomPolygons( + FloorplanFloor floor, + FloorplanProjection projection, + ) { + return { + for (final FloorplanRoom room in floor.rooms) + if (room.polygon.length >= 3) + Polygon( + polygonId: PolygonId('floorplan_room_${room.id}'), + points: projection.toLatLngList(room.polygon), + fillColor: floorplanRoomFill(room.type), + strokeColor: FLOORPLAN_STROKE, + strokeWidth: FLOORPLAN_ROOM_STROKE_WIDTH, + zIndex: FLOORPLAN_Z_ROOMS, + ), + }; + } + + Set _buildWallPolylines( + FloorplanFloor floor, + FloorplanProjection projection, + ) { + final List> chains = _chainWallSegments(floor.walls); + return { + for (int i = 0; i < chains.length; i++) + Polyline( + polylineId: PolylineId('floorplan_wall_${floor.id}_$i'), + points: projection.toLatLngList(chains[i]), + color: FLOORPLAN_STROKE, + width: FLOORPLAN_WALL_STROKE_WIDTH, + jointType: JointType.round, + startCap: Cap.buttCap, + endCap: Cap.buttCap, + zIndex: FLOORPLAN_Z_WALLS, + ), + }; + } + + /// Room labels and POI icons. A POI can get both, in which case the label is + /// pushed below the icon rather than drawn over it. + Future> _buildPoiMarkers( + FloorplanFloor floor, + FloorplanProjection projection, + ) async { + final Set result = {}; + + for (final FloorplanPoi poi in floor.pois) { + final bool hasIcon = FLOORPLAN_POI_ICONS.containsKey(poi.type); + final String? name = poi.name; + final bool hasLabel = + FLOORPLAN_LABELED_POI_TYPES.contains(poi.type) && + name != null && + name.isNotEmpty; + + if (!hasIcon && !hasLabel) continue; + + final LatLng position = projection.toLatLng(poi.position); + + if (hasIcon) { + final BitmapDescriptor? icon = await FloorplanMarkerService.icon( + poi.type, + ); + if (icon != null) { + result.add( + Marker( + markerId: MarkerId('floorplan_icon_${poi.id}'), + position: position, + icon: icon, + anchor: const Offset(0.5, 0.5), + zIndexInt: FLOORPLAN_Z_MARKERS, + ), + ); + } + } + + if (hasLabel) { + result.add( + Marker( + markerId: MarkerId('floorplan_label_${poi.id}'), + position: position, + icon: await FloorplanMarkerService.label(name, belowIcon: hasIcon), + anchor: const Offset(0.5, 0.5), + zIndexInt: FLOORPLAN_Z_MARKERS, + ), + ); + } + } + + return result; + } + + /// Joins wall segments that meet end to end into longer polylines. + /// + /// The data has one entry per straight segment (~900 on the Duderstadt + /// ground floor), and each one would otherwise become its own map object. + /// Segments are merged wherever exactly two of them meet at a point, which + /// cuts the object count roughly in half without changing what's drawn. + /// Junctions where three or more walls meet stay split, since there's no + /// unambiguous way to continue through them. + static List> _chainWallSegments(List walls) { + // Endpoints are matched on their rounded coordinates so that segments the + // authoring tool wrote out separately still join up. + String pointKey(Offset point) => + '${point.dx.toStringAsFixed(2)},${point.dy.toStringAsFixed(2)}'; + String segmentKey(String a, String b) => + a.compareTo(b) <= 0 ? '$a>$b' : '$b>$a'; + + final Map pointsByKey = {}; + final Map> neighbors = {}; + final Set unusedSegments = {}; + + for (final FloorplanWall wall in walls) { + final String start = pointKey(wall.start); + final String end = pointKey(wall.end); + if (start == end) continue; // Zero length segment, nothing to draw. + + pointsByKey[start] = wall.start; + pointsByKey[end] = wall.end; + neighbors.putIfAbsent(start, () => {}).add(end); + neighbors.putIfAbsent(end, () => {}).add(start); + unusedSegments.add(segmentKey(start, end)); + } + + final List> chains = []; + + while (unusedSegments.isNotEmpty) { + final String seed = unusedSegments.first; + unusedSegments.remove(seed); + + final List chain = seed.split('>'); + + // Grow the chain outward from each end for as long as the endpoint is a + // simple pass-through with an unused segment left on it. + for (final bool forward in const [true, false]) { + while (true) { + final String tip = forward ? chain.last : chain.first; + final Set tipNeighbors = neighbors[tip]!; + if (tipNeighbors.length != 2) break; + + String? next; + for (final String candidate in tipNeighbors) { + if (unusedSegments.contains(segmentKey(tip, candidate))) { + next = candidate; + break; + } + } + if (next == null) break; + + unusedSegments.remove(segmentKey(tip, next)); + if (forward) { + chain.add(next); + } else { + chain.insert(0, next); + } + } + } + + chains.add([for (final String key in chain) pointsByKey[key]!]); + } + + return chains; + } + + /// Points the map at whichever prebuilt sets the current zoom calls for. + void _applyDetailLevel() { + switch (_detailLevel) { + case FloorplanDetailLevel.hidden: + polygons = const {}; + polylines = const {}; + markers = const {}; + case FloorplanDetailLevel.outline: + polygons = _footprintPolygons; + polylines = const {}; + markers = const {}; + case FloorplanDetailLevel.full: + polygons = _detailedPolygons; + polylines = _wallPolylines; + markers = const {}; + case FloorplanDetailLevel.labeled: + polygons = _detailedPolygons; + polylines = _wallPolylines; + markers = _poiMarkers; + } + } +} diff --git a/lib/services/map_layers/journey_layer.dart b/lib/services/map_layers/journey_layer.dart new file mode 100644 index 0000000..104108b --- /dev/null +++ b/lib/services/map_layers/journey_layer.dart @@ -0,0 +1,353 @@ +import 'package:bluebus/constants.dart'; +import 'package:bluebus/globals.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/journey.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'package:bluebus/utils/geometry.dart'; + +class JourneyLayer extends CompositeMapLayer { + // maximum allowed distance (meters) from a stop to a candidate polyline point + static const double _maxMatchDistanceMeters = 150.0; + + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + + Function(String s) _showBusSheet = (String s) { + debugPrint("Error: _showBusSheet was called but callback was never set"); + }; + + BitmapDescriptor? _getOn; + BitmapDescriptor? _getOff; + BitmapDescriptor? _destination; + BitmapDescriptor? _start; + + Set activeJourneyBusIds = {}; + Set activeJourneyRoutes = {}; + Set liveBusMarkers = {}; + + Map> routesCache = {}; + BuildContext? context; + + GoogleMapController? _mapController; + + void setMapController(GoogleMapController mapController_in) { + _mapController = mapController_in; + } + + void init( + Function(String s) showBusSheet_in, + Set activeJourneyBusIds_in, + Set activeJourneyRoutes_in, + BuildContext context_in, + ) { + // activeJourneyBusIds = activeJourneyBusIds_in; + // activeJourneyRoutes = activeJourneyRoutes_in; + // TODO: Get rid of activeJourneyBusIds and activeJourneyRoutes as they're passed in here + _showBusSheet = showBusSheet_in; + context = context_in; + loadMarkers(); + } + + Future loadMarkers() async { + _getOn = await MapImageService.resizeImage( + await rootBundle.load('assets/getOn.png'), + ); + _getOff = await MapImageService.resizeImage( + await rootBundle.load('assets/getOff.png'), + ); + _destination = await MapImageService.resizeImage( + await rootBundle.load('assets/destination.png'), + ); + _start = await MapImageService.resizeImage( + await rootBundle.load('assets/start.png'), + ); + } + + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + void refreshLiveBusMarkers(List allBuses) { + liveBusMarkers.clear(); + for (final bus in allBuses) { + // Show buses that are on routes used in the journey + if (activeJourneyBusIds.contains(bus.id)) { + BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); + + liveBusMarkers.add( + Marker( + flat: true, + markerId: MarkerId('journey_bus_${bus.id}'), + consumeTapEvents: true, + position: bus.position, + icon: busIcon!, + rotation: bus.heading, + anchor: const Offset(0.5, 0.5), + onTap: () => _showBusSheet(bus.id), + ), + ); + } + } + } + + void setRoutesCache(List routes) { + routesCache.clear(); + for (BusRouteLine l in routes) { + routesCache.putIfAbsent(l.routeId, () => []).add(l); + } + } + + // Helper to extract a contiguous segment from polyline points between two latlngs + // Return null if indices are invalid or segment is too short. + List? _extractRouteSegment( + List poly, + LatLng start, + LatLng end, + ) { + final (si, sDist) = start.nearestPolylineIndexAndDistanceDiscrete(poly); + final (ei, eDist) = end.nearestPolylineIndexAndDistanceDiscrete(poly); + + // If either nearest point is too far from the stop, we consider this polyline not a match + if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) { + return null; + } + + if (si == ei) return null; + + // Ensure start < end in index space, if reversed, flip the sublist + if (si < ei) { + return poly.sublist(si, ei + 1); + } else { + final seg = poly.sublist(ei, si + 1); + return seg.reversed.toList(); + } + } + + Future addBusLegMarkersAndPolylines( + Leg leg, + Journey journey, + int legIndex, + ) async { + // This accepts a bus leg that goes from, e.g. CCTC (C251) through several stops to a destination, e.g. Stop C251 + // and adds the necessary markers and polylines to the markers and polylines Sets + + if (leg.rt != null) activeJourneyRoutes.add(leg.rt!); + if (leg.trip != null) activeJourneyBusIds.add(leg.trip!.vid); + + final rt = leg.rt; + final line = rt != null + ? determineRouteOfBusLeg(routesCache, rt, leg.originID, leg.destinationID) + : null; + + // debugPrint("Tracing path from ${leg.originID} to ${leg.destinationID}"); + + final LatLng? startLatLng = getLatLongFromStopID(leg.originID); + final LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); + + if (startLatLng != null && endLatLng != null && line?.points != null) { + List? segment = _extractRouteSegment( + line!.points, + startLatLng, + endLatLng, + ); + if (segment == null) { + // debugPrint("ERROR: Line segment is null!"); + + // If something went wrong tracing streets between stops, just draw a straight + // line between the start and end + final polyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), + points: [startLatLng, endLatLng], + color: RouteColorService.getRouteColor(leg.rt!), + width: 6, + ); + polylines.add(polyline); + } else { + final polyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), + points: segment, + color: RouteColorService.getRouteColor(leg.rt!), + width: 6, + ); + polylines.add(polyline); + } + + // add stop markers at endpoints of the segment (boarding/getting off) + if ((segment?.first != null || startLatLng != null)) { + // Making sure the marker has a valid location + + // BitmapDescriptor iconBitmap = await RouteIcon.small( + // leg.rt!, + // ).toBitmapDescriptor(); + + // TODO: See what the UI team says about this--if it looks good, add an extra method to the RouteIcon class that generates a bitmap instead of having to render this whole thing to the widget tree (it'll be MUCH faster) + + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), + position: segment?.first ?? startLatLng, + icon: + _getOn ?? + // iconBitmap ?? + BitmapDescriptor.defaultMarkerWithHue( + colorToHue(RouteColorService.getRouteColor(leg.rt!)), + ), + anchor: Offset(0.5, 0.5), + ), + ); + } + if ((segment?.last != null || endLatLng != null)) { + // Making sure the marker has a valid location + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_stop_${leg.destinationID}_$legIndex'), + position: segment?.last ?? endLatLng, + icon: + _getOff ?? + BitmapDescriptor.defaultMarkerWithHue( + colorToHue(RouteColorService.getRouteColor(leg.rt!)), + ), + ), + ); + } + } + } + + void addWalkingLegMarkersAndPolylines( + Leg leg, + Journey journey, + int legIndex, + ) { + // Walking legs add a dotted line between origin and destination + // First try to get the locations from origin and destination IDs + LatLng? startLatLng = getLatLongFromStopID(leg.originID); + LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); + + // NEXT STEPS TODO: Get these walking lines working and see if I can fix the straight-line bus segment problem (where it says ERROR: Line segment is null!) + + if (startLatLng == null && legIndex > 0) { + // Try to get end location from previous leg + final prevLeg = journey.legs[legIndex - 1]; + startLatLng = getLatLongFromStopID(prevLeg.destinationID); + } + + List pathCoords = leg.pathCoords ?? []; + + if (leg.pathCoords == null) { + if (startLatLng != null && endLatLng != null) { + // If there's no path available, draw a straight line if we can + pathCoords = [startLatLng, endLatLng]; + } + } + + // Create a dotted line for walking segments + final walkingPolyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('walking_${journey.hashCode}_$legIndex'), + points: pathCoords, + color: (context != null) + ? getColor(context!, ColorType.mapWalkingLine) + : Colors.black, // Walk line color + width: 8, // line width + patterns: [ + PatternItem.dot, + // PatternItem.dash(30), // Longer dashes + PatternItem.gap(15), // Longer gaps + ], + ); + + polylines.add(walkingPolyline); + } + + void addRouteStartMarker(LatLng position, Journey journey) { + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_start_${journey.hashCode}'), + position: position, + icon: + _start ?? + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + ), + ); + } + + void addRouteEndMarker(LatLng position, Journey journey) { + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_final_destination_${journey.hashCode}'), + position: position, + icon: + _destination ?? + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + ), + ); + } + + void setJourney(Journey journey, Color walkLineColor) { + // Don't stop believin' + + // clear previous journey overlay + polylines.clear(); + markers.clear(); + activeJourneyBusIds.clear(); + activeJourneyRoutes.clear(); + + final allPoints = []; + + // First, analyze the journey to find which legs are bus and which are walking + + for (int legIndex = 0; legIndex < journey.legs.length; legIndex++) { + final leg = journey.legs[legIndex]; + + if (leg.destinationID == "VIRTUAL_DESTINATION" && + leg.pathCoords != null && + leg.pathCoords!.isNotEmpty) { + addRouteEndMarker(leg.pathCoords!.last, journey); + } + + // Determine if this is a walking or bus leg - walking legs don't have rt or trip + final bool isBusLeg = leg.rt != null && leg.trip != null; + // Determine leg type for processing + + if (isBusLeg) { + addBusLegMarkersAndPolylines(leg, journey, legIndex); + } else { + addWalkingLegMarkersAndPolylines(leg, journey, legIndex); + } + } + + if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update + } + + void clearJourney() { + markers.clear(); + polylines.clear(); + if (isVisible) onUpdate(); + } +} diff --git a/lib/services/map_layers/live_buses_layer.dart b/lib/services/map_layers/live_buses_layer.dart new file mode 100644 index 0000000..8733b4b --- /dev/null +++ b/lib/services/map_layers/live_buses_layer.dart @@ -0,0 +1,402 @@ +import 'dart:math'; + +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:haptic_feedback/haptic_feedback.dart'; + +class BusAnimationState { + Bus? + prevBus; // Used to animate from the previous position to current position + Bus bus; + // BitmapDescriptor busIcon; + MarkerId markerId; + int lastUpdated = 0; + + LatLng? lastInterpolatedPosition; + double? lastInterpolatedHeading; + LatLng? fromPosition; + double? fromHeading; + LatLng? toPosition; + double? toHeading; + + BusAnimationState({ + required this.bus, + // required this.busIcon, + required this.markerId, + this.lastUpdated = 0, + }) { + toHeading = bus.heading; + toPosition = bus.position; + } +} + +class LiveBusesLayer extends CompositeMapLayer { + @override + bool isVisible = true; + + @override + Set markers = {}; + + @override + Function() onUpdate = () { + debugPrint("Error: onUpdate called but callback was not registered!"); + }; + + @override + Function(LatLng) showRipple = (LatLng location) { + debugPrint("Error: showRipple called but callback was not registered!"); + }; + + @override + Set polylines = {}; + + bool isAnimating = false; + late Animation animation; + int nextAnimationFrameTime = 0; + int animationStartedTime = 0; + static const int FRAME_DURATION = 100; // Frame duration in ms for animations + static const int ANIMATION_DURATION = + 11000; //4000; // Animation duration in ms + + AnimationController? controller; + List buses = []; + Set selectedRoutes = {}; + TickerProvider? tickerProvider; + + Map busAnimationCache = + {}; // Maps Bus ID -> BusAnimationState + + Function(Bus b) onBusClicked = (Bus b) { + debugPrint("Error: onBusClicked callback was called but never intiialized"); + }; + + @override + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + @override + void setShowRipple(Function(LatLng) callback) { + showRipple = callback; + } + + void initWithTickerProvider(TickerProvider tickerProviderIn) { + tickerProvider = tickerProviderIn; + controller = AnimationController( + duration: const Duration(milliseconds: ANIMATION_DURATION), + vsync: tickerProvider!, + ); + } + + void init( + List buses_in, + Set selectedRoutes_in, + Function(Bus b) onBusClicked_in, + ) { + buses = buses_in; + selectedRoutes = selectedRoutes_in; + onBusClicked = onBusClicked_in; + + // MapImageService.loadData(); // Testing NOT including this since it's already happening inside map_screen.dart on app load. Looks like commenting this out fixed the weird marker problems + } + + Marker createBusMarker(Bus bus) { + final icon = MapImageService.getBusIcon(bus); + return Marker( + flat: true, + markerId: MarkerId('bus_${bus.id}'), + consumeTapEvents: true, + position: bus.position, + icon: icon, + rotation: bus.heading, + anchor: const Offset(0.5, 0.5), + onTap: () { + showRipple(bus.position); + onBusClicked(bus); + }, + ); + } + + void updateAnimation() { + DateTime now = DateTime.now(); + + markers = busAnimationCache.keys + .where((String busId) { + return selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId); + }) + .map((String busId) { + LatLng interpolatedPosition; + double interpolatedHeading = busAnimationCache[busId]!.bus.heading; + double animatedPercentage = min( + (now.millisecondsSinceEpoch - + busAnimationCache[busId]!.lastUpdated) / + ANIMATION_DURATION, + 1.0, + ); + + if (busAnimationCache[busId]?.prevBus == null) { + // If this is the first time we've seen this bus, there won't be a previous position to animate from + interpolatedPosition = busAnimationCache[busId]!.bus.position; + } else { + LatLng? oldPosition = busAnimationCache[busId]?.fromPosition; + LatLng? newPosition = busAnimationCache[busId]?.toPosition; + + interpolatedPosition = LatLng( + animatedPercentage * + (newPosition!.latitude - oldPosition!.latitude) + + oldPosition!.latitude, + animatedPercentage * + (newPosition!.longitude - oldPosition!.longitude) + + oldPosition!.longitude, + ); + + busAnimationCache[busId]?.lastInterpolatedPosition = + interpolatedPosition; + // TODO: Figure out why the buses are still jumpy? They might not be anymore actually + + // NOTE: Combined with the "has the bus moved at all" check, this might cause problems if the bus is staying still at a stop light? Double check this + + double headingDelta = + (busAnimationCache[busId]!.fromHeading! - + busAnimationCache[busId]!.toHeading!); + + if (headingDelta.abs() > (360 + headingDelta).abs()) { + // Might need to fix this + headingDelta = + 360 + headingDelta; // Turn the tightest direction possible + } + + if ((headingDelta).abs() < 120) { + // Don't animate heading changes of more than 120 degrees to avoid weird spinning if the bus turns 180 + + interpolatedHeading = + animatedPercentage * + (busAnimationCache[busId]!.toHeading! - + busAnimationCache[busId]!.fromHeading!) + + busAnimationCache[busId]!.fromHeading!; + } + } + + busAnimationCache[busId]?.lastInterpolatedHeading = + interpolatedHeading; + busAnimationCache[busId]?.lastInterpolatedPosition = + interpolatedPosition; + + return Marker( + flat: true, + zIndexInt: + busId.hashCode.abs() % + 1000, // To prevent buses from fighting over who's on top and causing flickering + markerId: busAnimationCache[busId]!.markerId, + consumeTapEvents: true, + position: interpolatedPosition, + // icon: busAnimationCache[busId]!.busIcon, + icon: MapImageService.getBusIcon(busAnimationCache[busId]!.bus), + rotation: interpolatedHeading, + anchor: const Offset(0.5, 0.5), // Center the icon on the position + onTap: () { + showRipple(interpolatedPosition); + try { + Haptics.vibrate(HapticsType.light); + } catch (e) {} + onBusClicked(busAnimationCache[busId]!.bus); + // _showBusSheet(bus.id); + }, + ); + + // return Marker(); + }) + .toSet(); + + // busAnimationCache.where((bus) => selectedRoutes.contains(bus.routeId)) + // // .map((bus) { + // .forEach((bus) { + + // // Update all cached markers with new location data (location is contained inside bus object) + // if (busAnimationCache.containsKey(bus.id)) { + // busAnimationCache[bus.id]?.prevBus = busAnimationCache[bus.id]?.bus; + // busAnimationCache[bus.id]?.bus = bus; + // } else { + // busAnimationCache[bus.id] = BusAnimationState( + // bus: bus, + // busIcon: MapImageService.getBusIcon(bus), + // markerId: MarkerId('bus_${bus.id}') + // ); + // } + // }); + + // //TODO: Start the animation here! + // startAnimation(); + + // // Use route specific bus icon if available, otherwise fallback to default + // BitmapDescriptor? busIcon = MapImageService.getBusIcon(bus); + + // // NEXT STEPS TODO: Get bus animations working on android, and get the live updating to work! + + // // Maybe try Project SmoothBus(TM) again? + + // return Marker( + // flat: true, + // markerId: MarkerId('bus_${bus.id}'), + // consumeTapEvents: true, + // position: bus.position, + // icon: busIcon, + // rotation: bus.heading, + // anchor: const Offset(0.5, 0.5), // Center the icon on the position + // onTap: () { + // try { + // Haptics.vibrate(HapticsType.light); + // } catch (e) {} + // onBusClicked(bus); + // // _showBusSheet(bus.id); + // }, + // ); + // }) + // .toSet(); + } + + void startAnimation() { + DateTime now = DateTime.now(); + if (animationStartedTime + ANIMATION_DURATION > + now.millisecondsSinceEpoch) { + return; // Prevent starting the same animation twice if startAnimation() gets multiple calls + } + + if (controller == null) return; + + animationStartedTime = now.millisecondsSinceEpoch; + + // TODO: Don't start the animation if it's already going + + // controller?.reset(); // Stop all previous animations + // WHY DOES IT BREAK WHEN THIS ISN'T HERE???? + + if (isAnimating) return; + + controller?.reset(); + isAnimating = true; + + animation = Tween(begin: 0, end: 1).animate(controller!) + ..addListener(() { + DateTime now = DateTime.now(); + if (now.millisecondsSinceEpoch < nextAnimationFrameTime) return; + nextAnimationFrameTime = + now.millisecondsSinceEpoch + FRAME_DURATION; // 100ms frametimes + + updateAnimation(); + if (isVisible) { + onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) + } + }); + + animation.addStatusListener((AnimationStatus status) {}); + + controller?.forward(); + controller?.repeat(); + + } + + void reload() { + // Called when parent has new live bus GPS data to tell us about! + + // null case or error contacting server case + if (buses == []) return; + + DateTime now = DateTime.now(); + + // markers = buses + buses.where((bus) => selectedRoutes.contains(bus.routeId)) + // .map((bus) { + .forEach((bus) { + // Update all cached markers with new location data (location is contained inside bus object) + if (busAnimationCache.containsKey(bus.id) && + busAnimationCache[bus.id]!.lastUpdated + 30000 > + now.millisecondsSinceEpoch) { + // If the last bus position is super old and we try to animate it, it appears to "skate" across the map from its old position to its new position, ignoring streets entirely. It looks really funky, so if the last updated time is more than 30 seconds old, skip the animation + + if (busAnimationCache[bus.id]?.bus.position == bus.position && + busAnimationCache[bus.id]?.bus.heading == bus.heading && + busAnimationCache[bus.id]!.lastUpdated + ANIMATION_DURATION + 200 > + now.millisecondsSinceEpoch) { + // If the bus position hasn't changed and the bus was updated recently, skip it! + return; + } + + busAnimationCache[bus.id]!.lastUpdated = now.millisecondsSinceEpoch; + + busAnimationCache[bus.id]?.prevBus = busAnimationCache[bus.id]?.bus; + busAnimationCache[bus.id]?.bus = bus; + // busAnimationCache[bus.id]?.busIcon = MapImageService.getBusIcon(bus); + + busAnimationCache[bus.id]?.fromPosition = + busAnimationCache[bus.id]?.lastInterpolatedPosition; + busAnimationCache[bus.id]?.fromHeading = + busAnimationCache[bus.id]?.lastInterpolatedHeading; + busAnimationCache[bus.id]?.toPosition = bus.position; + busAnimationCache[bus.id]?.toHeading = bus.heading; + } else { + // If we get here, the previous position either doesn't exist or is too old. Create a new BusAnimationState from scratch + + busAnimationCache[bus.id] = BusAnimationState( + bus: bus, + // busIcon: MapImageService.getBusIcon(bus), + markerId: MarkerId('bus_${bus.id}'), + lastUpdated: now.millisecondsSinceEpoch, + ); + // // TODO: This runs for EVERY bus route, so even if we're already downloading the icon for a Bursley-Baits bus, it'll try to download the icon for EVERY Bursley-Baits bus on the map + // // NEXT STEPS TODO: Figure out if the cache is working, and do some live testing on my phone to make sure. + // if (!MapImageService.isBusIconAvailable(bus)) { + // MapImageService.ensureRouteIconIsLoaded(bus.routeId).then(( + // BitmapDescriptor? icon, + // ) { + // // Add the icon to the cache when it's ready + // if (icon == null) return; + + // for (final state in busAnimationCache.values) { + // if (state.bus.routeId == bus.routeId) { + // state.busIcon = icon; + // } + // } + // }); + // } + } + }); + + //TODO: Start the animation here! + startAnimation(); + + // // Use route specific bus icon if available, otherwise fallback to default + // BitmapDescriptor? busIcon = MapImageService.getBusIcon(bus); + + // // NEXT STEPS TODO: Get bus animations working on android, and get the live updating to work! + + // // Maybe try Project SmoothBus(TM) again? + + // return Marker( + // flat: true, + // markerId: MarkerId('bus_${bus.id}'), + // consumeTapEvents: true, + // position: bus.position, + // icon: busIcon, + // rotation: bus.heading, + // anchor: const Offset(0.5, 0.5), // Center the icon on the position + // onTap: () { + // try { + // Haptics.vibrate(HapticsType.light); + // } catch (e) {} + // onBusClicked(bus); + // // _showBusSheet(bus.id); + // }, + // ); + // }) + // .toSet(); + } + + // TODO: Dispose of the AnimationController when done! + void dispose() { + controller?.dispose(); + } +} diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart new file mode 100644 index 0000000..ae30af7 --- /dev/null +++ b/lib/services/map_layers/navigation_layer.dart @@ -0,0 +1,53 @@ +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/bus_stop.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class NavigationLayer extends CompositeMapLayer { + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + Function(BusStop) onStopClicked = (BusStop s) { + debugPrint("Warning! onStopClicked called but no callback was registered"); + }; + + void init( + ) { + //... + } + + void reload() { + // reloadMarkers(); + // reloadPolylines(); + if (isVisible) onUpdate(); + } + + void setMarkers(Set markers_in) { + this.markers = markers_in; + } + + void setPolylines(Set polylines_in) { + this.polylines = polylines_in; + } + + void reloadMarkers() { + //... + } + + void reloadPolylines() { + //... + } + + void setOnUpdate(Function() callback) { + onUpdate = callback; + } +} diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart new file mode 100644 index 0000000..5d93054 --- /dev/null +++ b/lib/services/navigation/navigation_manager.dart @@ -0,0 +1,1106 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; +import 'dart:math' as math; +import 'package:geolocator/geolocator.dart'; +import 'package:bluebus/constants.dart'; +import 'package:bluebus/globals.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/bus_stop.dart' show BusStop; +import 'package:bluebus/models/journey.dart'; +import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/utils/geometry.dart'; +import 'package:bluebus/utils/time.dart'; +import 'package:bluebus/widgets/route_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +enum LineType { Dotted, Dashed } + +class NavigationStageStep { + String title; + String? subtitle; + String time; + Color color; + LineType lineType; // e.g. LineType.Dashed + + NavigationStageStep({ + required this.title, + this.subtitle, + required this.time, + required this.color, + required this.lineType, + }); + + String getTitle() { + return title; + } + + String? getSubtitle() { + return subtitle; // Return null if no subtitle + } + + String getTime() { + return time; // Get the time + } + + Color? getColor() { + return color; // Return null for neutral gray + } + + LineType getLineType() { + return lineType; + } +} + +sealed class NavigationStage { + // String title = "..."; //Don't use this anymore--implement getTitle() instead + String getTitle() { + return "Swim forward"; // Title displayed on the big bar at the top + } + + String getSubtitle() { + return "Swim for 200 meters"; // Subtitle displayed on the big bar at the top + } + + double length = + 0.0; // Estimated length of your segment, in minutes (i.e. is it a 20-minute walk or 12-minute bus ride?) + double percent_complete = + 0.0; // Estimated completion percentage of your segment (i.e. if you're 32% of the way through your walk) + + List getSteps() { + return []; // Get navigation stage steps + } + + List getMarkers() { + return []; + } + + List getPolylines() { + return []; + } + + Color getColor() { + return Color(0xFFDBE4ED); + } + + bool hasRoundedCorners() { + return false; + } + + void receiveLocationUpdate(Position p) { + // Do whatever you need to with the current location. + // You might want to do some processing (e.g. figure out if the user is close to the end of their walking path) and send a stage event, e.g.: + // emit(StageComplete()) // If the user has reached the end! + } + + // Broadcast so the NavigationManager can unsubscribe and resubscribe to the + // same stage (e.g. when the user pages backwards) without the stream + // complaining that it has already been listened to. + final _eventController = StreamController.broadcast(); + + Stream get events => _eventController + .stream; // The NavigationManager does yourStage.events to listen in + + /// How a stage talks back to the NavigationManager. Call this from your + /// stage (usually from receiveLocationUpdate) when something happens: + /// emit(StageComplete()) // Your stage is done, move on to the next one + /// emit(StageReroute(RerouteReason.wrongBus)) // Something went wrong--this is what pops the "Oops" dialog + /// emit(StageReroute(RerouteReason.walkPathChanged)) + /// Note to all frontend devs: Feel free to add additional RerouteReasons if you need them! + @protected + void emit(StageEvent event) { + if (_eventController.isClosed) return; + _eventController.add(event); + } + + void dispose() { + _eventController.close(); + } + + void initWithLeg(Leg leg) { + // Do cool stuff to set up your Stage with an e.g. walking or bus leg + } +} + +enum RerouteReason { + wrongBus, + walkPathChanged, + // Feel free to add additional reasons as necessary +} + +class BusPromptOption { + // DISCLAIMER: STRUCTURES SUBJECT TO CHANGE BECAUSE IM NOT SURE IF WE HAVE CUSTOM STRUCTURES + // going to remove this soon probably, since i can use the bus structure... + final String code; // "CN" "BB"... + final String label; // expanded name + final Color color; + final String? busNumber; // 3067 :) + BusPromptOption({ + required this.code, + required this.label, + required this.color, + this.busNumber, + }); +} + +sealed class StageEvent {} + +class StageComplete extends StageEvent {} + +class StageReroute extends StageEvent { + final RerouteReason reason; // e.g. wrong bus, missed stop + StageReroute(this.reason); +} + +// used to ensure that the stage is fully initialized +class NavOnBusState { + Trip _trip; + String _departureStop; + String _arrivalStop; + BusRouteLine _line; + + NavOnBusState({ + required Trip trip, + required String departureStop, + required String arrivalStop, + required BusRouteLine line, + }) : _trip = trip, + _departureStop = departureStop, + _arrivalStop = arrivalStop, + _line = line; + + String get rt => _line.routeId; + String get departureStop => _departureStop; + String get arrivalStop => _arrivalStop; + + List<(int, BusStop)> get stops { + final (depIdx, (depPointIdx, _)) = _line.stops.indexed.firstWhere( + (x) => x.$2.$2.id == _departureStop, + ); + final (arrIdx, (arrPointIdx, _)) = _line.stops.indexed + .skip(depIdx) + .firstWhere((x) => x.$2.$2.id == _arrivalStop); + return _line.stops + .sublist(depIdx, arrIdx + 1) + .map( + (x) => switch (x) { + (final pointIdx, final stop) => (pointIdx - depPointIdx, stop), + }, + ) + .toList(); + } + + List get points { + final (depIdx, (depPointIdx, _)) = _line.stops.indexed.firstWhere( + (x) => x.$2.$2.id == _departureStop, + ); + final (arrIdx, (arrPointIdx, _)) = _line.stops.indexed + .skip(depIdx) + .firstWhere((x) => x.$2.$2.id == _arrivalStop); + return _line.points.sublist(depPointIdx, arrPointIdx + 1); + } + + List get stopTimes { + final List result = []; + for (final st in _trip.stopTimes.skipWhile( + (st) => st.stop != _departureStop, + )) { + result.add(st); + if (st.stop == _arrivalStop) { + break; + } + } + return result; + } +} + +class NavOnBus extends NavigationStage { + late NavOnBusState state; + late BitmapDescriptor stopBitmap; + LatLng? lastPosition; + + NavOnBus(); + + @override + void initWithLeg(Leg leg) { + if (leg.mode != LegMode.bus) { + throw ArgumentError("leg is of the wrong type"); + } + final rt = leg.rt; + final trip = leg.trip; + if (rt == null || + trip == null || + // leg.stopTimes == null || + leg.originID == '' || + leg.destinationID == '') { + throw FormatException("leg was malformed"); + } + if (trip.stopTimes.length < 2) throw FormatException("trip is too short"); + // TODO: use info from backend instead of this placeholder, check that line + // has the same number of stops + final points = []; + final stops = <(int, BusStop)>[]; + + for (final st in trip.stopTimes.skipWhile( + (st) => st.stop != leg.originID, + )) { + final loc = getLatLongFromStopID(st.stop); + if (loc == null) continue; + points.add(loc); + stops.add(( + stops.length, + BusStop( + id: st.stop, + name: getStopNameFromID(st.stop), + location: loc, + routeId: rt, + rotation: 0.0, + isRide: isRide(rt), + ), + )); + } + final line = BusRouteLine( + routeId: rt, + points: points, + stops: stops, + color: RouteColorService.getRouteColor(rt), + imageUrl: null, + ); + state = NavOnBusState( + trip: trip, + departureStop: leg.originID, + arrivalStop: leg.destinationID, + line: line, + ); + // const svgString = '' + // + '' + // + ''; + + // // final svg = SvgPicture.string(svgString, width: 19, height: 19,); + // final pictureInfo = vg.loadPicture(const SvgStringLoader(svgString), null); + // pictureInfo. + // svg.clipBehavior + // stopBitmap = BitmapDescriptor.bytes(); + stopBitmap = BitmapDescriptor.defaultMarker; + } + + @override + void receiveLocationUpdate(Position p) { + lastPosition = LatLng(p.latitude, p.longitude); + // TODO: determine if stage is over + } + + @override + String getTitle() { + // TODO: move route thing to a route icon widget + final stopsRemaining = state.stops.length - getStepIndex() - 1; + return "(${state.rt}) Ride $stopsRemaining more stops"; + } + + String getFixedTitle() { + return "Board ${state.rt}"; + } + + @override + String getSubtitle() { + return "Get off at ${getStopNameFromID(state.arrivalStop)}"; + } + + @override + // using seconds to match the walking stage right now, if you change this make + // sure to adjust the use of length in percent_complete + double get length { + final sts = state.stopTimes; + return (sts.last.arrivalTime.toDouble() - sts.first.departureTime); + } + + @override + // uses the departure time of the last stop passed with respect to `trip` as + // a baseline before adding progress past that stop + double get percent_complete { + final pos = lastPosition; + if (pos == null) return 0; + + final sts = state.stopTimes; + final stops = state.stops; + final points = state.points; + + final stepIdx = getStepIndex(); + final (prevStopIdx, _) = stops[stepIdx]; + final (nextStopIdx, _) = stops[min(stepIdx + 1, stops.length - 1)]; + final (pointsIdx, _) = pos.nearestPolylineIndexAndDistanceContinuous( + points, + ); + final currStepTotalDist = points + .sublist(prevStopIdx, nextStopIdx + 1) + .totalDistance(); + + // compute distance past the stop + var currStepMovedDist = points + .sublist(prevStopIdx, pointsIdx.truncate() + 1) + .totalDistance(); + final currSegmentDist = points + .sublist( + pointsIdx.truncate(), + min(pointsIdx.truncate() + 2, points.length), + ) + .totalDistance(); + currStepMovedDist += currSegmentDist * (pointsIdx - pointsIdx.truncate()); + + var progress = + sts[stepIdx].arrivalTime.toDouble() - sts.first.departureTime; + if (currStepTotalDist != 0.0) { + // add progress past the stop + progress += + (currStepMovedDist / currStepTotalDist) * + (sts[min(stepIdx + 1, sts.length - 1)].arrivalTime - + sts[stepIdx].arrivalTime); + } + return progress / length; + } + + /// the index of the last step reached/passed + int getStepIndex() { + final pos = lastPosition; + if (pos == null) return 0; + // project lastPosition onto polyline + final (idx, _) = pos.nearestPolylineIndexAndDistanceContinuous( + state.points, + ); + // return how many stops were passed + return state.stops.takeWhile((x) => x.$1 <= idx).length - 1; + } + + @override + List getSteps() { + final color = RouteColorService.getRouteColor(state.rt); + final steps = state.stopTimes + .map( + (st) => NavigationStageStep( + title: getStopNameFromID(st.stop), + time: convertSecondsToFormattedTime(st.departureTime), + color: color, + lineType: LineType.Dotted, + ), + ) + .toList(); + steps[0].title = getFixedTitle(); + steps[steps.length - 1].title = + "Get off at ${steps[steps.length - 1].title}"; + return steps; + } + + @override + List getMarkers() { + return state.stops + .map( + (x) => switch (x) { + (int _, BusStop stop) => AdvancedMarker( + markerId: MarkerId("navonbus_marker_${state.rt}_${stop.id}"), + flat: true, + position: stop.location, + zIndex: 2000, + icon: stopBitmap, + ), + }, + ) + .toList(); + } + + @override + List getPolylines() { + return [ + Polyline( + polylineId: PolylineId("navonbus_polyline_${state.rt}"), + color: RouteColorService.getRouteColor(state.rt), + points: state.points, + zIndex: 1999, + ), + ]; + } + + @override + Color getColor() { + return RouteColorService.getRouteColor(state.rt); + } +} + +typedef Edge = ({BusStop from, BusStop to, List points}); +typedef AdjacencyEntry = ({ + BusStop from, + Set<({BusStop stop, List points})> tos, +}); +BusRouteLine? determineRouteOfBusLeg( + Map> routesCache, + String rt, + String originID, + String destinationID, +) { + List candidates = routesCache[rt] ?? []; + + // happy path + final directLine = candidates.where((line) { + final stpids = line.stops.map((s) => s.$2.id); + return stpids + .skipWhile((stpid) => stpid != originID) + .contains(destinationID); + }).firstOrNull; + if (directLine != null) return directLine; + + // big sad path: graph traverse the entire route... + final Map adjacency = {}; // for stpids + // make the adjacency structure ... + for (final line in candidates) { + (int, BusStop)? prev; + for (final (i, stop) in line.stops) { + if (prev != null) { + final (prevIdx, prevStop) = prev; + // ignore: prefer_collection_literals (for better type inference) + adjacency + .putIfAbsent(prevStop.id, () => (from: prevStop, tos: Set())) + .tos + .add((stop: stop, points: line.points.sublist(prevIdx, i + 1))); + } + prev = (i, stop); + } + } + // do breadth first search ... + final Set explored = {}; + final queue = ListQueue<(String, List)>(); + queue.addLast((originID, [])); + + while (queue.isNotEmpty) { + final (stpid, edges) = queue.first; + if (stpid == destinationID) break; + queue.removeFirst(); + + if (explored.contains(stpid)) continue; + explored.add(stpid); + + final neighbors = adjacency[stpid]; + if (neighbors != null) { + for (final entry in neighbors.tos) { + queue.addLast(( + entry.stop.id, + edges.followedBy([ + (from: neighbors.from, to: entry.stop, points: entry.points), + ]).toList(), + )); + } + } + } + + if (queue.isEmpty || queue.first.$2.isEmpty) return null; + final edges = queue.first.$2; + + List points = [LatLng(0.0, 0.0)]; + List<(int, BusStop)> stops = [(0, edges.first.from)]; + for (final e in edges) { + points.removeLast(); + points.addAll(e.points); + stops.add((points.length - 1, e.to)); + } + + return BusRouteLine( + points: points, + stops: stops, + routeId: candidates.first.routeId, + color: candidates.fold(null, (acc, next) => acc ?? next.color), + imageUrl: candidates.fold(null, (acc, next) => acc ?? next.imageUrl), + ); +} + +class ChooseBus extends NavigationStage { + String title = "Choose a Bus"; + //Not sure if we actually need this. Depends on if we want to filter out some buses from certain stops. + List potentialBuses = []; + List potentialStops = []; + // If you have a list of buses to board and stops, + // this can help you display a bus and the stop you will board + // This could be simplified more, probably by picking up data from another function +} + +// oops stage +// TODOs: MOVING TO NAVIGATION MANAGER +// class MissedBus extends NavigationStage { +// // using the new title information method +// @override +// String getTitle() { +// // could be a more descriptive title who knows.. +// return "Oops!"; +// } + +// // information for the popup +// @override +// String getSubtitle() { +// // Looks like these are for pop-ups, so maybe this can be part of a user prompt? +// return "Looks like you might've missed your bus! Would you like to re-route?"; +// } + +// String route; // current route +// String nearest_stop; // nearest stop: ideally to get off +// String c_bus; // current bus i am/was on +// String c_pos; // current position (maybe not str lat lng?) + +// MissedBus({ +// // Constructor for more stuff +// required this.route, +// required this.nearest_stop, +// required this.c_bus, +// required this.c_pos, +// }); +// } + +//I believe this is just NavWalking but I'm doing it here to be sure. +class Walking extends NavigationStage { + List points = [ + //dummy pts taken from google maps by the cctc (replace later) + const LatLng(42.27792397921826, -83.73596985653457), + const LatLng(42.27756042901099, -83.7359661838265), + const LatLng(42.27754197988967, -83.73706331473826), + const LatLng(42.2775215703816, -83.73809993417933), + const LatLng(42.278481544159916, -83.73811396072821), + ]; + Leg? leg; + Color color = Colors.black; + + LatLng? currWalkingPos = const LatLng( + 42.27831772684626, + -83.73599054149456, + ); //near cctc (replace w user's location) + + int _nextIndex = 0; + static const double _reachThresholdMeters = 15.0; + + double _distMeters(LatLng a, LatLng b) { + const R = 6371000.0; + final dLat = (b.latitude - a.latitude) * math.pi / 180; + final dLon = (b.longitude - a.longitude) * math.pi / 180; + final s = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(a.latitude * math.pi / 180) * + math.cos(b.latitude * math.pi / 180) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return 2 * R * math.asin(math.sqrt(s)); + } + + double _bearing(LatLng a, LatLng b) { + final dLon = (b.longitude - a.longitude) * math.pi / 180; + final lat1 = a.latitude * math.pi / 180; + final lat2 = b.latitude * math.pi / 180; + final y = math.sin(dLon) * math.cos(lat2); + final x = + math.cos(lat1) * math.sin(lat2) - + math.sin(lat1) * math.cos(lat2) * math.cos(dLon); + return (math.atan2(y, x) * 180 / math.pi + 360) % 360; + } + + void setColor(Color newColor) { + color = + newColor; // Flutter won't let us call getColor(context, ...) because we can only get context from inside a widget. Thus, we have to thread it through all the way from map_screen.dart. Great. + } + + //call this whenever a new gps fix arrives, returns true if a waypoint was just cleared (so the ui can refresh) + @override + bool receiveLocationUpdate(Position p) { + LatLng newLocation = LatLng(p.latitude, p.longitude); + currWalkingPos = LatLng(p.latitude, p.longitude); + + // check if it has not reached new waypoint + if (_nextIndex >= points.length || + _distMeters(newLocation, points[_nextIndex]) > _reachThresholdMeters) { + return false; + } + + // if it has reached new waypoint, update index, length left, and percent complete + _nextIndex++; + + // TODO: update length and percent_complete here + + return true; + } + + @override + String getTitle() { + if (_nextIndex >= points.length) { + return "You've arrived!"; + } + final pos = currWalkingPos; + if (pos == null) { + return "Acquiring GPS…"; + } + final feet = (_distMeters(pos, points[_nextIndex]) * 3.28084).round(); + return "${_directionWord(pos)} in $feet ft"; + } + + // Calculates the distance left in the walking stage + // in feet based on the current user position + double getDistanceLeftFeet() { + double distLeft = 0; + if (currWalkingPos != null) { + // if GPS is broken/off, use distance from _nextIndex to the destination + distLeft = _distMeters(currWalkingPos!, points[_nextIndex]); + } + // calculate remaining walking distance + for (int i = _nextIndex; i < points.length - 1; ++i) { + distLeft += _distMeters(points[i], points[i + 1]); + } + return (distLeft * 3.28084); + } + + // Get summary text that shows up in steps view + // such as "Walk 67 ft" + // @override + String getFixedTitle() { + return "Walk ${getDistanceLeftFeet().round()} ft"; + } + + @override + String getSubtitle() { + if (_nextIndex >= points.length) { + return "Walk complete"; + } + return "Waypoint ${_nextIndex + 1} of ${points.length}"; + } + + String _directionWord(LatLng pos) { + if (_nextIndex == 0) { + return "Head"; + } + final incoming = _bearing(points[_nextIndex - 1], pos); + final outgoing = _bearing(pos, points[_nextIndex]); + final diff = (outgoing - incoming + 360) % 360; + if (diff < 20 || diff > 340) { + return "Continue straight"; + } + if (diff <= 170) { + return "Turn right"; + } + if (diff >= 190) { + return "Turn left"; + } + return "U-turn"; + } + + // Initializes the Walking stage given a Leg. + @override + void initWithLeg(Leg leg_in) { + final path = leg_in.pathCoords; + leg = leg_in; + + if (path == null || path.isEmpty) { + throw ArgumentError( + 'Walking leg from ${leg_in.origin} to ${leg_in.destination} has no path.', + ); + } + + points = List.unmodifiable(path); + _nextIndex = 0; + + length = leg_in.duration; + percent_complete = 0.0; + } + + @override + List getPolylines() { + return [ + Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('navigation_walking_${leg?.hashCode ?? "00"}'), + points: points, + color: color, // Walk line color + width: 8, // line width + patterns: [ + PatternItem.dot, + // PatternItem.dash(30), // Longer dashes + PatternItem.gap(15), // Longer gaps + ], + ), + ]; + } +} + +class DemoStage extends NavigationStage { + String getTitle() { + return "This is a demo! #$favoriteNumber"; + } + + String getSubtitle() { + return "Look, here's a subtitle too #$favoriteNumber"; + } + + double length = 15.0; // In minutes + double percent_complete = 0.110; + + LatLng startPoint; + LatLng endPoint; + + double favoriteNumber; + Color color = Colors.black; + LineType lineType; + + DemoStage({ + required this.favoriteNumber, + required this.length, + required this.percent_complete, + required this.startPoint, + required this.endPoint, + required this.color, + required this.lineType, + }); + + @override + Color getColor() { + // Return a random color + // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber + return color; + } + + @override + List getMarkers() { + return [ + Marker( + markerId: MarkerId( + "${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}", + ), + position: this.startPoint, + ), + Marker( + markerId: MarkerId( + "${this.favoriteNumber}-${this.endPoint.latitude}-${this.endPoint.longitude}", + ), + position: this.endPoint, + ), + ]; + } + + @override + List getPolylines() { + return [ + Polyline( + polylineId: PolylineId( + "${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}", + ), + points: [this.startPoint, this.endPoint], + color: this.getColor(), + ), + ]; + } + + List getSteps() { + return [ + NavigationStageStep( + title: "Step 1", + subtitle: "Step 1 subtitle", + time: '1:23 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType, + ), + NavigationStageStep( + title: favoriteNumber == 3 + ? "Step 2 I'm making this title really long to test text wrapping. It's getting even longer now--practically absurd for the name of a bus stop but great for UI testing. " + : "Step 2", + subtitle: "Step 2 subtitle", + time: '4:56 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType, + ), + NavigationStageStep( + title: "Step 3", + subtitle: "Step 3 subtitle", + time: '7:89 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType, + ), + ]; // Get navigation stage steps + } + + bool hasRoundedCorners() { + return favoriteNumber == 2; + } + + final _eventController = StreamController(); + + Stream get events => _eventController + .stream; // This is so the NavigationController can do yourStage.events and access your event controller + + // To add stage events (i.e. if you miss the bus): + // _eventController.add(StageReroute(RerouteReason.wrongBus)) + // _eventController.add(StageReroute(RerouteReason.walkPathChanged)) + // _eventController.add(StageComplete()) // If your stage is complete! + // Note to all frontend devs: Feel free to add additional RerouteReasons if you need them! + + void dispose() { + _eventController.close(); + } + + // New! + void initWithLeg(Leg leg) { + // Do cool stuff to set up your Stage with an e.g. walking or bus leg + } + + void receiveLocationUpdate(Position p) { + // Do whatever you need to with the current location. + // You might want to do some processing (e.g. figure out if the user is close to the end of their walking path) and send a stage event, e.g.: + // _eventController.add(StageComplete()) // If the user has reached the end! + } +} + +class TimelineStep { + double estimated_time; + double percentage; + Color color; + + TimelineStep({ + required this.estimated_time, + required this.percentage, // Percentage of the entire progress bar occupied by this timeline step + required this.color, + }); +} + +class TimelineInfo { + List timelineSteps = []; + double activePositionPercentage = + 0.0; // e.g. if the user is 31% of the way through the whole trip, this equals 0.31 + TimelineInfo({ + List? timelineSteps, + this.activePositionPercentage = 0.0, + }) : timelineSteps = timelineSteps ?? []; +} + +class NavigationManager { + // TODO: Implement ChangeNotifier and learn how that works + + StreamSubscription? _stageEventSub; + + int currentStage = 0; // Stores the current navigation state index + List stageList = [ + DemoStage( + favoriteNumber: 1, + length: 15, + percent_complete: 0.80, + startPoint: LatLng(42.281973, -83.765719), + endPoint: LatLng(42.281291, -83.743918), + color: + darkColors[ColorType + .navigationStepsGray]!, // TODO: Make this dynamic. This will be messy since we need to do something about context in getColor(context, color Type) + lineType: LineType.Dashed, + ), + DemoStage( + favoriteNumber: 2, + length: 33, + percent_complete: 0.23, + startPoint: LatLng(42.281291, -83.743918), + endPoint: LatLng(42.287031, -83.743532), + color: Colors.purple, + lineType: LineType.Dotted, + ), + DemoStage( + favoriteNumber: 3, + length: 4, + percent_complete: 0.0, + startPoint: LatLng(42.287031, -83.743532), + endPoint: LatLng(42.289689, -83.738435), + color: darkColors[ColorType.navigationStepsGray]!, + lineType: LineType.Dashed, + ), + ]; // Stores all the states for users to page back and forth + NavigationLayer? mapLayer; + + NavigationOverlayHost? _overlay; + + void registerOverlay(NavigationOverlayHost overlay) { + _overlay = overlay; + } + + void unregisterOverlay(NavigationOverlayHost overlay) { + if (_overlay == overlay) { + _overlay = null; + } + } + + void receiveLocationUpdate(Position p) { + stageList[currentStage].receiveLocationUpdate(p); + if (currentStage < stageList.length - 1) { + stageList[currentStage + 1].receiveLocationUpdate(p); + } + } + + // Call to update if state changes require an update + void notifyOverlay() { + _overlay?.onNavigationUpdated(); + } + + // Overlay can display the "which bus are you on?" prompt + // We can call this + void showOopsDialog() { + _overlay?.displayOopsDialog(); + } + + void _activateStageSub(NavigationStage stage) { + // TODO: Call this whenever the stage is activated + _stageEventSub?.cancel(); // Drop the old subscription + _stageEventSub = stage.events.listen((event) { + switch (event) { + case StageComplete(): + // Move on to the next stage + case StageReroute(:final reason): + // Handle the reroute + } + }); + } + + void setMapLayer(NavigationLayer mapLayer_in) { + this.mapLayer = mapLayer_in; + rebuildMarkersAndPolylines(); + } + + TimelineInfo getTimeline() { + // TODO: Also return the user's position in the whole journey + + double total_estimated_time = 0.0; + double activePositionTime = + 0.0; // This is the active position percentage before dividing by total estimated trip length + double activePositionPercentage = 0.0; + + for (int i = 0; i < stageList.length; i++) { + double currentStageLength = stageList[i].length; + + total_estimated_time += currentStageLength; + + if (i < currentStage) { + activePositionTime = activePositionTime + currentStageLength; + } else if (i == currentStage) { + activePositionTime += + currentStageLength * stageList[i].percent_complete; + } + } + activePositionPercentage = activePositionTime / total_estimated_time; + + List timelineSteps = []; + + for (int i = 0; i < stageList.length; i++) { + timelineSteps.add( + TimelineStep( + estimated_time: stageList[i].length, + percentage: stageList[i].length / total_estimated_time, + color: stageList[i].getColor(), + // TODO: Define a color for the stage in the stage itself + // color: Colors.red + ), + ); + } + + return TimelineInfo( + timelineSteps: timelineSteps, + activePositionPercentage: activePositionPercentage, + ); + } + + // Some way for the navigation widget to + + void init() { + // Init as necessary + rebuildMarkersAndPolylines(); + } + + // Some sort of code to read the current stage and next stage to determine whether the user can "jump" (stage switch) + // Look at the bus times of the next stage and the walking position of the current stage to determine if the user is A) near the end of their walking path and B) the bus hasn't left yet + // Write a function to detect if the stage switch went wrong + // Allen: Add UI to ask the user about which new bus to take [Check with Ishan and Harvey] + // Isaac: I'll talk to Ishan (gc with Allen+Ishan+Harvey) about what the final logic is for the "Oops" stage + + void rebuildMarkersAndPolylines() { + // Call this whenever markers or polylines change + if (this.mapLayer == null) { + debugPrint( + "Warning: Tried to rebuild markers and polylines but no map layer was registered with NavigationManager!", + ); + return; + } + Set markersToDisplay = stageList + .expand((NavigationStage stage) => stage.getMarkers()) + .toSet(); + Set polylinesToDisplay = stageList + .expand((NavigationStage stage) => stage.getPolylines()) + .toSet(); + + this.mapLayer!.setMarkers(markersToDisplay); + this.mapLayer!.setPolylines(polylinesToDisplay); + this.mapLayer!.reload(); + + // FUTURE TODO: Get some sample data for polylines/markers and conditionally show them on the map--define a "navigation mode" that can be active (or not) in map_screen.dart + } + + NavigationStage getCurrentStage() { + return stageList[currentStage]; + } + + void nextStage() { + currentStage = (currentStage + 1) % stageList.length; + _activateStageSub(stageList[currentStage]); + } + + void previousStage() { + currentStage = (currentStage - 1) % stageList.length; + _activateStageSub(stageList[currentStage]); + } + + void initFromJourney(Journey journey, Color walkingLineColor) { + this.stageList.clear(); + + for (Leg leg in journey.legs) { + // TODO: Call initWithLeg(leg) constructor here if it's a Bus leg + + if (leg.mode == LegMode.walk) { + Walking walkingStage = Walking(); + walkingStage.setColor( + walkingLineColor, + ); // Because Flutter won't let us get a Context inside WalkingStage because it isn't a widget. Womp womp + walkingStage.initWithLeg(leg); + this.stageList.add(walkingStage); + } else if (leg.mode == LegMode.bus) { + NavOnBus onBusStage = NavOnBus(); + onBusStage.initWithLeg(leg); + this.stageList.add(onBusStage); + } + } + + // this.stageList.add( + // DemoStage( + // favoriteNumber: 7, + // length: 20.0, + // percent_complete: 0.72, + // startPoint: LatLng(42.297493, -83.710782), + // endPoint: LatLng(42.398493, -83.811782), + // color: Colors.orange, + // lineType: LineType.Dashed + // ) + // ); + + _overlay?.onNavigationUpdated(); + rebuildMarkersAndPolylines(); + } + + // TODO: Add start()/stop() methods + + // - Allen: Get “Oops” code started. Find a way to talk to the NavigationOverlayWidget + // - Find a way to get the two to talk to each other: I.e. whenever `NavigationOverlayWidget` is created, it calls a specific method inside NavigationManager that says "Hey, I'm here, please save me in a member variable", so when the "Oops" stage happens later you can call localReferenceToOverlayWidget.displayOopsDialog(...) + // The stage (e.g. "On bus") should call the "Oops" stage when it needs to +} + +abstract class NavigationOverlayHost { + void displayOopsDialog(); // just for the Oops state for now... + void onNavigationUpdated(); // call navigation overlay widget to refresh +} +// TODO: Call dispose() on stages as they are removed diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 14b0277..4f97a96 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -10,6 +10,7 @@ class NotificationService { static final _localNotificationsPlugin = FlutterLocalNotificationsPlugin(); static bool _listeningForFcmUpdates = false; static bool _listeningForForegroundMessages = false; + static bool _listeningForMessageOpened = false; static String? _registrationToken; static Function(String)? _tokenChangeCallback; diff --git a/lib/theride_api.dart b/lib/theride_api.dart index 545446d..8f9b7e7 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'dart:math' as Math; +import 'package:bluebus/utils/geometry.dart'; import 'package:http/http.dart' as http; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'constants.dart'; @@ -8,27 +8,6 @@ import 'models/bus.dart'; import 'models/bus_route_line.dart'; import 'services/route_color_service.dart'; -// Function to calculate rotation angle between two geographical points -// (used for bus stop icon orientation) -double pointRotation(double lat1, double lon1, double lat2, double lon2) { - const double degToRad = 0.017453292519943295; // π / 180 - const double radToDeg = 57.29577951308232; // 180 / π - - double dLat = lat2 - lat1; - double dLon = lon2 - lon1; - - // Scale longitude by cos(lat) to correct for east-west distance - double x = dLon * (Math.cos(lat1 * degToRad)); - double y = dLat; - - double angle = Math.atan2(x, y) * radToDeg; - - // Normalize to [0, 360) - if (angle < 0) angle += 360; - - return angle; -} - class RideAPI { static const String baseUrl = BACKEND_URL; @@ -46,14 +25,13 @@ class RideAPI { for (final subroute in subroutes) { try { final points = []; - final stops = []; + final stops = <(int, BusStop)>[]; // Cast to list to be able to be able to get different elements final pointList = subroute['pt'] as List; for (int i = 0; i < pointList.length; i++) { final point = pointList[i]; - final isLast = i == pointList.length - 1; // bool to check if last points.add( LatLng( point['lat']?.toDouble() ?? 0, @@ -61,27 +39,8 @@ class RideAPI { ), ); if (point['typ'] == 'S') { - // get rotation of stop - if (isLast){ - // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( - pointList[i - 2]['lat']?.toDouble() ?? 0, - pointList[i - 2]['lon']?.toDouble() ?? 0, - pointList[i - 1]['lat']?.toDouble() ?? 0, - pointList[i - 1]['lon']?.toDouble() ?? 0, - ); - stops.add(BusStop.fromJson(point, routeId, stopRotation, true)); - - } else { - // use the next 2 points to calculate rotation - double stopRotation = pointRotation( - pointList[i + 1]['lat']?.toDouble() ?? 0, - pointList[i + 1]['lon']?.toDouble() ?? 0, - pointList[i + 2]['lat']?.toDouble() ?? 0, - pointList[i + 2]['lon']?.toDouble() ?? 0, - ); - stops.add(BusStop.fromJson(point, routeId, stopRotation, true)); - } + final stopRotation = routeStopRotation(pointList, i); + stops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } } @@ -103,15 +62,13 @@ class RideAPI { // Handle detour points if present if (subroute.containsKey('dtrpt')) { final detourPoints = []; - final detourStops = []; + final detourStops = <(int, BusStop)>[]; // Cast to list to be able to be able to get different elements final detourPointList = subroute['dtrpt'] as List; for (int i = 0; i < detourPointList.length; i++) { final point = detourPointList[i]; - final isLast = i == detourPointList.length - 1; // bool to check if last - detourPoints.add( LatLng( point['lat']?.toDouble() ?? 0, @@ -119,27 +76,8 @@ class RideAPI { ), ); if (point['typ'] == 'S') { - // get rotation of stop - if (isLast){ - // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( - detourPointList[i - 2]['lat']?.toDouble() ?? 0, - detourPointList[i - 2]['lon']?.toDouble() ?? 0, - detourPointList[i - 1]['lat']?.toDouble() ?? 0, - detourPointList[i - 1]['lon']?.toDouble() ?? 0, - ); - detourStops.add(BusStop.fromJson(point, routeId, stopRotation, true)); - - } else { - // use the next 2 points to calculate rotation - double stopRotation = pointRotation( - detourPointList[i + 1]['lat']?.toDouble() ?? 0, - detourPointList[i + 1]['lon']?.toDouble() ?? 0, - detourPointList[i + 2]['lat']?.toDouble() ?? 0, - detourPointList[i + 2]['lon']?.toDouble() ?? 0, - ); - detourStops.add(BusStop.fromJson(point, routeId, stopRotation, true)); - } + final stopRotation = routeStopRotation(detourPointList, i); + detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } } diff --git a/lib/utils/floorplan_projection.dart b/lib/utils/floorplan_projection.dart new file mode 100644 index 0000000..7f33a3b --- /dev/null +++ b/lib/utils/floorplan_projection.dart @@ -0,0 +1,123 @@ +import 'dart:math' as math; +import 'dart:ui' show Offset; + +import 'package:bluebus/models/floorplan.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Converts a floorplan's plan-pixel coordinates into real world lat/lng. +/// +/// A floor carries two georeferencing POIs (`waypoint1` and `waypoint2`) whose +/// real world positions we know. Two matched point pairs are exactly enough to +/// pin down a *similarity transform* -- uniform scale, rotation and translation +/// -- which is all a flat floorplan needs. Solving for rotation too (rather +/// than assuming the plan is drawn north-up) means buildings that were drawn at +/// an angle line up without any extra data. +/// +/// The math is done in a local equirectangular space where +/// `x = longitude * cos(referenceLatitude)` and `y = latitude`, so that one +/// unit of x and one unit of y cover the same real world distance. Over a +/// single building the distortion from this is far below drawing precision. +class FloorplanProjection { + /// Scale/rotation, stored as the complex number `_scaleCos + i * _scaleSin`. + final double _scaleCos; + final double _scaleSin; + + /// Translation in the local equirectangular space. + final double _translateX; + final double _translateY; + + final double _cosReferenceLatitude; + + const FloorplanProjection._( + this._scaleCos, + this._scaleSin, + this._translateX, + this._translateY, + this._cosReferenceLatitude, + ); + + /// Solves for the transform that maps [pixelA] onto [worldA] and [pixelB] + /// onto [worldB]. + /// + /// Returns null if the two pixel points coincide, which would leave the + /// scale and rotation undetermined. + static FloorplanProjection? fromControlPoints({ + required Offset pixelA, + required LatLng worldA, + required Offset pixelB, + required LatLng worldB, + }) { + final double referenceLatitude = (worldA.latitude + worldB.latitude) / 2; + final double cosReferenceLatitude = math.cos(referenceLatitude * math.pi / 180); + + // Plan pixels grow downward while latitude grows upward, so flip y. + final double planAx = pixelA.dx; + final double planAy = -pixelA.dy; + final double planBx = pixelB.dx; + final double planBy = -pixelB.dy; + + final double worldAx = worldA.longitude * cosReferenceLatitude; + final double worldAy = worldA.latitude; + final double worldBx = worldB.longitude * cosReferenceLatitude; + final double worldBy = worldB.latitude; + + final double planDx = planBx - planAx; + final double planDy = planBy - planAy; + final double worldDx = worldBx - worldAx; + final double worldDy = worldBy - worldAy; + + final double planLengthSquared = planDx * planDx + planDy * planDy; + if (planLengthSquared == 0) return null; + + // Complex division (worldDelta / planDelta) gives scale and rotation at once. + final double scaleCos = (worldDx * planDx + worldDy * planDy) / planLengthSquared; + final double scaleSin = (worldDy * planDx - worldDx * planDy) / planLengthSquared; + + final double translateX = worldAx - (scaleCos * planAx - scaleSin * planAy); + final double translateY = worldAy - (scaleSin * planAx + scaleCos * planAy); + + return FloorplanProjection._( + scaleCos, + scaleSin, + translateX, + translateY, + cosReferenceLatitude, + ); + } + + /// Builds the projection for [floor] from its two waypoint POIs. + /// + /// Returns null if the floor is missing either waypoint, in which case we + /// have no way to place it on the map and it should not be drawn. + static FloorplanProjection? forFloor( + FloorplanFloor floor, { + required LatLng waypoint1, + required LatLng waypoint2, + }) { + final FloorplanPoi? poi1 = floor.findPoiByType(FloorplanTypes.waypoint1); + final FloorplanPoi? poi2 = floor.findPoiByType(FloorplanTypes.waypoint2); + if (poi1 == null || poi2 == null) return null; + + return fromControlPoints( + pixelA: poi1.position, + worldA: waypoint1, + pixelB: poi2.position, + worldB: waypoint2, + ); + } + + /// Projects a single plan-pixel point into real world coordinates. + LatLng toLatLng(Offset planPoint) { + final double planX = planPoint.dx; + final double planY = -planPoint.dy; + + final double worldX = _scaleCos * planX - _scaleSin * planY + _translateX; + final double worldY = _scaleSin * planX + _scaleCos * planY + _translateY; + + return LatLng(worldY, worldX / _cosReferenceLatitude); + } + + /// Projects a whole polygon or polyline. + List toLatLngList(List planPoints) => + planPoints.map(toLatLng).toList(); +} diff --git a/lib/utils/geometry.dart b/lib/utils/geometry.dart new file mode 100644 index 0000000..f261710 --- /dev/null +++ b/lib/utils/geometry.dart @@ -0,0 +1,186 @@ +import 'dart:math'; + +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// Function to calculate rotation angle between two geographical points +/// (used for bus stop icon orientation) +double pointRotation(double lat1, double lon1, double lat2, double lon2) { + double dLat = lat2 - lat1; + double dLon = lon2 - lon1; + + // Scale longitude by cos(lat) to correct for east-west distance + double x = dLon * (cos(lat1 * degrees2Radians)); + double y = dLat; + + double angle = atan2(x, y) * radians2Degrees; + + // Normalize to [0, 360) + if (angle < 0) angle += 360; + + return angle; +} + +double routeStopRotation(List points, int stopIndex) { + final currentStop = points[stopIndex]; + + (double, double) averageLocation(List stops) { + final locations = stops.isEmpty ? [currentStop] : stops; + // find sum of lat/lng and divide by the amount to get average + final latitude = locations.fold(0, (sum, stop) => sum + (stop['lat']?.toDouble() ?? 0)) / locations.length; + final longitude = locations.fold(0, (sum, stop) => sum + (stop['lon']?.toDouble() ?? 0)) / locations.length; + return (latitude, longitude); + } + + // deal with edge cases at start/end of list by reading next four or previous four points + if (stopIndex >= points.length - 2) { + final prevTwo = averageLocation(points.getRange(stopIndex - 2, stopIndex).toList()); + final prevFour = averageLocation(points.getRange(stopIndex - 4, stopIndex - 2).toList()); + return pointRotation(prevTwo.$1, prevTwo.$2, prevFour.$1, prevFour.$2); + } else if (stopIndex <= 2) { + final nextTwo = averageLocation(points.getRange(stopIndex + 1, stopIndex + 3).toList()); + final nextFour = averageLocation(points.getRange(stopIndex + 3, stopIndex + 5).toList()); + return pointRotation(nextTwo.$1, nextTwo.$2, nextFour.$1, nextFour.$2); + } + + final stopsRange = 2; + + // get rotation from the average of the previous stopsRange stops to the average location of the next 3 stops + final previousStops = points + .take(stopIndex).toList() + .reversed + .take(stopsRange).toList(); + final nextStops = points + .skip(stopIndex + 1) + .take(stopsRange).toList(); + + final previous = averageLocation(previousStops); + final next = averageLocation(nextStops); + + return pointRotation(previous.$1, previous.$2, next.$1, next.$2); +} + +extension LatLngListHelpers on List { + double totalDistance() { + double acc = 0.0; + LatLng? prev; + for (final next in this) { + if (prev != null) acc += prev.haversineDistanceMetersTo(next); + prev = next; + } + return acc; + } +} + +extension Vector3GeometryHelpers on Vector3 { + /// expects [this] to be in the same coordinate system used by [LatLng.toEuclideanUnitSphere()] + LatLng toLatLng() { + return LatLng( + (180.0 - acos(z) * radians2Degrees) - 90.0, + atan2(y, x) * radians2Degrees, + ); + } +} + +extension LatLngGeometryHelpers on LatLng { + Vector3 toEuclideanUnitSphere() { + final phi = (180.0 - (latitude + 90.0)) * degrees2Radians; + final theta = longitude * degrees2Radians; + return Vector3(sin(phi) * cos(theta), sin(phi) * sin(theta), cos(phi)); + } + + /// Haversine distance to `other` in meters + double haversineDistanceMetersTo(LatLng other) { + const R = 6371000; // Earth radius in meters + final lat1 = latitude * degrees2Radians; + final lat2 = other.latitude * degrees2Radians; + final dLat = (other.latitude - latitude) * degrees2Radians; + final dLon = (other.longitude - longitude) * degrees2Radians; + + final sa = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2); + final c = 2 * atan2(sqrt(sa), sqrt(1 - sa)); + return R * c; + } + + /// Finds the nearest point in the list [poly], returning an index and distance. + (int, double) nearestPolylineIndexAndDistanceDiscrete(List poly) { + int bestIdx = 0; + double bestDist = double.infinity; + for (int i = 0; i < poly.length; i++) { + final p = poly[i]; + final d = haversineDistanceMetersTo(p); + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + return (bestIdx, bestDist); + } + + /// Returns the closest point on the great circle containing [a] and [b] in euclidean + Vector3 projectedToGreatCircle(LatLng a, LatLng b) { + final point = toEuclideanUnitSphere(); + point.applyProjection( + makePlaneProjection( + a.toEuclideanUnitSphere().cross(b.toEuclideanUnitSphere()), + Vector3.zero(), + ), + ); + return point.normalized(); + } + + /// Returns the closest point on the geodesic between [a] and [b] + LatLng projectedToSegment(LatLng a, LatLng b) { + final aEuc = a.toEuclideanUnitSphere(); + final bEuc = b.toEuclideanUnitSphere(); + final thisEucGreatCirc = projectedToGreatCircle(a, b); + + // this would break if you were on the other side of the globe, which should be fine + final notPastA = (bEuc - aEuc).dot(thisEucGreatCirc - aEuc) >= 0.0; + final notPastB = (aEuc - bEuc).dot(thisEucGreatCirc - bEuc) >= 0.0; + if (notPastA && notPastB) { + return thisEucGreatCirc.toLatLng(); + } + + final aDist = haversineDistanceMetersTo(a); + final bDist = haversineDistanceMetersTo(b); + if (aDist <= bDist) { + return a; + } else { + return b; + } + } + + /// Finds the nearest point on [poly], treating it as a continuous polyline. + /// + /// Returns an index and distance + /// + /// WARNING: hasn't been tested yet, might be buggy + (double, double) nearestPolylineIndexAndDistanceContinuous( + List poly, + ) { + if (poly.isEmpty) { + return (0.0, double.infinity); + } + if (poly.length == 1) { + return (0.0, haversineDistanceMetersTo(poly[0])); + } + var bestIdx = 0.0; + var bestDistance = double.infinity; + for (var i = 0; i < poly.length - 1; i++) { + final projected = projectedToSegment(poly[i], poly[i + 1]); + final distance = haversineDistanceMetersTo(projected); + if (distance < bestDistance) { + bestDistance = distance; + var segmentLength = poly[i].haversineDistanceMetersTo(poly[i + 1]); + if (segmentLength == 0.0) { + segmentLength = double.infinity; + } + bestIdx = i + poly[i].haversineDistanceMetersTo(projected) / segmentLength; + } + } + return (bestIdx, bestDistance); + } +} diff --git a/lib/utils/rebuild_watchdog.dart b/lib/utils/rebuild_watchdog.dart new file mode 100644 index 0000000..ebbb6ba --- /dev/null +++ b/lib/utils/rebuild_watchdog.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +// Call tick() at the top of a State's build() method. Warns in the console if build() +// is firing in a rapid burst (many calls with < threshold between them), which usually +// means a setState() is being triggered from a high-frequency callback (onCameraMove, +// a position stream, an animation listener, etc) instead of only when something visible +// actually changed. +class RebuildWatchdog { + final String label; + final Duration threshold; + final int consecutiveTrigger; + DateTime? _lastBuild; + int _rapidCount = 0; + + RebuildWatchdog( + this.label, { + this.threshold = const Duration(milliseconds: 20), + this.consecutiveTrigger = 5, + }); + + void tick() { + if (!kDebugMode) return; + final now = DateTime.now(); + if (_lastBuild != null && now.difference(_lastBuild!) < threshold) { + _rapidCount++; + if (_rapidCount == consecutiveTrigger) { + debugPrint( + '\x1B[33m⚠️ [$label] build() fired $consecutiveTrigger+ times <${threshold.inMilliseconds}ms apart — ' + 'likely rebuilding every frame (causes low performance/stutter). Check for setState() in a high-frequency callback ' + '(onCameraMove, animation listener, build loop, etc).\x1B[0m', + ); + } + } else { + _rapidCount = 0; // reset once builds slow back down, so it can fire again on a future incident + } + _lastBuild = now; + } +} diff --git a/lib/utils/time.dart b/lib/utils/time.dart new file mode 100644 index 0000000..58ba19d --- /dev/null +++ b/lib/utils/time.dart @@ -0,0 +1,16 @@ + +// utc secs after midnight -> michigan time +import 'package:intl/intl.dart'; + +String convertSecondsToFormattedTime(int secondsFromMidnightUtc) { + final now = DateTime.now().toUtc(); + final midnightUtc = DateTime.utc(now.year, now.month, now.day); + final timeUtc = midnightUtc.add(Duration(seconds: secondsFromMidnightUtc)); + + // Convert the UTC time to the local timezone. + final localTime = timeUtc.toLocal(); + + // Use the DateFormat class to format the local time string. + return DateFormat('h:mm a').format(localTime); +} + diff --git a/lib/widgets/building_sheet.dart b/lib/widgets/building_sheet.dart index e316a21..f7b6848 100644 --- a/lib/widgets/building_sheet.dart +++ b/lib/widgets/building_sheet.dart @@ -24,8 +24,8 @@ void sendEmailWithSender(BuildContext context, String emailSubject, String email void showFallbackOptions(BuildContext context) { showMaizebusOKDialog( contextIn: context, - title: const Text("Email-Send failed"), - content: const Text("Unable to reach the email app on your device. You can still send us feedback by manually emailing contact@maizebus.com"), + title: "Email-Send failed", + content: "Unable to reach the email app on your device. You can still send us feedback by manually emailing contact@maizebus.com", ); } diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 7249238..1e664d8 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -1,6 +1,8 @@ import 'package:bluebus/services/bus_info_service.dart'; import 'package:bluebus/services/bus_repository.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; +import 'package:bluebus/widgets/dialog.dart'; import '../constants.dart'; import '../models/bus.dart'; import '../services/route_color_service.dart'; @@ -40,18 +42,33 @@ class _BusSheetState extends State { @override void initState() { super.initState(); - futureBusStops = fetchNextBusStops(widget.busID); + if (currBus == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + + Navigator.of(context).pop(); + + showMaizebusOKDialog( + contextIn: context, + title: "Uh Oh!", + content: "Unable to fetch bus data. Please check your internet connection and try again.", + ); + }); + } else { + futureBusStops = fetchNextBusStops(widget.busID); + } } @override Widget build(BuildContext context) { // There was a really weird bug where _BusSheetState would get a busID that doesn't exist so currBus would be null. // This accounts for that. - if (currBus == null) return Text("Bus not found"); + // Update: Fixed the blank text "bus not found", should + + if (currBus == null) return Text("Bus Not Found"); final bus = currBus!; - debugPrint(" currBus is ${currBus?.routeId}"); return Container( decoration: BoxDecoration( color: getColor(context, ColorType.background), @@ -129,30 +146,7 @@ Widget michiganBusHeader(Bus bus, BuildContext context) { ), child: Row( children: [ - Container( // Bus circular icon - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: bus.routeColor, - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.routeId, - style: TextStyle( - color: RouteColorService.getContrastingColor( - bus.routeId, - ), - fontSize: 30, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.large(bus.routeId), SizedBox(width: 15), @@ -202,31 +196,7 @@ Widget theRideHeader(Bus bus, BuildContext context) { ), child: Row( children: [ - Container( // Bus circular icon - width: 78, - height: 55, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: bus.routeColor, - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.routeId, - style: TextStyle( - color: RouteColorService.getContrastingColor( - bus.routeId, - ), - fontSize: 30, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.large(bus.routeId), SizedBox(width: 15), diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart new file mode 100644 index 0000000..f6bc084 --- /dev/null +++ b/lib/widgets/composite_map_widget.dart @@ -0,0 +1,320 @@ +import 'package:bluebus/constants.dart'; +import 'package:bluebus/services/map_layers/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_layer.dart'; +import 'package:bluebus/utils/rebuild_watchdog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Zoom the map opens at. Layers that switch behaviour on zoom use this as +/// their starting point, since onCameraMove only fires once the user moves. +const double INITIAL_MAP_ZOOM = 15.0; + +// Define the CompositeMapLayer +abstract class CompositeMapLayer { + // Every CompositeMapLayer must have these five things + bool get isVisible; + Set get polylines; + Set get markers; + Function() get onUpdate; + void setOnUpdate(Function() fn); + void setShowRipple(Function(LatLng) fn) { + debugPrint("Warning: setShowRipple called but method was not overridden."); + } + void dispose() {} + + // Optional: If they need, CompositeMapLayers can include these things + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) {} + + // Optional: Filled shapes this layer draws. Defaults to none so layers that + // only deal in lines and markers don't have to think about it. + Set get polygons => const {}; +} + +// TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff + +class CompositeMapWidget extends StatefulWidget { + final LatLng initialCenter; + final List mapLayers; + final Function(GoogleMapController) onMapCreated; + final ValueChanged? onCameraMove; + final VoidCallback? onCameraIdle; + + CompositeMapWidget({ + required this.initialCenter, + required this.mapLayers, + required this.onMapCreated, + this.onCameraMove, + this.onCameraIdle, + }); + + @override + State createState() { + return CompositeMapWidgetState(); + } +} + +class _RippleWidget extends StatefulWidget { + final Offset center; + final VoidCallback onComplete; + + const _RippleWidget({ + super.key, + required this.center, + required this.onComplete + }); + + @override + State<_RippleWidget> createState() => _RippleWidgetState(); +} + +class _RippleWidgetState extends State<_RippleWidget> with SingleTickerProviderStateMixin { + + late final AnimationController _controller; + late final Animation _scale; + late final Animation _opacity; + + double _maxRadius = 32; + static const _duration = Duration(milliseconds: 350); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: _duration + )..addStatusListener((status) { + if (status == AnimationStatus.completed) widget.onComplete(); + })..forward(); + + _scale = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut + ); + + _opacity = Tween(begin: 0.7, end: 0.0) + .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final radius = _maxRadius * _scale.value; + return Positioned( + left: widget.center.dx - radius, + top: widget.center.dy - radius, + width: radius * 2, + height: radius * 2, + child: Opacity( + opacity: _opacity.value, + child: const DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.black) + ) + ) + ); + }, + ); + + + + } + +} + +class CompositeMapWidgetState extends State + with SingleTickerProviderStateMixin { + GoogleMapController? _mapController; + Set allMarkers = {}; + Set allPolylines = {}; + Set allPolygons = {}; + CameraPosition? oldCameraPosition; + ValueNotifier> _ripples = ValueNotifier([]); + final _rebuildWatchdog = RebuildWatchdog('CompositeMapWidget'); + + void reloadMap() { + setState(() {}); // Rebuild with updated markers + } + void showRipple(LatLng location) async { + if (_mapController == null) { + debugPrint("mapcontroller is null!!!!!!!!!"); + return; + } + debugPrint("Showing ripple at $location"); + ScreenCoordinate coord = await _mapController!.getScreenCoordinate(location); + debugPrint("Got screen coordinate of $coord"); + if (!mounted) return; + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final offset = Offset(coord.x / devicePixelRatio, coord.y / devicePixelRatio); + _ripples.value = [..._ripples.value, offset]; + } + void _removeRipple(Offset offset) { + _ripples.value = _ripples.value.where((r) => r != offset).toList(); + } + + // GoogleMaps styles + String _darkMapStyle = "{}"; + String _lightMapStyle = "{}"; + + Future _loadMapStyles() async { + _darkMapStyle = await rootBundle.loadString('assets/maps_dark_style.json'); + _lightMapStyle = await rootBundle.loadString( + 'assets/maps_light_style.json', + ); + setState(() {}); + } + + @override + initState() { + super.initState(); + _loadMapStyles(); + widget.mapLayers.forEach((CompositeMapLayer layer) { + layer.setOnUpdate(reloadMap); + layer.setShowRipple(showRipple); + if (layer is LiveBusesLayer) { + layer.initWithTickerProvider(this); + } + }); + } + + @override + Widget build(BuildContext context) { + _rebuildWatchdog.tick(); + allMarkers = widget.mapLayers.expand((CompositeMapLayer layer) { + if (!layer.isVisible) return {}; + return layer.markers; + }).toSet(); //Flatten all the markers from each layer into one big layer + allPolylines = widget.mapLayers.expand((CompositeMapLayer layer) { + if (!layer.isVisible) return {}; + return layer.polylines; + }).toSet(); + allPolygons = widget.mapLayers.expand((CompositeMapLayer layer) { + if (!layer.isVisible) return {}; + return layer.polygons; + }).toSet(); + + return Stack( + children: [ + RepaintBoundary( + child: GoogleMap( + compassEnabled: false, + myLocationEnabled: true, + mapToolbarEnabled: false, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + markers: allMarkers, + polylines: allPolylines, + polygons: allPolygons, + cameraTargetBounds: CameraTargetBounds( + LatLngBounds( + southwest: LatLng( + 42.217530, + -83.84367266, + ), // Southern and Westernmost point + northeast: LatLng( + 42.328602, + -83.53892646, + ), // Northern and Easternmost point + ), + ), + minMaxZoomPreference: const MinMaxZoomPreference(10, 21), + // markers: curMarkers.union(widget.staticMarkers), + initialCameraPosition: CameraPosition( + target: widget.initialCenter, + zoom: INITIAL_MAP_ZOOM, + ), + style: isDarkMode(context) ? _darkMapStyle : _lightMapStyle, + onMapCreated: (GoogleMapController controller) { + _mapController = controller; + widget.mapLayers.forEach((CompositeMapLayer layer) { + if (layer is JourneyLayer) { + layer.setMapController(controller); + } + }); + widget.onMapCreated(controller); + }, + onCameraMove: (CameraPosition position) { + + if (oldCameraPosition == null) { + // First camera update + oldCameraPosition = position; + + } else if (oldCameraPosition?.target != position.target || + oldCameraPosition?.tilt != position.tilt || + oldCameraPosition?.zoom != position.zoom) { + for (CompositeMapLayer layer in widget.mapLayers) { + layer.onCameraMove(oldCameraPosition!, position); + } + oldCameraPosition = position; + } + + widget.onCameraMove?.call(position); + }, + onCameraIdle: widget.onCameraIdle, + ), + ), + + IgnorePointer( + child: ValueListenableBuilder>( + valueListenable: _ripples, + builder: (context, ripples, _) { + return Stack( + children: ripples.map((offset) { + return _RippleWidget( + key: ObjectKey(offset), + center: offset, + onComplete: () => _removeRipple(offset) + + ); + }).toList() + ); + }, + ), + ) + ], + ); + + + } + + @override + void dispose() { + super.dispose(); + // widget.mapLayers.forEach((CompositeMapLayer l) { + // l.dispose(); + // }); + for (CompositeMapLayer l in widget.mapLayers) { + l.dispose(); + } + } +} + +// REFACTOR TO-DOS + +// [Done]: Modify each widget's onUpdate call so it only does anything if the widget is visible +// TODO: Talk to Backend team about getting the polyline data sent alongside the navigation request +// [Done]: Pass the MapController back to map_screen.dart to get features like moving the camera working +// [Done, I think]: Figure out why the bus markers aren't loading sometimes +// TODO: Go back to the normal map view when you swipe away the navigation screen +// Looks like pressing the Android back button after swiping away the nav screen works--does it still think the sheet is displayed? +// TODO: Talk with team to make nicer "Get on bus" and "Get off bus" icons in navigation +// POSSIBLE: Maybe work on getting Project Smoothbus to snap to routes if it's close? Engineering that will be pretty involved +// When a new position is received, it'll have to calculate the closest starting point on the line. To do that: +// 1. Find the closest polyline vertex to the bus +// 2. There'll be two possible line segments that include that vertex--Try projecting the bus onto both and pick which is closer +// Do that same process to calculate the bus's ending point on the line +// Then: +// 1. Calculate the total distance *along the line* the bus travels through +// 2. Divide this distance into ~100 segments (10 per second) and save them in an array somewhere +// 3. At each frame, move the bus to the next segment +// NOTE: Some routes "double back" on the same path, which will probably cause problems. We really need a way to distinguish which direction the polyline goes +// POSSIBLE: Make bus stop markers small if you're zoomed out far enough +// POSSIBLE OPTIMIZATION: Only run animation updates for buses that are visible in the viewport? diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 75a2f32..cf3cf27 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; /// maizebus style to all dialogs in the app. Future showMaizebusOKDialog({ required BuildContext contextIn, - Widget? title, - Widget? content, + required String title, + required String content, }) { return showDialog( context: contextIn, @@ -32,8 +32,8 @@ Future showMaizebusOKDialog({ actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), actionsAlignment: MainAxisAlignment.end, - title: title, - content: content, + title: Text(title), + content: Text(content), actions: [ SizedBox( width: double.infinity, diff --git a/lib/widgets/directions_sheet.dart b/lib/widgets/directions_sheet.dart index cae5597..4ae3f34 100644 --- a/lib/widgets/directions_sheet.dart +++ b/lib/widgets/directions_sheet.dart @@ -23,9 +23,10 @@ class DirectionsSheet extends StatefulWidget { final void Function(Location, bool) onChangeSelection; final void Function(Journey)? onSelectJourney; final void Function(Map, Map)? onResolved;final ScrollController? scrollController; + Function(Journey)? onStartNavigation; - const DirectionsSheet({ + DirectionsSheet({ Key? key, required this.origin, required this.dest, @@ -36,6 +37,7 @@ class DirectionsSheet extends StatefulWidget { required this.scrollController, this.onSelectJourney, this.onResolved, + this.onStartNavigation, }) : super(key: key); @override @@ -292,6 +294,7 @@ class _DirectionsSheetState extends State { onChangeSelection: widget.onChangeSelection, onSelectJourney: widget.onSelectJourney, scrollController: widget.scrollController, + onStartNavigation: widget.onStartNavigation, ); } else if (journeyload.hasError) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -302,20 +305,20 @@ class _DirectionsSheetState extends State { if (journeyload.error is LocationError) { showMaizebusOKDialog( contextIn: context, - title: const Text("Location Error"), - content: const Text("Please make sure you have location permissions enabled in settings before trying to get directions"), + title: "Location Error", + content: "Please make sure you have location permissions enabled in settings before trying to get directions", ); } else if (journeyload.error is NotInAnnArborError) { showMaizebusOKDialog( contextIn: context, - title: const Text("Not in Ann Arbor"), - content: const Text("Please make sure you are in Ann Arbor before trying to get on-campus bus directions"), + title: "Not in Ann Arbor", + content: "Please make sure you are in Ann Arbor before trying to get on-campus bus directions", ); } else { showMaizebusOKDialog( contextIn: context, - title: const Text("Unknown Error"), - content: const Text("An unknown error occurred while trying to get directions. Please contact contact@maizebus.com if this persists."), + title: "Unknown Error", + content: "An unknown error occurred while trying to get directions. Please contact contact@maizebus.com if this persists.", ); } }); diff --git a/lib/widgets/favorites_sheet.dart b/lib/widgets/favorites_sheet.dart index 877a935..5c22b28 100644 --- a/lib/widgets/favorites_sheet.dart +++ b/lib/widgets/favorites_sheet.dart @@ -52,7 +52,7 @@ class _FavoritesSheetState extends State { if (!mounted) return; // makes sure widget hasn't been closed while waiting for this final map = {}; for (final r in routes) { - for (final s in r.stops) { + for (final (_, s) in r.stops) { if (!map.containsKey(s.id)) map[s.id] = s.name; } } @@ -68,7 +68,7 @@ class _FavoritesSheetState extends State { if (!mounted) return; // makes sure widget hasn't been closed while waiting for this final map = {}; for (final r in routes) { - for (final s in r.stops) { + for (final (_, s) in r.stops) { if (!map.containsKey(s.id)) map[s.id] = s.name; } } @@ -160,8 +160,8 @@ class _FavoritesSheetState extends State { showMaizebusOKDialog( contextIn: context, - title: const Text("No Favorites"), - content: const Text("Hit the heart icon on a stop to add it to your favorites and see it here!"), + title: "No Favorites", + content: "Hit the heart icon on a stop to add it to your favorites and see it here!", ); }); diff --git a/lib/widgets/floating_draggable_sheet.dart b/lib/widgets/floating_draggable_sheet.dart new file mode 100644 index 0000000..4832cc0 --- /dev/null +++ b/lib/widgets/floating_draggable_sheet.dart @@ -0,0 +1,157 @@ +import 'dart:math'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +/// A [DraggableScrollableSheet] that floats above the bottom of the screen while +/// it is collapsed and grows edge to edge as it is dragged up, similar to the +/// Apple Maps bottom sheet. +/// +/// While collapsed the sheet is inset by [inset] on the left, right and bottom. +/// The inset shrinks to zero as the sheet is dragged towards [expandedSize], so +/// a fully expanded sheet sits flush against the edges of the screen. +/// +/// The corners follow the physical screen corners: a sheet inset by `i` is +/// rounded by [screenCornerRadius] `- i`, so it stays concentric with the screen +/// while floating and matches it exactly once the inset reaches zero. Pass the +/// value from the `screen_corner_radius` package as [screenCornerRadius]. +/// +/// The bottom corners hug the screen, so they follow it all the way down to +/// square on a device that reports no radius. The top corners aren't against an +/// edge: they're currently held at a fixed [topRadius], but see the commented +/// out line in [build] to make them follow the screen too (never dropping below +/// [minRadius]). +class FloatingDraggableSheet extends StatefulWidget { + const FloatingDraggableSheet({ + super.key, + required this.builder, + required this.color, + this.collapsedSize = 0.12, + this.expandedSize = 0.85, + this.inset = 10, + this.screenCornerRadius = 0, + this.minRadius = 25, + this.topRadius = 25, + this.boxShadow = const [], + }); + + /// Builds the sheet's contents. The [ScrollController] must be handed to a + /// scrollable descendant, exactly like [DraggableScrollableSheet.builder]. + final Widget Function(BuildContext context, ScrollController scrollController) + builder; + + /// Background color of the sheet. Contents are clipped to its rounded corners. + final Color color; + + /// Height of the visible (floating) sheet as a fraction of the available + /// height, excluding the [inset] below it. + final double collapsedSize; + + /// Height of the fully expanded sheet as a fraction of the available height. + final double expandedSize; + + /// Gap between the collapsed sheet and the left, right and bottom edges. + final double inset; + + /// Corner radius of the physical screen, used to keep the sheet's corners + /// concentric with it. + final double screenCornerRadius; + + /// Smallest radius the sheet rounds its corners by, for screens whose radius + /// is too small to derive a concentric one from. The bottom corners ignore + /// this once they are flush with the screen edge, so a square-cornered screen + /// gets square bottom corners. + final double minRadius; + + /// Corner radius of the top of the sheet, in every state. + final double topRadius; + + final List boxShadow; + + @override + State createState() => _FloatingDraggableSheetState(); +} + +class _FloatingDraggableSheetState extends State { + final DraggableScrollableController _controller = + DraggableScrollableController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // The sheet's box always reaches the bottom of the screen and the inset + // is drawn inside of it, so the collapsed box has to be taller by the + // inset for the visible part to still be [collapsedSize] tall. + final double minSize = (widget.collapsedSize + + widget.inset / constraints.maxHeight) + .clamp(0.0, widget.expandedSize); + + return DraggableScrollableSheet( + controller: _controller, + initialChildSize: minSize, + minChildSize: minSize, + maxChildSize: widget.expandedSize, + snap: true, + builder: (context, scrollController) { + // DraggableScrollableSheet builds this only once, so the sheet's + // chrome subscribes to the controller itself and the contents are + // passed through untouched (and unrebuilt) as [child]. + return AnimatedBuilder( + animation: _controller, + child: widget.builder(context, scrollController), + builder: (context, child) { + final double size = + _controller.isAttached ? _controller.size : minSize; + // 0 while collapsed, 1 once fully expanded. + final double t = ((size - minSize) / + max(widget.expandedSize - minSize, 0.0001)) + .clamp(0.0, 1.0); + final double inset = widget.inset * (1 - t); + + // Concentric with the screen: the further in the sheet sits, + // the tighter its corners. + final double radius = max( + widget.screenCornerRadius - inset, + widget.minRadius, + ); + + return Padding( + padding: EdgeInsets.only( + left: inset, + right: inset, + bottom: inset, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: widget.color, + boxShadow: widget.boxShadow, + borderRadius: BorderRadius.vertical( + top: Radius.circular(widget.topRadius), + // top: Radius.circular(radius), // uncomment to make the top corners match the screen as well + // Unlike the top, the bottom is up against the screen + // edge once expanded, so it follows the screen exactly + // instead of stopping at [minRadius]. + bottom: Radius.circular( + lerpDouble(radius, widget.screenCornerRadius, t)!, + ), + ), + ), + child: child, + ), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart new file mode 100644 index 0000000..f55b827 --- /dev/null +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -0,0 +1,814 @@ +// import 'dart:js_interop'; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/models/floorplan.dart'; +import 'package:bluebus/services/floorplan_style.dart'; +import 'package:bluebus/services/map_layers/floorplans_layer.dart'; +import 'package:bluebus/utils/floorplan_projection.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:haptic_feedback/haptic_feedback.dart'; + + +const FLOOR_SELECTOR_WIDTH = 50.0; +const FLOOR_SELECTOR_ITEM_HEIGHT = 60.0; +const FLOOR_SELECTOR_BORDER_RADIUS = 25.0; +const FLOOR_SELECTED_HIGHLIGHT_MARGIN = 5.0; +class FloorSelector extends StatefulWidget { + /// Short labels for the floors, in the order they're drawn -- top to bottom. + final List floors; + + /// Which of [floors] starts out selected. + final int initialIndex; + + /// Called with the index into [floors] whenever the selection changes. + final void Function(int index) onFloorSelected; + final void Function(int index) onFloorPreselected; + + const FloorSelector({ + super.key, + required this.floors, + required this.initialIndex, + required this.onFloorSelected, + required this.onFloorPreselected + }); + + @override + State createState() => _FloorSelectorState(); +} + +class _FloorSelectorState extends State { + late int selectedIndex = widget.initialIndex; + late int preselectedIndex = widget.initialIndex; + double yDragDistance = 0.0; + + List get floors => widget.floors; + + void snapToIndex(int index) { + // A fling can overshoot the ends of the list, so land on the nearest floor + // that actually exists. + final int clamped = index.clamp(0, floors.length - 1); + final bool changed = clamped != selectedIndex; + + setState(() { + yDragDistance = 0; + selectedIndex = clamped; + preselectedIndex = selectedIndex; + }); + + if (changed) widget.onFloorSelected(clamped); + } + + + @override + Widget build(BuildContext context) { + + return Container( + width: FLOOR_SELECTOR_WIDTH, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(FLOOR_SELECTOR_BORDER_RADIUS), + // boxShadow: [ + // BoxShadow( + // color: Colors.black, + // blurRadius: 10.0, + // spreadRadius: 10.0 + // ) + // ] + ), + child: GestureDetector( + onVerticalDragUpdate: (details) { + // debugPrint("y delta: ${details.localPosition.dy}"); + setState(() { + yDragDistance += details.delta.dy; + double yPosition = selectedIndex * FLOOR_SELECTOR_ITEM_HEIGHT + yDragDistance; + if (yPosition < 0) { + yDragDistance = -1 * selectedIndex * FLOOR_SELECTOR_ITEM_HEIGHT; + // Lowest allowed value for yDragDistance + } + + if (yPosition > FLOOR_SELECTOR_ITEM_HEIGHT * (floors.length - 1)) { + // yDragDistance = FLOOR_SELECTOR_ITEM_HEIGHT * (floors.length - 1); + yDragDistance = (floors.length - 1 - selectedIndex) * FLOOR_SELECTOR_ITEM_HEIGHT; + // Highest allowed value for yDragDistance + } + + double roughIndex = selectedIndex + (yDragDistance / FLOOR_SELECTOR_ITEM_HEIGHT); + + + if (preselectedIndex != roughIndex.round()) { // We have a new floor preselected + preselectedIndex = roughIndex.round(); + widget.onFloorPreselected(preselectedIndex); + } + + }); + + }, + onVerticalDragEnd: (details) { + double roughIndex = selectedIndex + (yDragDistance / FLOOR_SELECTOR_ITEM_HEIGHT); + debugPrint("Rough new index: $roughIndex"); + snapToIndex(roughIndex.round()); + }, + child: Stack( + children: [ + + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + top: selectedIndex * FLOOR_SELECTOR_ITEM_HEIGHT + yDragDistance, + child: Container( + width: FLOOR_SELECTOR_WIDTH, + height: FLOOR_SELECTOR_ITEM_HEIGHT, + alignment: Alignment.center, + child: Container( + width: FLOOR_SELECTOR_WIDTH - FLOOR_SELECTED_HIGHLIGHT_MARGIN * 2, + height: FLOOR_SELECTOR_ITEM_HEIGHT - FLOOR_SELECTED_HIGHLIGHT_MARGIN * 2, + decoration: BoxDecoration( + color: maizeBusYellow, + borderRadius: BorderRadius.circular(FLOOR_SELECTOR_BORDER_RADIUS - FLOOR_SELECTED_HIGHLIGHT_MARGIN) + ), + ), + ), + ), + + + Column( + children: [ + ...floors.asMap().entries.map((entry) { + int index = entry.key; + String floorNum = entry.value; + + return InkWell( + onTap: () { + snapToIndex(index); + // setState(() { + // selectedIndex = index; + // yDragDistance = 0; + // }); + }, + child: Container( + width: FLOOR_SELECTOR_WIDTH, + height: FLOOR_SELECTOR_ITEM_HEIGHT, + alignment: Alignment.center, + child: Text( + floorNum, + style: TextStyle( + color: Colors.black, + fontSize: 20.0, + fontWeight: (selectedIndex == index) ? FontWeight.bold : FontWeight.normal + ), + + ), + ), + ); + + + }) + ], + ), + + + ], + ) + ) + + + + + + ); + } +} + +class FloorplanPreviewPainter extends CustomPainter { + FloorplanFloor floor; + bool isActive; + + double scaleFactor; + double offsetX; + double offsetY; + + FloorplanPreviewPainter({ + required this.floor, + required this.isActive, + required this.scaleFactor, + required this.offsetX, + required this.offsetY + }); + + @override + void paint(Canvas canvas, Size size) { + // TODO: implement paint + if (this.isActive) { + paintActiveFloorBase(canvas, size); + paintActiveFloorRooms(canvas, size); + } + else paintInactiveFloor(canvas, size); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + // TODO: implement shouldRepaint + return false; + } + + void paintActiveFloorBase(Canvas canvas, Size size) { + + final fillPaint = Paint() + ..color = FLOORPLAN_BASE_FILL + ..strokeWidth = 2.0 + ..style = PaintingStyle.fill; + + final strokePaint = Paint() + ..color = FLOORPLAN_STROKE + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + Path path = Path()..addPolygon( + floor.outline.map((Offset o) { + double newX = (o.dx + offsetX) * scaleFactor; + double newY = (o.dy + offsetY) * scaleFactor; + // debugPrint("New X and Y: $newX, $newY"); + return Offset(newX, newY); + }).toList(), true); + + canvas.drawPath( + path, fillPaint + ); + + canvas.drawPath( + path, + strokePaint + ); + } + + void paintActiveFloorRooms(Canvas canvas, Size size) { + + + + final strokePaint = Paint() + ..color = FLOORPLAN_STROKE + ..strokeWidth = FLOORPLAN_ROOM_STROKE_WIDTH.toDouble() + ..style = PaintingStyle.stroke; + + floor.rooms.forEach((FloorplanRoom room) { + if (room.polygon.length < 3) return; // Skip rooms that aren't sufficiently 2D + if (room.type == "inaccessible") return; + + final fillPaint = Paint() + ..color = floorplanRoomFill(room.type) + ..style = PaintingStyle.fill; + + Path path = Path()..addPolygon( + room.polygon.map((Offset o) { + double newX = (o.dx + offsetX) * scaleFactor; + double newY = (o.dy + offsetY) * scaleFactor; + // debugPrint("New X and Y: $newX, $newY"); + return Offset(newX, newY); + }).toList(), true); + + canvas.drawPath( + path, fillPaint + ); + + // canvas.drawPath( + // path, + // strokePaint + // ); + }); + + } + + static Path floorOutlineToPath(List outline, double scaleFactor, double offsetX, double offsetY) { + return Path()..addPolygon( + outline.map((Offset o) { + double newX = (o.dx + offsetX) * scaleFactor; + double newY = (o.dy + offsetY) * scaleFactor; + // debugPrint("New X and Y: $newX, $newY"); + return Offset(newX, newY); + }).toList(), true); + } + + void paintInactiveFloor(Canvas canvas, Size size) { + + final fillPaint = Paint() + ..color = Color(0x4409006b) + ..strokeWidth = 2.0 + ..style = PaintingStyle.fill; + + final strokePaint = Paint() + ..color = Colors.white + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + // debugPrint("Canvas dimensions are ${size.width} x ${size.height}"); + // debugPrint("Floor outline is ${floor.outline}"); + + // canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), strokePaint); + + // debugPrint("Scale factor: $scaleFactor, offset X: $offsetX, Offset Y: $offsetY"); + + Path path = floorOutlineToPath(floor.outline, scaleFactor, offsetX, offsetY); + + canvas.drawPath( + path, fillPaint + ); + + canvas.drawPath( + path, + strokePaint + ); + } + +} + +class FloorOutlineClipper extends CustomClipper { + + List outline; + double scaleFactor; + double offsetX; + double offsetY; + + FloorOutlineClipper({ + required this.outline, + required this.scaleFactor, + required this.offsetX, + required this.offsetY + }); + + @override + Path getClip(Size size) { + final path = FloorplanPreviewPainter.floorOutlineToPath(outline, scaleFactor, offsetX, offsetY); + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + return false; + } + +} + +class FloorplanOverlay extends StatefulWidget { + // const FloorplanOverlauy + Function? onClosed; + + /// The map layer this overlay drives. Picking a floor here is what swaps the + /// geometry drawn on the map underneath. + final FloorplansLayer floorplansLayer; + + FloorplanOverlay({ + required this.onClosed, + required this.floorplansLayer + }); + + @override + State createState() => _FloorplanOverlayState(); +} + +class _FloorplanOverlayState extends State with TickerProviderStateMixin { + /// The building's floors ordered the way the selector shows them: the top of + /// the building at the top of the list. Empty until the floorplan loads. + List floors = const []; + List alignedFloors = []; + late final AnimationController _controller; + late Animation _floorOffset; + int selectedIndex = 0; + bool loadFinished = false; + double scaleFactor = 1; // Overwritten by getScaleFactor + + double offsetX = 0; // Offset X and Y are used to get rid of any extra space on + double offsetY = 0; // the top or left side of the floor plan + + FloorplansLayer get layer => widget.floorplansLayer; + + @override + void initState() { + super.initState(); + loadFloors(); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 600) + ); + _floorOffset = AlwaysStoppedAnimation(selectedIndex.toDouble()); + } + + double getScaleFactor() { + double maxX = 0; + double maxY = 0; + double minX = double.infinity; + double minY = double.infinity; + + alignedFloors.forEach((FloorplanFloor floor) { + floor.outline.forEach((Offset point) { + if (point.dx < minX) minX = point.dx; + if (point.dy < minY) minY = point.dy; + if (point.dx > maxX) maxX = point.dx; + if (point.dy > maxY) maxY = point.dy; + }); + }); + + offsetX = -1 * minX; + offsetY = -1 * minY; + + debugPrint("Context width: ${MediaQuery.sizeOf(context).width}, maxX - offsetX: ${maxX - offsetX}"); + + return MediaQuery.sizeOf(context).width / (maxX + offsetX); + + // TODO: Check if this works with really tall floors (it might not) + } + + /// Rescales and rotates every floor so its waypoints land on the first + /// floor's waypoint positions, undoing whatever arbitrary pixel scale/angle + /// each floor was originally drawn at. Floors missing either waypoint are + /// left untouched -- there's nothing to align them on. + List getAlignedFloors(List floors) { + if (floors.isEmpty) return floors; + + final FloorplanPoi? refWp1 = floors.first.findPoiByType(FloorplanTypes.waypoint1); + final FloorplanPoi? refWp2 = floors.first.findPoiByType(FloorplanTypes.waypoint2); + if (refWp1 == null || refWp2 == null) return floors; + + return [ + for (final floor in floors) + _alignFloor(floor, targetA: refWp1.position, targetB: refWp2.position), + ]; + } + + /// Same complex-division trick as [FloorplanProjection.fromControlPoints], + /// but solved pixel-to-pixel instead of pixel-to-lat/lng -- no y-flip needed + /// since both floors' plan pixels already grow downward the same way. + FloorplanFloor _alignFloor( + FloorplanFloor floor, { + required Offset targetA, + required Offset targetB, + }) { + final FloorplanPoi? wp1 = floor.findPoiByType(FloorplanTypes.waypoint1); + final FloorplanPoi? wp2 = floor.findPoiByType(FloorplanTypes.waypoint2); + if (wp1 == null || wp2 == null) return floor; + + final Offset planA = wp1.position; + final Offset planB = wp2.position; + + final double planDx = planB.dx - planA.dx; + final double planDy = planB.dy - planA.dy; + final double planLengthSquared = planDx * planDx + planDy * planDy; + if (planLengthSquared == 0) return floor; + + final double targetDx = targetB.dx - targetA.dx; + final double targetDy = targetB.dy - targetA.dy; + + final double scaleCos = (targetDx * planDx + targetDy * planDy) / planLengthSquared; + final double scaleSin = (targetDy * planDx - targetDx * planDy) / planLengthSquared; + + Offset transform(Offset point) { + final double relX = point.dx - planA.dx; + final double relY = point.dy - planA.dy; + return Offset( + targetA.dx + scaleCos * relX - scaleSin * relY, + targetA.dy + scaleSin * relX + scaleCos * relY, + ); + } + + return FloorplanFloor( + id: floor.id, + name: floor.name, + pxPerMeter: floor.pxPerMeter, + width: floor.width, + height: floor.height, + walls: [ + for (final wall in floor.walls) + FloorplanWall(start: transform(wall.start), end: transform(wall.end)), + ], + rooms: [ + for (final room in floor.rooms) + FloorplanRoom( + id: room.id, + name: room.name, + type: room.type, + polygon: room.polygon.map(transform).toList(), + poiId: room.poiId, + ), + ], + pois: [ + for (final poi in floor.pois) + FloorplanPoi( + id: poi.id, + position: transform(poi.position), + type: poi.type, + name: poi.name, + roomId: poi.roomId, + navNodeId: poi.navNodeId, + ), + ], + outline: floor.outline.map(transform).toList(), + nav: FloorplanNavGraph( + nodes: [ + for (final node in floor.nav.nodes) + FloorplanNavNode(id: node.id, position: transform(node.position)), + ], + edges: floor.nav.edges, + ), + ); + } + + /// The map screen kicks the load off at startup, so this has almost always + /// finished already -- awaiting it just covers opening the overlay early. + Future loadFloors() async { + await layer.load(); + if (!mounted) return; + + final List ordered = [...layer.floors, ...layer.floors] // Duplicate floors for testing + ..sort((a, b) => b.level.compareTo(a.level)); + final FloorplanFloor? active = layer.activeFloor; + final int activeIndex = active == null ? -1 : ordered.indexOf(active); + + setState(() { + floors = ordered; + selectedIndex = activeIndex < 0 ? 0 : activeIndex; + loadFinished = true; + + alignedFloors = getAlignedFloors(floors); + scaleFactor = getScaleFactor(); + }); + } + + void selectFloor(int index) async { + // _controller.reset(); + final double oldValue = _floorOffset.value; + await Haptics.vibrate(HapticsType.medium); + setState(() { + selectedIndex = index; + _floorOffset = Tween(begin: oldValue, end: index.toDouble()) + .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutExpo)); + }); + + _controller.forward(from: 0); + // The selector works in display order, the layer in the data's own order. + layer.setFloorIndex(layer.floors.indexOf(floors[index])); + } + + void preselectFloor(int index) async { + // _controller.reset(); + final double oldValue = _floorOffset.value; + await Haptics.vibrate(HapticsType.medium); + setState(() { + selectedIndex = index; + _floorOffset = Tween(begin: oldValue, end: index.toDouble()) + .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutExpo)); + }); + + _controller.forward(from: 0); + // The selector works in display order, the layer in the data's own order. + // layer.setFloorIndex(layer.floors.indexOf(floors[index])); + } + + // void preselectFloor(int index) { + // final double oldValue = _floorOffset.value; + // setState(() { + // _floorOffset = Tween(begin: oldValue, end: index.toDouble()) + // .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutExpo)); + // }); + // _controller.forward(from: 0); + // } + + String get building { + return layer.floorplan?.building ?? ""; + } + + String get floorName { + return floors[selectedIndex].name; + } + + String get title { + if (floors.isEmpty) { + return loadFinished ? "Floorplan unavailable" : "Loading floorplan..."; + } + return "${layer.floorplan?.building ?? ''} ${floors[selectedIndex].name}".trim(); + } + + @override + Widget build(BuildContext context) { + // TODO: implement build + return Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + // Transparent so the floorplan the map is drawing underneath shows + // through -- this overlay is just the chrome around it. + color: Color(0xFF0B5394), + // gradient: LinearGradient( + // begin: Alignment.topLeft, + // end: Alignment(0.8, 1), + // colors: [ + // Color(0xff1f005c), + // Color(0xff5b0060), + // Color(0xff870160), + // Color(0xffac255e), + // Color(0xffca485c), + // Color(0xffe16b5c), + // Color(0xfff39060), + // Color(0xffffb56b), + // ], // Gradient from https://learnui.design/tools/gradient-generator.html + // tileMode: TileMode.mirror, + // ), + ), + child: Center( + + + + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, -30 * _floorOffset.value + 100), + child: Stack( + // alignment: Alignment.center, + children: alignedFloors.asMap().entries.map((entry) { + //entry.key is the index, entry.value is the FloorplanFloor + return + Padding( + padding: EdgeInsetsGeometry.only(top: 30 * entry.key.toDouble() ), + child: Stack( + children: [ + // ClipPath( + // clipper: FloorOutlineClipper( + // outline: entry.value.outline, + // scaleFactor: scaleFactor * 1.2, + // offsetX: offsetX - (50 / scaleFactor), + // offsetY: offsetY + // ), + // child: BackdropFilter( + // filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + // child: SizedBox( + // width: MediaQuery.sizeOf(context).width + 200, + // height: 300 + // // color: Colors.transparent + // ) + // ) + // ), + Transform.scale( + scaleY: 0.75, + child: Transform.rotate( + angle: -1 * math.pi / 4, // 45°, in radians — because of course Flutter didn't give you a degrees param + child: CustomPaint( + size: Size(MediaQuery.sizeOf(context).width + 200, 300), + painter: FloorplanPreviewPainter( + floor: entry.value, + isActive: entry.key == selectedIndex, + scaleFactor: scaleFactor * 1.2, + offsetX: offsetX - (50 / scaleFactor), + offsetY: offsetY + ), + ), + ) + ) + + + + ] + ) + ); + + + }).toList().reversed.toList(), + ), + ); + } + ) + + ), + ), + + SafeArea( // Makes sure the contents aren't covered up by the status or navigation bars. TODO: Add this to NavigationOverlayWidget and other widgets as necessary + child: Column( + children: [ + Padding( + padding: EdgeInsetsGeometry.all(15), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.filled( + icon: Icon(Icons.arrow_back), + iconSize: 30, + onPressed: () { + widget.onClosed?.call(); + }, + style: IconButton.styleFrom(backgroundColor: Colors.white), // TODO: Make this dynamic for light/dark mode + ), + SizedBox(width: 10,), + Spacer() + // Expanded( + // child: Container( + // decoration: BoxDecoration( + // color: Colors.white, // TODO: Make dynamic for light/dark mode + // borderRadius: BorderRadius.all(Radius.circular(30)) + // ), + // child: Padding( + // padding: EdgeInsetsGeometry.only(left: 20, right: 20, top: 7, bottom: 7), + // child: Text( + // title, + // style: TextStyle(color: Colors.black,), + // textAlign: TextAlign.center, + // ), + // ) + // ) + + // ) + ], + ), + ), + + Text( + building, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: "Urbanist", + fontSize: 40, + height: 1 + ) + ), + Text( + floorName, + style: TextStyle( + fontSize: 20 + ), + ), + + Spacer(), + Padding( + padding: EdgeInsetsGeometry.all(15), + child: Row( + // mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + + + // Nothing to pick between until the floorplan has loaded. + if (floors.isNotEmpty) + FloorSelector( + floors: [for (final floor in floors) floor.shortName], + initialIndex: selectedIndex, + onFloorSelected: selectFloor, + onFloorPreselected: preselectFloor, + ), + + + // IconButton.filled( + // icon: Icon(Icons.layers), + // iconSize: 30, + // onPressed: () { + + // }, + // style: IconButton.styleFrom(backgroundColor: Colors.white), // TODO: Make this dynamic for light/dark mode + // ), + SizedBox(width: 8,), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, // TODO: Make dynamic for light/dark mode + borderRadius: BorderRadius.all(Radius.circular(30)) + ), + child: Padding( + padding: EdgeInsetsGeometry.only(left: 20, right: 20, top: 10, bottom: 10), + child: Row( + children: [ + Icon( + Icons.search, + color: Colors.black, + size: 30, + ), + SizedBox(width: 5), + Text( + "Room #", + style: TextStyle( + color: Colors.black, + fontSize: 18 + ), + + ), + ], + ) + + + ) + ) + + ) + + ], + ) + ) + ] + ), + ) + + ], + ); + } + +} \ No newline at end of file diff --git a/lib/widgets/journey_results_widget.dart b/lib/widgets/journey_results_widget.dart index 87c9c5d..0618af0 100644 --- a/lib/widgets/journey_results_widget.dart +++ b/lib/widgets/journey_results_widget.dart @@ -1,5 +1,7 @@ import 'package:bluebus/globals.dart'; import 'package:bluebus/innerShadow.dart'; +import 'package:bluebus/utils/time.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:bluebus/widgets/upcoming_stops_widget.dart'; import 'package:flutter/material.dart'; import '../models/journey.dart'; @@ -59,35 +61,6 @@ String formatSecondsToTimeNoAMPM(int utcSeconds) { } -// helper class to display legs with expanded property -class LegToDisplay { - final String origin; - final String destination; - final double duration; - final int startTime; - final int endTime; - final List? stopTimes; - final Trip? trip; - final String? rt; - final String originID; - final String destinationID; - - bool expanded = false; - - LegToDisplay({ - required this.origin, - required this.destination, - required this.duration, - required this.startTime, - required this.endTime, - this.stopTimes, - this.trip, - this.rt, - required this.originID, - required this.destinationID, - }); -} - class JourneyResultsWidget extends StatefulWidget { final List journeys; final String start; @@ -96,9 +69,10 @@ class JourneyResultsWidget extends StatefulWidget { final Map? dest; final void Function(Location, bool) onChangeSelection; final void Function(Journey)? onSelectJourney; + Function(Journey)? onStartNavigation; final ScrollController? scrollController; - const JourneyResultsWidget({ + JourneyResultsWidget({ super.key, required this.journeys, required this.start, @@ -107,6 +81,7 @@ class JourneyResultsWidget extends StatefulWidget { required this.dest, required this.onChangeSelection, this.onSelectJourney, + this.onStartNavigation, required this.scrollController }); @@ -226,29 +201,7 @@ class _JourneyResultsWidgetState extends State { ...busIDs.map((busID) { return Padding( padding: const EdgeInsets.only(right: 3), - child: Container( - width: 35, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(busID), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - busID, - style: TextStyle( - color: RouteColorService.getContrastingColor(busID), - fontSize: 18, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + child: RouteIcon.smallWithLargerFont(busID), ); }), ], @@ -258,7 +211,10 @@ class _JourneyResultsWidgetState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 16, right: 16), - child: JourneyBody(journey: journey), + child: JourneyBody( + journey: journey, + onStartNavigation: widget.onStartNavigation, + ), ), ], ), @@ -443,33 +399,18 @@ class _JourneyResultsWidgetState extends State { class JourneyBody extends StatefulWidget { final Journey journey; - const JourneyBody({super.key, required this.journey}); + Function(Journey)? onStartNavigation = (Journey _) {}; + JourneyBody({ + super.key, + required this.journey, + this.onStartNavigation + }); @override State createState() => _JourneyBodyState(); } class _JourneyBodyState extends State { - late List legsToDisplay; - void initState() { - super.initState(); - // Initialize the list of legs from the journey prop - legsToDisplay = widget.journey.legs.map((leg) { - return LegToDisplay( - origin: leg.origin, - destination: leg.destination, - duration: leg.duration, - startTime: leg.startTime, - endTime: leg.endTime, - stopTimes: leg.stopTimes, - trip: leg.trip, - rt: leg.rt, - originID: leg.originID, - destinationID: leg.destinationID, - ); - }).toList(); - } - //function to get intermediary stops between start and end (bool, List<(String,int)>) intermediaryBusStops( String orgID, @@ -511,19 +452,6 @@ class _JourneyBodyState extends State { return outputLocations; } - // utc secs after midnight -> michigan time - String convertSecondsToFormattedTime(int secondsFromMidnightUtc) { - final now = DateTime.now().toUtc(); - final midnightUtc = DateTime.utc(now.year, now.month, now.day); - final timeUtc = midnightUtc.add(Duration(seconds: secondsFromMidnightUtc)); - - // Convert the UTC time to the local timezone. - final localTime = timeUtc.toLocal(); - - // Use the DateFormat class to format the local time string. - return DateFormat('h:mm a').format(localTime); - } - // returns when the bus is arriving at this stop (used for navigation) String? busArrivalAtStop( String orgID, @@ -544,8 +472,8 @@ class _JourneyBodyState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // map each leg - ...legsToDisplay.map((leg) { - int index = legsToDisplay.indexOf(leg); + ...widget.journey.legs.map((leg) { + int index = widget.journey.legs.indexOf(leg); // walk or bus? if (leg.rt == null) { @@ -609,29 +537,7 @@ class _JourneyBodyState extends State { Row( children: [ // icon - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(leg.rt!), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - leg.rt!, - style: TextStyle( - color: RouteColorService.getContrastingColor(leg.rt!), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(leg.rt!), SizedBox(width: 10), @@ -672,6 +578,35 @@ class _JourneyBodyState extends State { ); } }), + + Row( + children: [ + Spacer(), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: getColor(context, ColorType.mapButtonPrimary), + foregroundColor: getColor(context, ColorType.mapButtonIcon), + + ), + onPressed: () { + debugPrint("onStartNavigation call!"); + widget.onStartNavigation?.call(widget.journey); + }, + icon: Icon(Icons.assistant_navigation), + // child: Text("Start navigation"), + label: Text( + "Start navigation", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + ), + ], + ), + + + const SizedBox(height: 10) ], ); } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1f52fe1..415f912 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -47,8 +47,8 @@ class MapWidget extends StatelessWidget { ), cameraTargetBounds: CameraTargetBounds( LatLngBounds( - southwest: LatLng(42.217530, -83.84367266), // Southern and Westernmost point - northeast: LatLng(42.328602, -83.53892646), // Northern and Easternmost point + southwest: LatLng(42.217530, -85.84367266), // Southern and Westernmost point + northeast: LatLng(43.328602, -83.53892646), // Northern and Easternmost point ) ), minMaxZoomPreference: const MinMaxZoomPreference(10, 21), @@ -239,8 +239,8 @@ class _AndroidMapState extends State myLocationButtonEnabled: false, cameraTargetBounds: CameraTargetBounds( LatLngBounds( - southwest: LatLng(42.217530, -83.84367266), // Southern and Westernmost point - northeast: LatLng(42.328602, -83.53892646), // Northern and Easternmost point + southwest: LatLng(42.217530, -85.84367266), // Southern and Westernmost point + northeast: LatLng(43.328602, -83.53892646), // Northern and Easternmost point ) ), minMaxZoomPreference: const MinMaxZoomPreference(10, 21), diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index 06e4a0f..6a64204 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -1,7 +1,7 @@ import 'package:bluebus/services/bus_info_service.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import '../constants.dart'; -import '../services/route_color_service.dart'; import '../models/bus_stop.dart'; import 'package:intl/intl.dart'; @@ -21,15 +21,6 @@ String format(String text) { return text[0].toUpperCase() + text.substring(1).toLowerCase(); } -/// lets you check if a bus is the ride (checks if id is numeric) -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - class MiniStopSheet extends StatefulWidget { final String stopID; final String stopName; @@ -49,7 +40,7 @@ class MiniStopSheet extends StatefulWidget { } class _MiniStopSheetState extends State { - late Future<(List, bool)> loadedStopData; + late Future> loadedStopData; @override void initState() { @@ -73,7 +64,7 @@ class _MiniStopSheetState extends State { List arrivingBuses = []; if (snapshot.hasData){ - arrivingBuses = snapshot.data!.$1; + arrivingBuses = snapshot.data!; } if (snapshot.hasData) { @@ -126,36 +117,7 @@ class _MiniStopSheetState extends State { children: [ Row( children: [ - Container( - width: isRide(bus.id) ? 45 : 40, - height: isRide(bus.id) ? 35 : 40, - decoration: isRide(bus.id) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(bus.id), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(bus.id), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.id, - style: TextStyle( - color: RouteColorService.getContrastingColor(bus.id), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(bus.id), SizedBox(width: 15,), diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart new file mode 100644 index 0000000..d2d6950 --- /dev/null +++ b/lib/widgets/navigation_overlay_widget.dart @@ -0,0 +1,794 @@ + +import 'dart:math'; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/globals.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/services/journey_repository.dart'; +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:bluebus/widgets/dialog.dart'; +import 'package:bluebus/widgets/floating_draggable_sheet.dart'; +import 'package:bluebus/widgets/route_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class NavigationOverlay extends StatefulWidget { + + final NavigationManager navigationManager; + + const NavigationOverlay({ + super.key, + required this.navigationManager + }); + + // TODO: add an "update callback register" command so that our NavigationManager can reach into the NavigationOverlayWidget and the map and tell them to update. + + @override + State createState() => _NavigationOverlayState(); + +} + +class _NavigationOverlayState extends State + implements NavigationOverlayHost { + + bool planJourneyInProgress = false; + TimelineInfo timelineInfo = TimelineInfo(); + + void updateTimeline() { // Call this after all the stages are loaded (or stages change) + timelineInfo = widget.navigationManager.getTimeline(); + } + + @override + void initState() { + super.initState(); + updateTimeline(); + widget.navigationManager.registerOverlay(this); // does not set to null, see the navigation manager + } + + @override + void dispose() { + // sets to null + widget.navigationManager.unregisterOverlay(this); + super.dispose(); + } + + @override + void onNavigationUpdated() { + setState(() { + // called from the nav manager, updates stuff + updateTimeline(); + }); + } + + // the regular busOptions button + Widget busOptionButton( + Bus bus, + VoidCallback onTap + ) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: bus.routeColor, + child: Text(bus.routeId, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + bus.id, + style: const TextStyle(decoration: TextDecoration.underline, fontSize: 14), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: const Color(0xFFFFC94A), borderRadius: BorderRadius.circular(10)), + child: Text("1", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), // NOTE: Mock using number 1, need to double check with Bus class + ), + ], + ), + ), + ), + ); + } + + // making this reusable, bit unecessary but if you need a red button with text! + Widget missedBusButton(VoidCallback onTap, Text text) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + child: text, // using passed in text parameter + ), + ); // the beautiful pill of doom and despair + } + + // The code below should diplay the "which bus are you on" popup from UI Team + // Should currently show (#4) (Version of the design..) + @override + void displayOopsDialog() { + showUndismissableMaizebusDialog( + contextIn: context, + title: Text("Which bus are you on?"), + // Builder so the buttons below get a context underneath the dialog route and can pop it + content: Builder( + builder: (dialogContext) => Column( + mainAxisSize: MainAxisSize.min, + spacing: 10.0, + children: [ // All of this data is currently placeholder + busOptionButton(Bus(id: "1234", + position: LatLng(12.1, 12.1), + routeId: "NES", + heading: 12.0, + fullness: "67%", + routeColor: Color.fromARGB(255, 9, 9, 239)), + () { + debugPrint("Oops dialog: picked bus 1234 (NES)"); + Navigator.pop(dialogContext); + }), + busOptionButton(Bus(id: "5678", + position: LatLng(12.1, 12.1), + routeId: "BB", + heading: 12.0, + fullness: "67%", + routeColor: Color.fromARGB(255, 9, 9, 239)), + () { + debugPrint("Oops dialog: picked bus 5678 (BB)"); + Navigator.pop(dialogContext); + }), + missedBusButton(() { + debugPrint("Oops dialog: user missed the bus"); + Navigator.pop(dialogContext); + }, Text("I missed the bus")) + ], + ) + ) + ); + } + + @override + Widget build(BuildContext context) { + // switch (widget.navigationManager.getCurrentStage()) { + // case NavOnBus(): + // // Do stuff + + // case NavWalking(): + // // TODO: Handle this case. + // throw UnimplementedError(); + // } + return Stack( + children: [ + Padding( + padding: EdgeInsetsGeometry.only(left: 10, right: 10, top: 70), + child: Column( // Core column for vertical layout + children: [ + // MaterialButton( + // color: Colors.blue.shade900, + // child: Text(planJourneyInProgress ? "Loading..." : "Init stages from /plan-journey"), + // onPressed: () async { + // setState(() { + // planJourneyInProgress = true; + // }); + // try { + // // Try both forwards and reverse directions + // final fut1 = JourneyRepository.planJourney( + // originLat: 42.274014, + // originLon: -83.753664, + // destLat: 42.297493, + // destLon: -83.710782, + // ); + // final fut2 = JourneyRepository.planJourney( + // originLat: 42.297493, + // originLon: -83.710782, + // destLat: 42.274014, + // destLon: -83.753664, + // ); + // final journeys = (await Future.wait([fut1, fut2])) + // .expand((x) => x) + // .toList(); + + // // Pick a random journey + // widget.navigationManager.initFromJourney(journeys[Random().nextInt(journeys.length)]); + // } finally { + // setState(() { + // planJourneyInProgress = false; + // }); + // } + // } + // ), + + + Row( // Top header row + children: [ + Expanded( + child: + Container( + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + decoration: BoxDecoration( + // color: getColor(context, ColorType.mapButtonPrimary), + color: Color.fromARGB(255, 187, 187, 187), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Column( + children: [ + Container( // The big white card at the top + + // margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(20), + + decoration: BoxDecoration( + color: getColor(context, ColorType.mapButtonPrimary), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Row(children: [ + Icon( + Icons.pool, + color: getColor(context, ColorType.mapButtonIcon), + size: 30, + ), + // Expanded( + + // child: + Padding( + padding: EdgeInsets.only(left: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: getColor(context, ColorType.mapButtonIcon)), + widget.navigationManager.getCurrentStage().getTitle() + ), + // Text( + // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + // widget.navigationManager.getCurrentStage().getSubtitle() + // ), + ] + ) + ), + + // ) + ] + ) + ), + Container( // The smaller bottom protrusion from the big white card + + padding: EdgeInsets.only(top: 10, bottom: 10, left: 20, right: 20), + + + child: Row( + children: [ + Icon( + Icons.pool, + size: 20, + color: getColor(context, ColorType.mapButtonIcon) + ), + SizedBox.square(dimension: 10,), + Text( + "Then turn left", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: getColor(context, ColorType.mapButtonIcon) + ) + ) + ], + ) + ) + ] + ) + ) + + ), + SizedBox.square(dimension: 10.0,), + Container( + + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(16), + + decoration: BoxDecoration( + color: getColor(context, ColorType.mapButtonPrimary), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Column( + children: [ + RouteIcon.small("BB"), + Text( + "3 min", + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + ), + Text( + "arrival", + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + ) + // Padding( + // padding: EdgeInsetsGeometry.only(left: 8), + // child: Text( + // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + // "Bus arriving in 218 mins" + // ), + // ), + // MaterialButton( + // minWidth: 50, + // onPressed: () { + // setState(() { + // widget.navigationManager.previousStage(); + // updateTimeline(); + // }); + // }, + // child: Icon(Icons.arrow_back, color: Colors.white), + // ), + // MaterialButton( + // minWidth: 50, + // onPressed: () { + // setState(() { + // widget.navigationManager.nextStage(); + // updateTimeline(); + // }); + // }, + // child: Icon(Icons.arrow_forward, color: Colors.white) + // ) + + + + ], + ) + ), + ], + ), + + MaterialButton( + color: Colors.red.shade900, + child: Text("Show Oops dialog"), + onPressed: () => widget.navigationManager.showOopsDialog(), + ), + // Expanded(child: SizedBox.expand()), + // SizedBox.expand(), + // const Spacer(), + + // VVVVVV This is the bottom bar--temporarily commenting it out to repurpose it as a DraggableScrollableSheet + + + + + // ) + + // Padding( + // padding: EdgeInsetsGeometry.only(left: 8), + // child: Text( + // // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.primary)), + // "I'm told your bus is coming" + // ), + // ) + + // ], + // ) + // ), + ] + ), + ), + + // TODO: Add a scrim that fades in when you drag up on the progress bar so that the background is darkened behind the DraggableScrollableSheet + + + FloatingDraggableSheet( + collapsedSize: 0.14, // TODO: Compute the height of the progress bar dynamically instead of using 14% of screen height as a hardcoded number + expandedSize: 0.85, + screenCornerRadius: globalScreenBottomRadius, + color: getColor(context, ColorType.infoCardColor), + boxShadow: [ + BoxShadow( + color: getColor(context, ColorType.mapButtonShadow), + blurRadius: 10, + offset: const Offset(0, 6), + ), + ], + builder: (context, scrollController) { + return ListView( + controller: scrollController, + padding: EdgeInsets.all(15), + children: [ + // Container( + + // margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + // padding: EdgeInsets.all(8), + + // decoration: BoxDecoration( + // color: getColor(context, ColorType.infoCardColor), + // boxShadow: [ + // BoxShadow( + // color: getColor( + // context, + // ColorType.mapButtonShadow, + // ), + // blurRadius: 10, + // offset: Offset(0, 6), + // ), + // ], + // borderRadius: + // BorderRadius.circular(25), + // ), + // child: + + // TODO: Figure out why the map panning is so laggy if there's a DraggableScrollableSheet on top + + // NEXT STEPS TODO: get the steps showing inside the DraggableScrollableSheet and fix the lagging! + + Row( // Drag handle + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 60, + height: 5, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.grey.shade400, // TODO: Make this a real color in constants.dart + borderRadius: BorderRadius.circular(1000) + ), + ) + ) + ], + ), + + Column( + children: [ + // ClipRRect( + // borderRadius: BorderRadius.circular(12), + + // child: + LayoutBuilder( + builder: (context, constraints) { + + const double dotSize = 30.0; + const double barHeight = 15.0; + const double topPadding = dotSize; + const double bottomPadding = 10.0; // Space between the bar and the ETA text below it + final double progressWidth = constraints.maxWidth * this.timelineInfo.activePositionPercentage; + final double dotLeft = progressWidth - (dotSize / 2); + // Centers the dot on the bar independently of the paddings above/below it + const double dotTop = topPadding + (barHeight / 2) - (dotSize / 2); + + + return Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Row( + + children: this.timelineInfo.timelineSteps.map((item) { + return Flexible( + flex: item.estimated_time.floor(), // Proportionally sizes to each item's time + child: Container( + height: barHeight, + decoration: BoxDecoration(color: item.color), + ) + ); + // return Container( + // width: MediaQuery.of(context).size.width * item.percentage, + // height: 10, + // decoration: BoxDecoration(color: item.color), + // ); + }).toList(), + // Container( + // width: MediaQuery.of(context).size.width * 0.3, + // height: 10, + // decoration: BoxDecoration(color: Colors.green), + // ), + // Container( + // width: MediaQuery.of(context).size.width * 0.3, + // height: 10, + // decoration: BoxDecoration(color: Colors.red), + // ), + // Container( + // width: MediaQuery.of(context).size.width * 0.3, + // height: 10, + // decoration: BoxDecoration(color: Colors.green), + // ), + ), + Positioned( // Progress fill covering everything before the dot + left: 0, + top: 0, + bottom: 0, + width: progressWidth, + child: DecoratedBox( + decoration: BoxDecoration(color: maizeBusBlue), + ), + ), + ], + ), + ), + ), + + + // Container( + // width: dotSize, + // height: dotSize, + // decoration: const BoxDecoration( + // color: Colors.red, + // shape: BoxShape.circle + // ), + // ), + + Positioned( // TODO: Make this thing animate smoooooothly! + left: dotLeft, + top: dotTop, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: maizeBusBlue, + width: 5 + ), + shape: BoxShape.circle + ), + ), + ) + ], + ); + } + ), + Padding( + padding: EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: Text( + "eta 3:21 ● 21 min", + style: TextStyle( + color: maizeBusBlue, + fontWeight: FontWeight.w700, + fontSize: 18 + ), + ) + ) + ] + // ) + ), + + SizedBox.square(dimension: 20.0,), + + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.navigationManager.stageList.asMap().entries.map((entry) { + + int index = entry.key; + NavigationStage stage = entry.value; + + bool shouldRoundTopCorners = (index == 0) || stage.hasRoundedCorners(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Row( + children: [ + Padding(padding: EdgeInsets.only(left: 10)), + Container( // Gray background behind colorful line segment + width: 30, + height: 50, + decoration: (index != 0) ? BoxDecoration( + color: getColor(context, ColorType.navigationStepsGray) + ) : null, + child: Container( + decoration: BoxDecoration( + color: stage.getColor(), + borderRadius: BorderRadius.only( + topLeft: shouldRoundTopCorners ? Radius.circular(20) : Radius.circular(0), + topRight: shouldRoundTopCorners ? Radius.circular(20) : Radius.circular(0), + ) + ), + ), + ), + + Padding(padding: EdgeInsets.only(left: 15)), + Text( + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold + ), + stage.getTitle() + ), + // Spacer(), + Container(width: 40), + // Text(stage.g()) + ] + ), + + ...stage.getSteps().asMap().entries.map((entry) { + int sub_index = entry.key; + NavigationStageStep step = entry.value; + // NEXT STEPS TODO: get live location showing on the step list + + bool shouldRoundBottomCorners = false; + + if (index == widget.navigationManager.stageList.length - 1 && sub_index == stage.getSteps().length - 1) { + shouldRoundBottomCorners = true; + } + + if (stage.hasRoundedCorners() && sub_index == stage.getSteps().length - 1) { + shouldRoundBottomCorners = true; + } + + return Stack( + + children: [ + Positioned( + left: 10, + top: 0, + bottom: 0, + width: 30, + child: Container( // Gray background behind colorful line segment + width: 30, + // height: 40, + decoration: (index != widget.navigationManager.stageList.length - 1) ? BoxDecoration( + // Only show the gray background if the box shouldn't have a rounded bottom (i.e. isn't at the end of the stage list) + color: getColor(context, ColorType.navigationStepsGray) + ) : null, + child: Container( // Colorful line segment + alignment: Alignment.center, + decoration: BoxDecoration( + color: step.getColor(), + borderRadius: BorderRadius.only( + bottomLeft: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero, + bottomRight: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero + ) + ), + + child: Container( // Inside dot or dash + width: step.lineType == LineType.Dotted ? ((sub_index == stage.getSteps().length - 1) ? 20 : 10) : 4, + height: step.lineType == LineType.Dotted ? ((sub_index == stage.getSteps().length - 1) ? 20 : 10) : 16, + // color: Colors.white, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(100)) + ), + // color: Colors.white + ) + ), + ), + ), + + Padding( + padding: EdgeInsetsGeometry.only(top: 10, bottom: 10, left: 55), + child: Row( + children: [ + // Padding(padding: EdgeInsets.only(left: 20)), + + // Padding(padding: EdgeInsets.only(left: 20)), + Expanded( + child: Text(step.getTitle() + step.getTitle()), + ), + + // // Spacer(), + // Container(width: 40), + Text(step.getTime()) + ] + ) + ) + + ], + ); + + // return Row( + // children: [ + // Padding(padding: EdgeInsets.only(left: 20)), + // Container( // Gray background behind colorful line segment + // width: 30, + // height: 40, + // decoration: (index != widget.navigationManager.stageList.length - 1) ? BoxDecoration( + // // Only show the gray background if the box shouldn't have a rounded bottom (i.e. isn't at the end of the stage list) + // color: getColor(context, ColorType.navigationStepsGray) + // ) : null, + // child: Container( // Colorful line segment + // alignment: Alignment.center, + // decoration: BoxDecoration( + // color: step.getColor(), + // borderRadius: BorderRadius.only( + // bottomLeft: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero, + // bottomRight: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero + // ) + // ), + + // child: Container( // Inside dot or dash + // width: step.lineType == LineType.Dotted ? ((sub_index == stage.getSteps().length - 1) ? 20 : 10) : 4, + // height: step.lineType == LineType.Dotted ? ((sub_index == stage.getSteps().length - 1) ? 20 : 10) : 16, + // // color: Colors.white, + // decoration: BoxDecoration( + // color: Colors.white, + // borderRadius: BorderRadius.all(Radius.circular(100)) + // ), + // // color: Colors.white + // ) + // ), + // ), + // Padding(padding: EdgeInsets.only(left: 20)), + // Expanded( + // child: Text(step.getTitle() + step.getTitle()), + // ), + + // // // Spacer(), + // // Container(width: 40), + // Text(step.getTime()) + // ] + // ); + + + + }).toList() + ], + ); + // return Text(stage.getTitle()); + }).toList(), + ) + + // ) + ] + ); + } + ), + ] + ); + } + +} + +// QUESTION: Should navigation_manager + + + +// FUTURE TODO: Add "Connection lost"/"GPS not very accurate" banners to alert the user of those things +// Some sort of live rotating compass that points to the end of the segment (i.e. if you're walking it replaces the icon and rotates) \ No newline at end of file diff --git a/lib/widgets/navigation_widget.dart b/lib/widgets/navigation_widget.dart new file mode 100644 index 0000000..c2529be --- /dev/null +++ b/lib/widgets/navigation_widget.dart @@ -0,0 +1,30 @@ +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:flutter/material.dart'; + +// TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff + +class NavigationWidget extends StatefulWidget { + final NavigationManager navigationManager; + + NavigationWidget({required this.navigationManager}); + + @override + State createState() { + return NavigationWidgetState(); + } +} + +class NavigationWidgetState extends State + with SingleTickerProviderStateMixin { + // NEXT STEPS TODO: Add a very simple navigation tracking UI to show the current stage + + @override + initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Text("Hello"); // TODO: Return some widget stuff + } +} diff --git a/lib/widgets/reminder_widgets.dart b/lib/widgets/reminder_widgets.dart index 04bd895..dcb1948 100644 --- a/lib/widgets/reminder_widgets.dart +++ b/lib/widgets/reminder_widgets.dart @@ -246,7 +246,7 @@ class ReminderWidget extends StatelessWidget { child: Row( spacing: 10, children: [ - RouteIcon.medium(rtid, type: RouteIconType.normal), + RouteIcon.medium(rtid), Expanded( child: Text( stopName, diff --git a/lib/widgets/route_icon.dart b/lib/widgets/route_icon.dart index 573dd38..4ea78d8 100644 --- a/lib/widgets/route_icon.dart +++ b/lib/widgets/route_icon.dart @@ -6,93 +6,98 @@ bool isRide(String? s) { if (s != null && int.tryParse(s) != null) { // busID is numeric, so it's a ride bus return true; - } + } return false; } -enum RouteIconType { normal, outlined, normalWithWhiteBorder } - class RouteIcon extends StatelessWidget { const RouteIcon({ super.key, required this.rtid, - required this.size, + required this.width, + required this.height, required this.fontSize, - this.type = RouteIconType.normal, }); + /// Match aspect ratio of medium but with a custom size + /// The other set sizes have different aspect ratios! factory RouteIcon.sized( String rtid, - int size, { + double size, { Key? key, - RouteIconType type = RouteIconType.normal, }) { return RouteIcon( key: key, rtid: rtid, - size: size, + width: isRide(rtid) ? size * 1.125 : size, + height: isRide(rtid) ? size * 0.875 : size, fontSize: (size / 2).floor(), - type: type, ); } factory RouteIcon.small( String rtid, { Key? key, - RouteIconType type = RouteIconType.normal, }) { - return RouteIcon.sized(rtid, 35, key: key, type: type); + return RouteIcon( + rtid: rtid, + key: key, + width: isRide(rtid) ? 40 : 35, + height: isRide(rtid) ? 30 : 35, + fontSize: 17, + ); } - factory RouteIcon.medium(String rtid, {Key? key, RouteIconType type = RouteIconType.normal}) { - return RouteIcon.sized(rtid, 40, key: key, type: type); + factory RouteIcon.smallWithLargerFont( + String rtid, { + Key? key, + }) { + return RouteIcon( + rtid: rtid, + key: key, + width: isRide(rtid) ? 40 : 35, + height: isRide(rtid) ? 30 : 35, + fontSize: 18, + ); } - factory RouteIcon.large(String rtid, {Key? key, RouteIconType type = RouteIconType.normal}) { - return RouteIcon.sized(rtid, 60, key: key, type: type); + factory RouteIcon.medium( + String rtid, { + Key? key, + }) { + return RouteIcon.sized(rtid, 40, key: key); // 45 x 35 for theride + } + + factory RouteIcon.large( + String rtid, { + Key? key, + }) { + return RouteIcon( + rtid: rtid, + width: isRide(rtid) ? 78 : 60, + height: isRide(rtid) ? 55 : 60, + fontSize: 30, + key: key, + ); } final String rtid; - final int size; + final double width, height; final int fontSize; - final RouteIconType type; @override Widget build(BuildContext context) { - final sizeWithBorder = switch (type) { - RouteIconType.normalWithWhiteBorder => size + 2, - _ => size - }.toDouble(); - final bgColor = switch (type) { - RouteIconType.outlined => null, - _ => RouteColorService.getRouteColor(rtid), - }; - final fgColor = switch (type) { - RouteIconType.outlined => RouteColorService.getRouteColor(rtid), - _ => RouteColorService.getContrastingColor(rtid), - }; - final border = switch (type) { - RouteIconType.normal => null, - RouteIconType.outlined => Border.all(color: fgColor, width: 2.0), - // Its a weight 2 centered border in the figma but occlusion makes it look like a weight 1 outside border - RouteIconType.normalWithWhiteBorder => Border.all(color: Color(0xFFFFFFFF), width: 1.0), - }; + final bgColor = RouteColorService.getRouteColor(rtid); + final fgColor = RouteColorService.getContrastingColor(rtid); - return Container( // 45, 35 - width: isRide(rtid)? sizeWithBorder * 1.125 : sizeWithBorder, - height: isRide(rtid)? sizeWithBorder * 0.875 : sizeWithBorder, - decoration: isRide(rtid)? - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(sizeWithBorder/2), - color: bgColor, - border: border, - ) - : BoxDecoration( - shape: BoxShape.circle, - color: bgColor, - border: border, - ), + return Container( + width: width, + height: height, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: bgColor, + borderRadius: BorderRadius.circular(9999), + ), alignment: Alignment.center, child: MediaQuery( data: MediaQuery.of( diff --git a/lib/widgets/route_selector_modal.dart b/lib/widgets/route_selector_modal.dart index 2065de4..dce7da4 100644 --- a/lib/widgets/route_selector_modal.dart +++ b/lib/widgets/route_selector_modal.dart @@ -4,10 +4,10 @@ import 'package:bluebus/globals.dart'; import 'package:bluebus/innerShadow.dart'; import 'package:bluebus/widgets/custom_sliding_segmented_control.dart'; import 'package:bluebus/widgets/dialog.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../services/route_color_service.dart'; import '../constants.dart'; // Selecting routes @@ -50,6 +50,8 @@ class _RouteSelectorModalState extends State { michiganRoutes = []; rideRoutes = []; + // debugPrint("******* widget.availableRoutes is ${widget.availableRoutes.length}"); + // Loop through the source once and sort for (var route in widget.availableRoutes) { if (route['id'] != null && int.tryParse(route['id']!) != null) { @@ -375,29 +377,7 @@ class _RouteSelectorModalState extends State { Expanded( child: ListTile( contentPadding: EdgeInsets.only(left: 10, right: 0), - leading: Container( - width: 35, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(route['id']!), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - route['id']!, - style: TextStyle( - color: RouteColorService.getContrastingColor(route['id']!), - fontSize: 17, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + leading: RouteIcon.small(route['id']!), title: Text( route['name'] ?? route['id']!, style: TextStyle( @@ -519,30 +499,7 @@ class _RouteSelectorModalState extends State { child: ListTile( contentPadding: EdgeInsets.only(left: 8, right: 0), minTileHeight: 40, - leading: Container( - width: 40, - height: 30, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(15), - color: RouteColorService.getRouteColor(route['id']!), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - route['id']!, - style: TextStyle( - color: RouteColorService.getContrastingColor(route['id']!), - fontSize: 17, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + leading: RouteIcon.small(route['id']!), title: Text( route['name'] ?? route['id']!, style: TextStyle( @@ -637,8 +594,8 @@ class _RouteSelectorModalState extends State { onPressed: () { showMaizebusOKDialog( contextIn: context, - title: const Text("Route Selector"), - content: const Text("Tap a route to show it on the map. Drag and drop to reorder routes. Long press to select only that route"), + title: "Route Selector", + content: "Tap a route to show it on the map. Drag and drop to reorder routes. Long press to select only that route", ); }, style: IconButton.styleFrom( diff --git a/lib/widgets/search_sheet_main.dart b/lib/widgets/search_sheet_main.dart index 777b944..7a71158 100644 --- a/lib/widgets/search_sheet_main.dart +++ b/lib/widgets/search_sheet_main.dart @@ -85,7 +85,7 @@ class LocationSearchBar extends HookWidget { final stopList = jsonDecode(response.body) as List; return stopList.map((stop) { - final name = stop['name'] as String; + final name = normalizeStopName(stop['name'] as String); final aliases = [ name.split(' ').map((w) => w.isNotEmpty ? w[0] : '').join(), ]; diff --git a/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a8ef262..5e331cd 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:bluebus/globals.dart'; import 'package:bluebus/providers/bus_provider.dart'; import 'package:bluebus/services/bus_info_service.dart'; import 'package:bluebus/services/incoming_bus_reminder_service.dart'; import 'package:bluebus/widgets/dialog.dart'; import 'package:bluebus/widgets/refresh_button.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import '../constants.dart'; @@ -12,17 +14,10 @@ import '../models/bus_stop.dart'; import 'package:intl/intl.dart'; import 'upcoming_stops_widget.dart'; -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - class StopSheet extends StatefulWidget { final String stopID; final String stopName; + final bool isFavorite; final Future Function(String, String) onFavorite; final Future Function(String, String) onUnFavorite; final void Function() onGetDirections; @@ -33,6 +28,7 @@ class StopSheet extends StatefulWidget { Key? key, required this.stopID, required this.stopName, + required this.isFavorite, required this.onFavorite, required this.onUnFavorite, required this.onGetDirections, @@ -88,36 +84,7 @@ class _ExpandableStopWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 9), child: Row( children: [ - Container( // Circular icon on the left (with the bus code, e.g. "NW") - width: isRide(widget.busId) ? 45 : 40, - height: isRide(widget.busId) ? 35 : 40, - decoration: isRide(widget.busId) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(widget.busId), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(widget.busId), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - widget.busId, - style: TextStyle( - color: RouteColorService.getContrastingColor(widget.busId), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(widget.busId), SizedBox(width: 15), @@ -250,10 +217,12 @@ class ExpandableStopWidget extends StatefulWidget { required this.busProvider, }); } - -class _StopSheetState extends State { - late Future<(List, bool)> loadedStopData; - bool? _isFavorited; + +class _StopSheetState extends State with WidgetsBindingObserver { + late Future> loadedStopData; + late bool _isFavorite; + Timer? _refreshTimer; + bool _isInBackground = false; // for select bus stops with images late bool imageBusStop; @@ -262,7 +231,9 @@ class _StopSheetState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); loadedStopData = fetchStopData(widget.stopID); + _isFavorite = widget.isFavorite; imageBusStop = (widget.stopID == "C250") || (widget.stopID == "N406") || @@ -292,14 +263,63 @@ class _StopSheetState extends State { if (widget.stopID == "N553") { imagePath = "assets/PierpontNorthwood.jpg"; } + + // Start auto-refresh every 30 seconds + _startRefreshTimer(); } - void _refreshData() { - setState(() { - loadedStopData = fetchStopData(widget.stopID); + void _startRefreshTimer() { + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + if (!_isInBackground) { + _refreshData(); + } }); } + void _stopRefreshTimer() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + _isInBackground = true; + _stopRefreshTimer(); + break; + case AppLifecycleState.resumed: + _isInBackground = false; + // Refresh immediately when app comes to foreground + _refreshData(); + _startRefreshTimer(); + break; + case AppLifecycleState.detached: + _stopRefreshTimer(); + break; + case AppLifecycleState.hidden: + // Handle hidden state if needed + break; + } + } + + void _refreshData() { + if (!_isInBackground) { + setState(() { + loadedStopData = fetchStopData(widget.stopID); + }); + } + } + + @override + void dispose() { + _stopRefreshTimer(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override Widget build(BuildContext context) { return Stack( @@ -320,13 +340,10 @@ class _StopSheetState extends State { List arrivingBuses = []; if (snapshot.hasData) { - arrivingBuses = snapshot.data!.$1; + arrivingBuses = snapshot.data!; arrivingBuses.sort( (lhs, rhs) => (int.tryParse(lhs.prediction) ?? 0).compareTo(int.tryParse(rhs.prediction) ?? 0) ); - if (_isFavorited == null) { - _isFavorited = snapshot.data!.$2; - } } double initialSize = 0.9; @@ -711,11 +728,8 @@ class _StopSheetState extends State { ElevatedButton( onPressed: () { - // Read the current state - final bool currentStatus = _isFavorited ?? false; - // Call the appropriate function - if (currentStatus){ + if (_isFavorite){ widget.onUnFavorite(widget.stopID, widget.stopName); } else { widget.onFavorite(widget.stopID, widget.stopName); @@ -723,7 +737,7 @@ class _StopSheetState extends State { // Update the UI immediately setState(() { - _isFavorited = !currentStatus; + _isFavorite = !_isFavorite; }); }, style: ElevatedButton.styleFrom( @@ -737,8 +751,8 @@ class _StopSheetState extends State { elevation: 0 ), child: Icon( - (_isFavorited ?? false)? Icons.favorite : Icons.favorite_border, - color: (_isFavorited ?? false)? Colors.red : getColor(context, ColorType.secondaryButtonText), + (_isFavorite ?? false)? Icons.favorite : Icons.favorite_border, + color: (_isFavorite ?? false)? Colors.red : getColor(context, ColorType.secondaryButtonText), size: 20, ), ), @@ -874,8 +888,8 @@ class _ReminderFormState extends State { showMaizebusOKDialog( contextIn: context, - title: Text("Failed to load reminders"), - content: Text("Make sure you have the notification permission enabled in settings. If this error is persistent, please send us feedback through the feedback form in the settings page"), + title: "Failed to load reminders", + content: "Make sure you have the notification permission enabled in settings. If this error is persistent, please send us feedback through the feedback form in the settings page", ); }); return SizedBox.shrink(); @@ -950,36 +964,7 @@ class _ReminderFormState extends State { height: 10, width: 60, ), - Container( - width: isRide(rtid) ? 45 : 40, - height: isRide(rtid) ? 35 : 40, - decoration: isRide(rtid) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(rtid), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(rtid), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - rtid, - style: TextStyle( - color: RouteColorService.getContrastingColor(rtid), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(rtid), Checkbox( value: activeRtids.contains(rtid) != rtidsToChange.contains(rtid), diff --git a/lib/widgets/upcoming_stops_widget.dart b/lib/widgets/upcoming_stops_widget.dart index e8ce6df..55bbd47 100644 --- a/lib/widgets/upcoming_stops_widget.dart +++ b/lib/widgets/upcoming_stops_widget.dart @@ -147,14 +147,6 @@ String futureTime(String minutesInFuture) { return DateFormat('h:mm a').format(futureTime); } -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - // TODO: Make KEY_STOPS an API call! const Color UPCOMING_STOP_COLOR = Color.fromARGB(255, 85, 119, 130); @@ -736,7 +728,9 @@ class _UpcomingStopsWidgetState extends State { child: Container( width: double.infinity, height: widget.isExpanded ? null : 0, - child: (rowElements.length > 0) + child: (!widget.isExpanded) + ? const SizedBox() + : (rowElements.length > 0) ? Column( children: [ ...rowElements, @@ -809,53 +803,3 @@ class UpcomingStopsWidget extends StatefulWidget { required this.childIfNoUpcomingStopsFound, }); } - -Widget rideIcon(Color color, String id){ - return Container( // Bus circular icon - width: 50, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: color, - ), - alignment: Alignment.center, - child: Text( - id, - style: TextStyle( - color: RouteColorService.getContrastingColor( - id, - ), - fontSize: 18, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ); -} - -Widget michiganBusIcon(Color color, String id){ - return Container( // Bus circular icon - width: 35, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: color, - ), - alignment: Alignment.center, - child: Text( - id, - style: TextStyle( - color: RouteColorService.getContrastingColor( - id, - ), - fontSize: 18, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ); -} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ce7db45..d55fd9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,17 +9,17 @@ environment: dependencies: flutter: sdk: flutter - google_maps_flutter: ^2.12.3 + google_maps_flutter: ^2.17.1 geolocator: ^13.0.1 http: ^1.4.0 provider: ^6.1.5+1 flutter_hooks: ^0.21.2 shared_preferences: ^2.2.2 - intl: ^0.20.2 + intl: ^0.20.2 flutter_email_sender: ^7.0.0 haptic_feedback: ^0.6.4+3 flutter_launcher_icons: ^0.14.4 - font_awesome_flutter: ^10.12.0 + font_awesome_flutter: ^11.0.0 flutter_map: ^8.2.2 latlong2: ^0.9.1 url_launcher: ^6.3.2 @@ -30,6 +30,9 @@ dependencies: firebase_messaging: ^16.1.1 flutter_staggered_animations: ^1.1.1 youtube_player_flutter: ^9.1.3 + screen_corner_radius: ^3.0.0 + widget_to_marker: ^1.0.6 + vector_math: ^2.2.0 dev_dependencies: flutter_test: @@ -64,6 +67,8 @@ flutter: assets: - assets/ - assets/portraits/ + - assets/floorplans/ + - assets/floorplans/icons/ flutter_launcher_icons: android: true diff --git a/test/floorplan_test.dart b/test/floorplan_test.dart new file mode 100644 index 0000000..25ebfde --- /dev/null +++ b/test/floorplan_test.dart @@ -0,0 +1,293 @@ +import 'dart:math' as math; + +import 'package:bluebus/models/floorplan.dart'; +import 'package:bluebus/services/floorplan_service.dart'; +import 'package:bluebus/services/map_layers/floorplans_layer.dart'; +import 'package:bluebus/utils/floorplan_projection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Rough great-circle distance in meters, good enough to sanity check that a +/// building comes out building-sized. +double _metersBetween(LatLng a, LatLng b) { + const double metersPerDegreeLatitude = 111320.0; + final double meanLatitude = (a.latitude + b.latitude) / 2 * math.pi / 180; + final double dy = (b.latitude - a.latitude) * metersPerDegreeLatitude; + final double dx = + (b.longitude - a.longitude) * + metersPerDegreeLatitude * + math.cos(meanLatitude); + return math.sqrt(dx * dx + dy * dy); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Duderstadt floorplan', () { + late GeoreferencedFloorplan source; + late FloorplanFloor floor; + late FloorplanProjection projection; + + setUpAll(() async { + source = await FloorplanService.loadDuderstadt(); + floor = source.floorplan.floors.first; + projection = FloorplanProjection.forFloor( + floor, + waypoint1: source.waypoint1, + waypoint2: source.waypoint2, + )!; + }); + + test('parses the asset', () { + expect(source.floorplan.building, 'Duderstadt'); + expect(source.floorplan.floors, hasLength(2)); + + expect(floor.name, 'Floor 1'); + expect(floor.walls, hasLength(919)); + expect(floor.rooms, hasLength(147)); + expect(floor.pois, hasLength(195)); + expect(floor.outline, hasLength(84)); + + final FloorplanFloor basement = source.floorplan.floors[1]; + expect(basement.name, 'Basement'); + expect(basement.walls, hasLength(418)); + expect(basement.rooms, hasLength(76)); + expect(basement.pois, hasLength(78)); + expect(basement.outline, hasLength(33)); + }); + + test('lands every floor on the same building', () { + // The floors are drawn in their own pixel spaces at different scales, so + // the only thing tying them together is the shared waypoint pair. + for (final FloorplanFloor other in source.floorplan.floors) { + final FloorplanProjection otherProjection = FloorplanProjection.forFloor( + other, + waypoint1: source.waypoint1, + waypoint2: source.waypoint2, + )!; + + for (final LatLng corner in otherProjection.toLatLngList(other.outline)) { + expect(_metersBetween(corner, source.waypoint1), lessThan(200)); + } + } + }); + + test('projects each waypoint back onto its real world position', () { + for (final (String type, LatLng expected) in [ + (FloorplanTypes.waypoint1, source.waypoint1), + (FloorplanTypes.waypoint2, source.waypoint2), + ]) { + final FloorplanPoi poi = floor.findPoiByType(type)!; + final LatLng actual = projection.toLatLng(poi.position); + expect(actual.latitude, closeTo(expected.latitude, 1e-9)); + expect(actual.longitude, closeTo(expected.longitude, 1e-9)); + } + }); + + test('scale agrees with the floorplan\'s own pxPerMeter', () { + // Project a known pixel distance and check it comes back the right size. + const double sampleLengthPx = 1000.0; + final double projectedMeters = _metersBetween( + projection.toLatLng(Offset.zero), + projection.toLatLng(const Offset(sampleLengthPx, 0)), + ); + final double expectedMeters = sampleLengthPx / floor.pxPerMeter; + expect(projectedMeters, closeTo(expectedMeters, expectedMeters * 0.05)); + }); + + test('building footprint comes out building-sized and in Ann Arbor', () { + final List outline = projection.toLatLngList(floor.outline); + + final double minLat = outline.map((p) => p.latitude).reduce(math.min); + final double maxLat = outline.map((p) => p.latitude).reduce(math.max); + final double minLng = outline.map((p) => p.longitude).reduce(math.min); + final double maxLng = outline.map((p) => p.longitude).reduce(math.max); + + final double widthMeters = _metersBetween( + LatLng(minLat, minLng), + LatLng(minLat, maxLng), + ); + final double heightMeters = _metersBetween( + LatLng(minLat, minLng), + LatLng(maxLat, minLng), + ); + + expect(widthMeters, inInclusiveRange(100, 250)); + expect(heightMeters, inInclusiveRange(50, 200)); + + // North campus, within a couple hundred meters of the waypoints. + expect( + _metersBetween( + LatLng((minLat + maxLat) / 2, (minLng + maxLng) / 2), + source.waypoint1, + ), + lessThan(200), + ); + }); + }); + + group('Floor ordering', () { + FloorplanFloor floorNamed(String name) => FloorplanFloor( + id: name, + name: name, + pxPerMeter: 1.0, + width: 0.0, + height: 0.0, + walls: const [], + rooms: const [], + pois: const [], + outline: const [], + nav: const FloorplanNavGraph(nodes: [], edges: []), + ); + + test('reads a level and a short label out of the floor name', () { + expect(floorNamed('Floor 1').level, 1); + expect(floorNamed('Floor 1').shortName, '1'); + + expect(floorNamed('Floor 12').level, 12); + expect(floorNamed('Floor 12').shortName, '12'); + + expect(floorNamed('Basement').level, -1); + expect(floorNamed('Basement').shortName, 'B'); + + expect(floorNamed('B2').level, -2); + expect(floorNamed('B2').shortName, 'B'); + }); + + test('falls back to something harmless for an unreadable name', () { + expect(floorNamed('Mezzanine').level, 0); + expect(floorNamed('Mezzanine').shortName, 'M'); + expect(floorNamed('').level, 0); + expect(floorNamed('').shortName, '?'); + }); + + test('orders the real floors with the basement at the bottom', () async { + final GeoreferencedFloorplan source = + await FloorplanService.loadDuderstadt(); + final List ordered = [...source.floorplan.floors] + ..sort((a, b) => b.level.compareTo(a.level)); + + expect([for (final f in ordered) f.shortName], ['1', 'B']); + }); + }); + + group('FloorplansLayer floor switching', () { + late FloorplansLayer layer; + + setUp(() async { + layer = FloorplansLayer(); + await layer.load(); + // Zoom in far enough that the detailed plan is what's drawn. + layer.onCameraMove( + const CameraPosition(target: LatLng(42.2912, -83.7167), zoom: 15.0), + const CameraPosition(target: LatLng(42.2912, -83.7167), zoom: 18.0), + ); + }); + + int segmentsDrawn() => layer.polylines.fold( + 0, + (total, polyline) => total + polyline.points.length - 1, + ); + + test('starts on the first floor', () { + expect(layer.activeFloor!.name, 'Floor 1'); + expect(segmentsDrawn(), 919); + }); + + test('swaps the drawn geometry when the basement is picked', () async { + await layer.setFloorIndex(1); + + expect(layer.activeFloor!.name, 'Basement'); + expect(segmentsDrawn(), 418); + expect( + layer.polygons, + hasLength(1 + layer.activeFloor!.rooms.length), + ); + // Nothing from floor 1 should be left behind. + expect( + layer.polygons.where( + (p) => p.polygonId.value.contains('n2b1-f34'), + ), + isEmpty, + ); + }); + + test('switches back to the first floor', () async { + await layer.setFloorIndex(1); + await layer.setFloorIndex(0); + + expect(layer.activeFloor!.name, 'Floor 1'); + expect(segmentsDrawn(), 919); + }); + }); + + group('FloorplansLayer', () { + late FloorplansLayer layer; + + setUpAll(() async { + layer = FloorplansLayer(); + await layer.load(); + }); + + CameraPosition cameraAt(double zoom) => + CameraPosition(target: const LatLng(42.2912, -83.7167), zoom: zoom); + + void moveTo(double from, double to) => + layer.onCameraMove(cameraAt(from), cameraAt(to)); + + test('draws only the footprint when zoomed out', () { + moveTo(19.0, 15.0); + expect(layer.polygons, hasLength(1)); + expect(layer.polylines, isEmpty); + expect(layer.markers, isEmpty); + }); + + test('draws nothing at city-wide zoom', () { + moveTo(15.0, 12.0); + expect(layer.polygons, isEmpty); + expect(layer.polylines, isEmpty); + expect(layer.markers, isEmpty); + }); + + test('draws the full plan once zoomed in, without labels', () { + moveTo(12.0, 18.0); + // Base footprint plus every room. + expect(layer.polygons, hasLength(1 + layer.activeFloor!.rooms.length)); + expect(layer.polylines, isNotEmpty); + expect(layer.markers, isEmpty); + }); + + test('merges wall segments without dropping any', () { + moveTo(15.0, 18.0); + + // Chaining should meaningfully reduce the object count... + expect(layer.polylines.length, lessThan(919)); + // ...while still accounting for all 919 segments. A chain of n points + // covers n - 1 segments. + final int segmentsDrawn = layer.polylines.fold( + 0, + (total, polyline) => total + polyline.points.length - 1, + ); + expect(segmentsDrawn, 919); + }); + + test('adds room labels and POI icons at the deepest zoom', () { + moveTo(18.0, 19.0); + expect(layer.markers, isNotEmpty); + + final int labels = layer.markers + .where((m) => m.markerId.value.startsWith('floorplan_label_')) + .length; + final int icons = layer.markers + .where((m) => m.markerId.value.startsWith('floorplan_icon_')) + .length; + + // 78 "room" POIs plus the one "food" POI get labels. + expect(labels, 79); + // Every POI with an icon assigned: 6 elevators, 6 stairways, + // 2 escalators, 6 bathrooms, 1 food and 1 info. + expect(icons, 22); + }); + }); +}