From 750834c37a9f07db1e230df6e7cd4fa1554d6c46 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Thu, 19 Mar 2026 17:12:55 -0400 Subject: [PATCH 001/121] made the dialog take in strings not widgets. Replaced where it showed up in the code --- lib/screens/map_screen.dart | 12 ++++++------ lib/widgets/building_sheet.dart | 4 ++-- lib/widgets/dialog.dart | 8 ++++---- lib/widgets/directions_sheet.dart | 12 ++++++------ lib/widgets/favorites_sheet.dart | 4 ++-- lib/widgets/mini_stop_sheet.dart | 4 +++- lib/widgets/route_selector_modal.dart | 4 ++-- lib/widgets/stop_sheet.dart | 4 ++-- 8 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 8ccee2d..78a13ef 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -268,8 +268,8 @@ class _MaizeBusCoreState extends State { if (startupData.persistantMessageTitle != '') { showMaizebusOKDialog( contextIn: context, - title: Text(startupData.persistantMessageTitle), - content: Text(startupData.persistantMessage), + title: startupData.persistantMessageTitle, + content: startupData.persistantMessage, ); } @@ -1810,8 +1810,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.", ); } }, @@ -1836,8 +1836,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.', ); } }, 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/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..81559c2 100644 --- a/lib/widgets/directions_sheet.dart +++ b/lib/widgets/directions_sheet.dart @@ -302,20 +302,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 3b16dac..f7f17d7 100644 --- a/lib/widgets/favorites_sheet.dart +++ b/lib/widgets/favorites_sheet.dart @@ -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/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index 06e4a0f..de7734b 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -106,7 +106,9 @@ class _MiniStopSheetState extends State { onTap: () { widget.onUnfavorite(); }, - child: Icon(Icons.delete_outline) + // changed this icon from a trash to a close + // because I think it looks better + child: Icon(Icons.close) ) ], ), diff --git a/lib/widgets/route_selector_modal.dart b/lib/widgets/route_selector_modal.dart index 2065de4..4cff57b 100644 --- a/lib/widgets/route_selector_modal.dart +++ b/lib/widgets/route_selector_modal.dart @@ -637,8 +637,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/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a8ef262..28a21c7 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -874,8 +874,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(); From 85460d4bd93452bae527a690e2ae28949b93fbf1 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Thu, 19 Mar 2026 18:22:39 -0400 Subject: [PATCH 002/121] added variable corner radius --- lib/screens/map_screen.dart | 70 ++++++++++++++++++------------------- pubspec.yaml | 1 + 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 8ccee2d..83aee0b 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -38,6 +38,7 @@ import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; +import 'package:screen_corner_radius/screen_corner_radius.dart'; //import 'dart:convert'; final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-16 00:00:00Z"); @@ -89,6 +90,8 @@ class MaizeBusCore extends StatefulWidget { class _MaizeBusCoreState extends State { late bool canVibrate; late Journey currDisplayed; + ScreenRadius? screenRadius; + bool screenRadiusLoaded = false; Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( @@ -239,6 +242,9 @@ class _MaizeBusCoreState extends State { theme.onSystemThemeUpdate(context); await theme.loadTheme(); // load user theme data + screenRadius = await ScreenCornerRadius.get(); // load screen radius + screenRadiusLoaded = true; + canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -2016,42 +2022,38 @@ class _MaizeBusCoreState extends State { 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 + // 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; - 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 + if (Platform.isIOS) perfectPadding -= 9; // the -9 just makes it look more pretty on ios - globalBottomPadding = flutterSafeAreaBottom + 15; - globalLeftRightPadding = 15; - globalTopPadding = flutterSafeAreaTop; - } + 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( @@ -2315,9 +2317,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( @@ -2374,7 +2373,7 @@ class _MaizeBusCoreState extends State { heroTag: 'settings_fab', elevation: 0, child: Icon( - Icons.menu, + Icons.settings, color: getColor( context, ColorType.mapButtonIcon, @@ -2425,7 +2424,6 @@ class _MaizeBusCoreState extends State { Spacer(), - // temp row (might add settings button to it later) (!_journeyOverlayActive) ? Padding( padding: const EdgeInsets.only(bottom: 20), diff --git a/pubspec.yaml b/pubspec.yaml index c849a33..78e9dc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: firebase_messaging: ^16.1.1 flutter_staggered_animations: ^1.1.1 youtube_player_flutter: ^9.1.3 + screen_corner_radius: ^3.0.0 dev_dependencies: flutter_test: From a237155fe4c8bcf28f913515bd5b7d04bba2aefc Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Fri, 20 Mar 2026 12:53:25 -0400 Subject: [PATCH 003/121] reverted menu button change --- lib/screens/map_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 83aee0b..969f29d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2373,7 +2373,7 @@ class _MaizeBusCoreState extends State { heroTag: 'settings_fab', elevation: 0, child: Icon( - Icons.settings, + Icons.menu color: getColor( context, ColorType.mapButtonIcon, From 291e6e30a18ec69dc7e4cf54f16a7f062fb18a41 Mon Sep 17 00:00:00 2001 From: Harvey Date: Fri, 20 Mar 2026 19:37:20 -0400 Subject: [PATCH 004/121] Removing multiple spaces in stop names --- lib/constants.dart | 7 +++++++ lib/models/bus_stop.dart | 9 +++++---- lib/screens/map_screen.dart | 2 +- lib/widgets/search_sheet_main.dart | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 0935f71..53a6779 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -50,6 +50,13 @@ const Map fallback_code_to_name = { 'NES': 'North-East Shuttle', }; +final _whitespacePattern = RegExp(r'\s+'); + +String normalizeStopName(String rawStopName) { + // Collapse any sequence of whitespace to a single space and trim edges. + return rawStopName.replaceAll(_whitespacePattern, ' ').trim(); +} + String getPrettyRouteName(String code) { for (Map route in globalAvailableRoutes) { if (route['id'] == code) { 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/screens/map_screen.dart b/lib/screens/map_screen.dart index 8ccee2d..965f669 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -329,7 +329,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(), ]; 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(), ]; From ce4038f6430bec419ef225756d51098fb7c716d1 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Sat, 21 Mar 2026 13:51:21 -0400 Subject: [PATCH 005/121] fixed syntax --- lib/screens/map_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 969f29d..07ed395 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2373,7 +2373,7 @@ class _MaizeBusCoreState extends State { heroTag: 'settings_fab', elevation: 0, child: Icon( - Icons.menu + Icons.menu, color: getColor( context, ColorType.mapButtonIcon, From 1d13a081f49c4507a7f94a547962a0d1392abee1 Mon Sep 17 00:00:00 2001 From: Harvey Date: Sat, 21 Mar 2026 14:20:55 -0400 Subject: [PATCH 006/121] removing "%" from stop names --- lib/constants.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 53a6779..ba5bbf8 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -53,8 +53,8 @@ const Map fallback_code_to_name = { final _whitespacePattern = RegExp(r'\s+'); String normalizeStopName(String rawStopName) { - // Collapse any sequence of whitespace to a single space and trim edges. - return rawStopName.replaceAll(_whitespacePattern, ' ').trim(); + // 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) { From 87e46957456b1a078e3d93cd6bafe45a15355ab5 Mon Sep 17 00:00:00 2001 From: john-yang-11 Date: Sat, 21 Mar 2026 15:06:08 -0400 Subject: [PATCH 007/121] refresh auto --- android/app/build.gradle.kts | 19 ++++++++++--------- android/settings.gradle.kts | 2 +- lib/widgets/stop_sheet.dart | 13 +++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4cc0d1f..fa8c50f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" @@ -48,7 +46,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName - resValue("string", "google_maps_api_key", localProperties.getProperty("GOOGLE_MAPS_API_KEY")) + resValue("string", "google_maps_api_key", localProperties.getProperty("GOOGLE_MAPS_API_KEY") ?: "") } signingConfigs { @@ -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/settings.gradle.kts b/android/settings.gradle.kts index 5067194..39c1887 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -24,7 +24,7 @@ plugins { 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/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a8ef262..26debbe 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:bluebus/globals.dart'; import 'package:bluebus/providers/bus_provider.dart'; import 'package:bluebus/services/bus_info_service.dart'; @@ -254,6 +255,7 @@ class ExpandableStopWidget extends StatefulWidget { class _StopSheetState extends State { late Future<(List, bool)> loadedStopData; bool? _isFavorited; + Timer? _refreshTimer; // for select bus stops with images late bool imageBusStop; @@ -292,6 +294,11 @@ class _StopSheetState extends State { if (widget.stopID == "N553") { imagePath = "assets/PierpontNorthwood.jpg"; } + + // Start auto-refresh every 30 seconds + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + _refreshData(); + }); } void _refreshData() { @@ -300,6 +307,12 @@ class _StopSheetState extends State { }); } + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Stack( From 3b855b252a5f997d8026541d6f9ef34ec46ada8a Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 21 Mar 2026 15:32:09 -0400 Subject: [PATCH 008/121] remove styles as they aren't being used, modify sizes to match existing ones, use widget throughout the app --- lib/widgets/bus_sheet.dart | 52 +----------- lib/widgets/journey_results_widget.dart | 49 +---------- lib/widgets/mini_stop_sheet.dart | 42 +--------- lib/widgets/reminder_widgets.dart | 2 +- lib/widgets/route_icon.dart | 105 +++++++++++++----------- lib/widgets/route_selector_modal.dart | 51 +----------- lib/widgets/stop_sheet.dart | 71 +--------------- lib/widgets/upcoming_stops_widget.dart | 58 ------------- 8 files changed, 70 insertions(+), 360 deletions(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 7249238..bf5483b 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -1,5 +1,6 @@ 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 '../constants.dart'; import '../models/bus.dart'; @@ -129,30 +130,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 +180,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/journey_results_widget.dart b/lib/widgets/journey_results_widget.dart index 87c9c5d..56e51e5 100644 --- a/lib/widgets/journey_results_widget.dart +++ b/lib/widgets/journey_results_widget.dart @@ -1,5 +1,6 @@ import 'package:bluebus/globals.dart'; import 'package:bluebus/innerShadow.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'; @@ -226,29 +227,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), ); }), ], @@ -609,29 +588,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), diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index 06e4a0f..6bdb7b0 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; @@ -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/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..fff6f44 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 @@ -375,29 +375,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 +497,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( diff --git a/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a8ef262..a6e17ff 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -4,6 +4,7 @@ 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,14 +13,6 @@ 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; @@ -88,36 +81,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), @@ -950,36 +914,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..a4430d9 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); @@ -809,53 +801,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 From 6cb6295dacd860dbe2957bfe37b965451753f307 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Sun, 22 Mar 2026 20:57:26 -0400 Subject: [PATCH 009/121] undid change --- lib/widgets/mini_stop_sheet.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index de7734b..f6f0d84 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -106,9 +106,7 @@ class _MiniStopSheetState extends State { onTap: () { widget.onUnfavorite(); }, - // changed this icon from a trash to a close - // because I think it looks better - child: Icon(Icons.close) + child: Icon(Icons.delete_outline ) ], ), From 6a6db2c3c9e8e198cc3355a56864ab4c7bd672f3 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Sun, 22 Mar 2026 20:57:50 -0400 Subject: [PATCH 010/121] undid change --- lib/widgets/mini_stop_sheet.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index f6f0d84..bf8561c 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -105,8 +105,8 @@ class _MiniStopSheetState extends State { GestureDetector( onTap: () { widget.onUnfavorite(); - }, - child: Icon(Icons.delete_outline + } + child: Icon(Icons.delete_outline) ) ], ), From ebc2738da43ee9dea7942c1bcf886042b7f5cfd9 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 22 Mar 2026 22:21:55 -0400 Subject: [PATCH 011/121] Added center map and fixed compile error Added centering for map (on app start, or directly after the user declares that they will allow location tracking) - Edited build gradle and settings gradle to remove compiling error (kotlin 2.3 instead of 2.1). --- android/app/build.gradle.kts | 6 ++++-- android/settings.gradle.kts | 2 +- lib/screens/map_screen.dart | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4cc0d1f..b0540ed 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -38,9 +38,11 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } +} defaultConfig { applicationId = "com.ishankumar.maizebus" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 5067194..06d5bca 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -24,7 +24,7 @@ plugins { 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.0" apply false } include(":app") diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index c30b8c9..460ffa3 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -272,6 +272,7 @@ class _MaizeBusCoreState extends State { content: Text(startupData.persistantMessage), ); } + // loading all this data in parallel await Future.wait([ @@ -306,6 +307,10 @@ class _MaizeBusCoreState extends State { busProvider.startBusUpdates(); busProvider.startRouteUpdates(); await Future.delayed(const Duration(milliseconds: 180)); + + // Center map on user's location on startup if available + + } // need this to make sure that the stop names exist in the cache @@ -1080,6 +1085,7 @@ class _MaizeBusCoreState extends State { void _onMapCreated(GoogleMapController controller) { _mapController = controller; + _centerOnLocation(true); } void _onCameraMove(CameraPosition position) async { @@ -1936,6 +1942,7 @@ class _MaizeBusCoreState extends State { } Position? position = await Geolocator.getLastKnownPosition(); + _centerOnLocation(true); return position; } catch (e) { ScaffoldMessenger.of(context).showSnackBar( From 9e849cc7b1518a0fd9dfd6bd5c8eaebfc89e8a2e Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Mon, 23 Mar 2026 18:01:13 -0400 Subject: [PATCH 012/121] Fixed Centering Infinite Loop Removed unneccessary comments and reimplemented location centering immediately after location permissions are provided to avoid infinite centering loop --- lib/screens/map_screen.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 460ffa3..4fc5017 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -307,10 +307,6 @@ class _MaizeBusCoreState extends State { busProvider.startBusUpdates(); busProvider.startRouteUpdates(); await Future.delayed(const Duration(milliseconds: 180)); - - // Center map on user's location on startup if available - - } // need this to make sure that the stop names exist in the cache @@ -1927,6 +1923,10 @@ class _MaizeBusCoreState extends State { ); return null; } + else { + //Center map once right after user grants location permissions + _centerOnLocation(true); + } } if (permission == LocationPermission.deniedForever) { @@ -1942,7 +1942,6 @@ class _MaizeBusCoreState extends State { } Position? position = await Geolocator.getLastKnownPosition(); - _centerOnLocation(true); return position; } catch (e) { ScaffoldMessenger.of(context).showSnackBar( From 01512243f37d74f9ef46c6b1e21fb05382f7cabf Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Mon, 23 Mar 2026 18:02:14 -0400 Subject: [PATCH 013/121] fixed comma --- lib/widgets/mini_stop_sheet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index bf8561c..06e4a0f 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -105,7 +105,7 @@ class _MiniStopSheetState extends State { GestureDetector( onTap: () { widget.onUnfavorite(); - } + }, child: Icon(Icons.delete_outline) ) ], From 9690a45db38b981e67c0e7845fba1b8cf9860f0a Mon Sep 17 00:00:00 2001 From: Swati Date: Wed, 25 Mar 2026 09:54:51 -0400 Subject: [PATCH 014/121] fixed bug by adding empty sizedbox() --- lib/widgets/upcoming_stops_widget.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/widgets/upcoming_stops_widget.dart b/lib/widgets/upcoming_stops_widget.dart index e8ce6df..743117a 100644 --- a/lib/widgets/upcoming_stops_widget.dart +++ b/lib/widgets/upcoming_stops_widget.dart @@ -736,7 +736,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, From 3a6da16d46b35008f4c0a93448aa10e11cc08a35 Mon Sep 17 00:00:00 2001 From: Static Date: Wed, 25 Mar 2026 16:03:04 -0400 Subject: [PATCH 015/121] persistent favorited stops --- lib/screens/map_screen.dart | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f3ea6d1..b48cd28 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -104,6 +104,7 @@ class _MaizeBusCoreState extends State { Set _displayedPolylines = {}; Set _displayedStopMarkers = {}; + Set _displayedFavoriteStopMarkers = {}; Set _displayedBusMarkers = {}; // Journey overlays for search results Set _displayedJourneyPolylines = {}; @@ -771,14 +772,16 @@ class _MaizeBusCoreState extends State { } if (!_routeStopMarkers.containsKey(routeKey)) { _routeStopMarkers[routeKey] = r.stops - .map( - (stop) => Marker( + .map((stop) { + final isFavorite = _favoriteStops.contains(stop.id); + + final marker = Marker( markerId: MarkerId( 'stop_${stop.id}_${Object.hashAll(r.points)}', ), position: stop.location, flat: true, - icon: _favoriteStops.contains(stop.id) + icon: isFavorite ? (stop.isRide ? _favRideStopIcon ?? BitmapDescriptor.defaultMarkerWithHue( @@ -812,9 +815,12 @@ class _MaizeBusCoreState extends State { }, rotation: stop.rotation, anchor: Offset(0.5, 0.5), - ), - ) - .toSet(); + ); + + // add created stop marker to the favorited markers if it is favorited + if (isFavorite) _displayedFavoriteStopMarkers.add(marker); + return marker; + }).toSet(); } } } @@ -853,7 +859,7 @@ class _MaizeBusCoreState extends State { _routeStopMarkers.forEach((routeKey, markers) { final updated = markers.map((m) { if (m.markerId.value.startsWith('stop_${stpid}_')) { - return Marker( + final marker = Marker( flat: true, markerId: m.markerId, position: m.position, @@ -872,6 +878,15 @@ class _MaizeBusCoreState extends State { rotation: m.rotation, anchor: m.anchor, ); + + // add or remove the marker from the displayed favorite stops + if (!favored) { + _displayedFavoriteStopMarkers.remove(m); + } else { + _displayedFavoriteStopMarkers.add(marker); + } + + return marker; } return m; }).toSet(); @@ -1010,6 +1025,7 @@ class _MaizeBusCoreState extends State { void _updateAllDisplayedMarkers() { _allDisplayedStopMarkers = _displayedStopMarkers + .union(_displayedFavoriteStopMarkers) .union(_displayedBusMarkers) .union(_displayedJourneyMarkers) .union(_searchLocationMarker != null ? {_searchLocationMarker!} : {}); @@ -1078,6 +1094,8 @@ 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, @@ -2129,6 +2147,7 @@ class _MaizeBusCoreState extends State { : {}, ) : _displayedStopMarkers + .union(_displayedFavoriteStopMarkers) .union(_displayedJourneyMarkers) .union( _searchLocationMarker != null From 13c1828b86904d0e72f6a236bcd66d3a1a3f042b Mon Sep 17 00:00:00 2001 From: Pronkle Date: Wed, 25 Mar 2026 16:16:41 -0400 Subject: [PATCH 016/121] swapped: text -> dialogue widget: resolving gradle issues (my end probably) --- lib/widgets/bus_sheet.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 7249238..c1f304d 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -1,6 +1,7 @@ import 'package:bluebus/services/bus_info_service.dart'; import 'package:bluebus/services/bus_repository.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'; @@ -47,7 +48,22 @@ class _BusSheetState extends State { 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"); + // ISSUE: Currently creates a very off aligned blank text screen + // TO DO: Replace with a pop up widget that simply says "No wifi oops" + if (currBus == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + + Navigator.of(context).pop(); + + showMaizebusOKDialog( + contextIn: context, + title: const Text("Uh Oh!"), + content: const Text("Unable to fetch bus data. Looks like you aren't connected to the internet!"), + ); + }); + } + final bus = currBus!; From e5000faecb3fa627b416f2ecdf12de211f5d0792 Mon Sep 17 00:00:00 2001 From: john-yang-11 Date: Sat, 28 Mar 2026 14:22:40 -0400 Subject: [PATCH 017/121] fixed bug with the going out app --- android/app/build.gradle.kts | 2 +- lib/widgets/stop_sheet.dart | 52 +++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fa8c50f..53fbbac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -46,7 +46,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName - resValue("string", "google_maps_api_key", localProperties.getProperty("GOOGLE_MAPS_API_KEY") ?: "") + resValue("string", "google_maps_api_key", localProperties.getProperty("GOOGLE_MAPS_API_KEY")) } signingConfigs { diff --git a/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index 26debbe..4472a7f 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -252,10 +252,11 @@ class ExpandableStopWidget extends StatefulWidget { }); } -class _StopSheetState extends State { +class _StopSheetState extends State with WidgetsBindingObserver { late Future<(List, bool)> loadedStopData; bool? _isFavorited; Timer? _refreshTimer; + bool _isInBackground = false; // for select bus stops with images late bool imageBusStop; @@ -264,6 +265,7 @@ class _StopSheetState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); loadedStopData = fetchStopData(widget.stopID); imageBusStop = (widget.stopID == "C250") || @@ -296,20 +298,58 @@ class _StopSheetState extends State { } // Start auto-refresh every 30 seconds + _startRefreshTimer(); + } + + void _startRefreshTimer() { _refreshTimer = Timer.periodic(const Duration(seconds: 30), (timer) { - _refreshData(); + 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() { - setState(() { - loadedStopData = fetchStopData(widget.stopID); - }); + if (!_isInBackground) { + setState(() { + loadedStopData = fetchStopData(widget.stopID); + }); + } } @override void dispose() { - _refreshTimer?.cancel(); + _stopRefreshTimer(); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } From 4c903bee91074bfa7719a587297513c610dcf67d Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sat, 28 Mar 2026 14:49:07 -0400 Subject: [PATCH 018/121] Update mapscreen.dart Made startLatLng value that starts as default but is changed if you can find a location --- lib/screens/map_screen.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4fc5017..5f56b8b 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -97,7 +97,8 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; CameraPosition? _currentCameraPos; bool? _userLocVisible; - static const LatLng _defaultCenter = LatLng(42.276463, -83.7374598); + static final LatLng _defaultCenter = LatLng(42.276463, -83.7374598); + static LatLng startLatLng = _defaultCenter; Set _displayedPolylines = {}; Set _displayedStopMarkers = {}; @@ -237,7 +238,15 @@ 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(); + try { + final pos = await Geolocator.getCurrentPosition().timeout( + Duration(seconds: 3), + ); + startLatLng = LatLng(pos.latitude, pos.longitude); + } catch (e) { + + }// load user theme data canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -1081,7 +1090,6 @@ class _MaizeBusCoreState extends State { void _onMapCreated(GoogleMapController controller) { _mapController = controller; - _centerOnLocation(true); } void _onCameraMove(CameraPosition position) async { @@ -2094,7 +2102,7 @@ class _MaizeBusCoreState extends State { // underlying map layer (different ios and android) Platform.isIOS ? MapWidget( - initialCenter: _defaultCenter, + initialCenter: startLatLng, polylines: _journeyOverlayActive ? _displayedJourneyPolylines : _displayedPolylines.union( @@ -2120,7 +2128,7 @@ class _MaizeBusCoreState extends State { mapToolbarEnabled: true, ) : AndroidMap( - initialCenter: _defaultCenter, + initialCenter: startLatLng, polylines: _journeyOverlayActive ? _displayedJourneyPolylines : _displayedPolylines.union( From 79b7791f2a6ba06ec7f861411afa8dc4391b7181 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sat, 28 Mar 2026 15:05:29 -0400 Subject: [PATCH 019/121] Update map_screen.dart Updated to use getLastKnownLocation, maintained _defaultCenter variable for clarity along with startLatLng which updates if _getLastKnownLocation provides a location --- lib/screens/map_screen.dart | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 5f56b8b..dd0d87a 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -97,7 +97,7 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; CameraPosition? _currentCameraPos; bool? _userLocVisible; - static final LatLng _defaultCenter = LatLng(42.276463, -83.7374598); + static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; Set _displayedPolylines = {}; @@ -239,15 +239,14 @@ class _MaizeBusCoreState extends State { ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); await theme.loadTheme(); - try { - final pos = await Geolocator.getCurrentPosition().timeout( - Duration(seconds: 3), - ); - startLatLng = LatLng(pos.latitude, pos.longitude); - } catch (e) { - - }// load user theme data + //Trying to find the location of the user to set initial position. If not found, defaults to _defaultCenter + Position? pos = await Geolocator.getLastKnownPosition(); + if (pos != null){ + startLatLng = LatLng(pos.latitude, pos.longitude); + } + + canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); From a509eed75c60ef313b7750d12eb1bcd56ade3ce1 Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 28 Mar 2026 15:39:37 -0400 Subject: [PATCH 020/121] fix(ui): unfavoriting a ride stop turns stop blue --- lib/screens/map_screen.dart | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b48cd28..711118f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -120,6 +120,7 @@ class _MaizeBusCoreState extends State { Marker? _searchLocationMarker; final Set _selectedRoutes = {}; List> _availableRoutes = []; + Map _stopIsRide = {}; // Custom marker icons BitmapDescriptor? _busIcon; @@ -819,6 +820,7 @@ class _MaizeBusCoreState extends State { // add created stop marker to the favorited markers if it is favorited if (isFavorite) _displayedFavoriteStopMarkers.add(marker); + _stopIsRide[stop.id] = stop.isRide; return marker; }).toSet(); } @@ -856,6 +858,7 @@ 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}_')) { @@ -864,15 +867,24 @@ class _MaizeBusCoreState extends State { markerId: m.markerId, position: m.position, icon: favored - ? (_favStopIcon ?? - _stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )) - : (_stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - )), + ? (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, From fa79a05a091009a564ba8994d2aec74df7338dcb Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 28 Mar 2026 16:22:29 -0400 Subject: [PATCH 021/121] perf(ui): turned stop vars into maps from stopID to marker, removing duplicate stop markers for optimization --- lib/screens/map_screen.dart | 221 +++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 103 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 711118f..1dc8b96 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -103,8 +103,8 @@ class _MaizeBusCoreState extends State { static const LatLng _defaultCenter = LatLng(42.276463, -83.7374598); Set _displayedPolylines = {}; - Set _displayedStopMarkers = {}; - Set _displayedFavoriteStopMarkers = {}; + Map _displayedStopMarkers = {}; // maps from stopID to marker + Map _displayedFavoriteStopMarkers = {}; Set _displayedBusMarkers = {}; // Journey overlays for search results Set _displayedJourneyPolylines = {}; @@ -136,7 +136,7 @@ class _MaizeBusCoreState extends State { // 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; // maximum allowed distance (meters) from a stop to a candidate polyline point @@ -772,57 +772,59 @@ class _MaizeBusCoreState extends State { ); } if (!_routeStopMarkers.containsKey(routeKey)) { - _routeStopMarkers[routeKey] = r.stops - .map((stop) { - final isFavorite = _favoriteStops.contains(stop.id); + _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) {} - 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), + _showStopSheet( + stop.id, + stop.name, + stop.location.latitude, + stop.location.longitude, ); - - // add created stop marker to the favorited markers if it is favorited - if (isFavorite) _displayedFavoriteStopMarkers.add(marker); - _stopIsRide[stop.id] = stop.isRide; - return marker; - }).toSet(); + }, + 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; + } } } } @@ -860,62 +862,71 @@ class _MaizeBusCoreState extends State { // 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}_')) { - final marker = 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, - ); + // 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, + ); - // add or remove the marker from the displayed favorite stops - if (!favored) { - _displayedFavoriteStopMarkers.remove(m); - } else { - _displayedFavoriteStopMarkers.add(marker); - } + // gets first marker of this stop id and adds it to the favorited stop markers + if (favored && !_displayedFavoriteStopMarkers.containsKey(stpid)) { + _displayedFavoriteStopMarkers[stpid] = newMarker; + } - return marker; - } - return m; - }).toSet(); - _routeStopMarkers[routeKey] = updated; + 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; @@ -925,7 +936,7 @@ class _MaizeBusCoreState extends State { void _updateDisplayedRoutes() { final selectedPolylines = {}; - final selectedStopMarkers = {}; + final selectedStopMarkers = {}; for (final routeId in _selectedRoutes) { // Find all variants of this route @@ -937,9 +948,13 @@ 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; + } + }); } } @@ -1036,8 +1051,8 @@ class _MaizeBusCoreState extends State { } void _updateAllDisplayedMarkers() { - _allDisplayedStopMarkers = _displayedStopMarkers - .union(_displayedFavoriteStopMarkers) + _allDisplayedStopMarkers = _displayedStopMarkers.values.toSet() + .union(_displayedFavoriteStopMarkers.values.toSet()) .union(_displayedBusMarkers) .union(_displayedJourneyMarkers) .union(_searchLocationMarker != null ? {_searchLocationMarker!} : {}); @@ -2158,8 +2173,8 @@ class _MaizeBusCoreState extends State { ? {_searchLocationMarker!} : {}, ) - : _displayedStopMarkers - .union(_displayedFavoriteStopMarkers) + : _displayedStopMarkers.values.toSet() + .union(_displayedFavoriteStopMarkers.values.toSet()) .union(_displayedJourneyMarkers) .union( _searchLocationMarker != null From a48590169a7f9f91117bc466aa3db3f7a74fa183 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sat, 28 Mar 2026 16:36:21 -0400 Subject: [PATCH 022/121] bus fix changes --- lib/widgets/bus_sheet.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index c1f304d..6134f12 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -48,8 +48,7 @@ class _BusSheetState extends State { 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. - // ISSUE: Currently creates a very off aligned blank text screen - // TO DO: Replace with a pop up widget that simply says "No wifi oops" + // Update: Fixed the blank text "bus not found", should if (currBus == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; @@ -61,8 +60,8 @@ class _BusSheetState extends State { title: const Text("Uh Oh!"), content: const Text("Unable to fetch bus data. Looks like you aren't connected to the internet!"), ); - }); - } + }); + } // bus not found final bus = currBus!; From eb17d3b8608d062fe2626acaaff05c7d66d41425 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sat, 28 Mar 2026 17:33:32 -0400 Subject: [PATCH 023/121] working and tested version of bus not found replacement popup --- lib/widgets/bus_sheet.dart | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 6134f12..234fb2b 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -41,14 +41,6 @@ class _BusSheetState extends State { @override void initState() { super.initState(); - 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. - // Update: Fixed the blank text "bus not found", should if (currBus == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; @@ -58,11 +50,21 @@ class _BusSheetState extends State { showMaizebusOKDialog( contextIn: context, title: const Text("Uh Oh!"), - content: const Text("Unable to fetch bus data. Looks like you aren't connected to the internet!"), + content: const Text("Unable to fetch bus data. Please check your internet connection and try again."), ); }); - } // bus not found + } 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. + // Update: Fixed the blank text "bus not found", should + if (currBus == null) return Text("Not Found Bus"); final bus = currBus!; From 65f9adc99780713aa8f3497ba56659fd6da2f16c Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sat, 28 Mar 2026 17:47:51 -0400 Subject: [PATCH 024/121] Removed a debug print I found --- lib/widgets/bus_sheet.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 234fb2b..7f4fe4d 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -64,11 +64,10 @@ class _BusSheetState extends State { // This accounts for that. // Update: Fixed the blank text "bus not found", should - if (currBus == null) return Text("Not Found Bus"); + 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), From 703f55dd843a8ff7005da77ede3513379307d7bd Mon Sep 17 00:00:00 2001 From: Static Date: Tue, 7 Apr 2026 23:05:19 -0400 Subject: [PATCH 025/121] fix(ui): Made StopSheet favorite button load instantly map_sheet.dart now passes in a stop's favorite status for immediate access in stop_sheet.dart and mini_stop_sheet.dart --- lib/screens/map_screen.dart | 1 + lib/services/bus_info_service.dart | 11 +++-------- lib/widgets/mini_stop_sheet.dart | 4 ++-- lib/widgets/stop_sheet.dart | 23 ++++++++++------------- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1dc8b96..1a2a597 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1916,6 +1916,7 @@ class _MaizeBusCoreState extends State { return StopSheet( stopID: stopID, stopName: stopName, + isFavorite: _favoriteStops.contains(stopID), onFavorite: _addFavoriteStop, onUnFavorite: _removeFavoriteStop, showBusSheet: (busId) { 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/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index 6bdb7b0..6a64204 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -40,7 +40,7 @@ class MiniStopSheet extends StatefulWidget { } class _MiniStopSheetState extends State { - late Future<(List, bool)> loadedStopData; + late Future> loadedStopData; @override void initState() { @@ -64,7 +64,7 @@ class _MiniStopSheetState extends State { List arrivingBuses = []; if (snapshot.hasData){ - arrivingBuses = snapshot.data!.$1; + arrivingBuses = snapshot.data!; } if (snapshot.hasData) { diff --git a/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a6e17ff..bb958c9 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -16,6 +16,7 @@ import 'upcoming_stops_widget.dart'; 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; @@ -26,6 +27,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, @@ -216,8 +218,8 @@ class ExpandableStopWidget extends StatefulWidget { } class _StopSheetState extends State { - late Future<(List, bool)> loadedStopData; - bool? _isFavorited; + late Future> loadedStopData; + late bool _isFavorite; // for select bus stops with images late bool imageBusStop; @@ -227,6 +229,7 @@ class _StopSheetState extends State { void initState() { super.initState(); loadedStopData = fetchStopData(widget.stopID); + _isFavorite = widget.isFavorite; imageBusStop = (widget.stopID == "C250") || (widget.stopID == "N406") || @@ -284,13 +287,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; @@ -675,11 +675,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); @@ -687,7 +684,7 @@ class _StopSheetState extends State { // Update the UI immediately setState(() { - _isFavorited = !currentStatus; + _isFavorite = !_isFavorite; }); }, style: ElevatedButton.styleFrom( @@ -701,8 +698,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, ), ), From c7ca3cdd5df3666feb75c5b75e298cef1fbef680 Mon Sep 17 00:00:00 2001 From: Static Date: Tue, 7 Apr 2026 23:18:10 -0400 Subject: [PATCH 026/121] fix(ui): Fixed a merge bug resulting from other commits in maizebus-2.1 --- lib/widgets/bus_sheet.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index f3e7978..1e664d8 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -50,8 +50,8 @@ class _BusSheetState extends State { showMaizebusOKDialog( contextIn: context, - title: const Text("Uh Oh!"), - content: const Text("Unable to fetch bus data. Please check your internet connection and try again."), + title: "Uh Oh!", + content: "Unable to fetch bus data. Please check your internet connection and try again.", ); }); } else { From 7b3605385fabcd6360d0347b0991dc9695a441c5 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:09:14 -0400 Subject: [PATCH 027/121] Got stops, lines, and buses appearing! Refactored a whole bunch of stuff out of map_screen.dart into composite_map_widget.dart and map_image_service.dart. Getting all neat and organized! --- lib/constants.dart | 7 + lib/screens/map_screen.dart | 769 ++++++++++++-------------- lib/services/map_image_service.dart | 258 +++++++++ lib/widgets/composite_map_widget.dart | 462 ++++++++++++++++ lib/widgets/route_selector_modal.dart | 2 + 5 files changed, 1083 insertions(+), 415 deletions(-) create mode 100644 lib/services/map_image_service.dart create mode 100644 lib/widgets/composite_map_widget.dart diff --git a/lib/constants.dart b/lib/constants.dart index ba5bbf8..a44eaa0 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -178,6 +178,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, @@ -431,3 +437,4 @@ const SheetBoxShadow = BoxShadow( blurRadius: 100.0, spreadRadius: 40.0, ); + diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7104b98..572b4fa 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,10 +5,13 @@ import 'dart:math' as Math; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:bluebus/globals.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/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/journey_results_widget.dart'; @@ -65,20 +68,7 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { 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}); @@ -88,7 +78,7 @@ class MaizeBusCore extends StatefulWidget { } class _MaizeBusCoreState extends State { - late bool canVibrate; + late bool canVibrate = false; late Journey currDisplayed; ScreenRadius? screenRadius; bool screenRadiusLoaded = false; @@ -118,13 +108,17 @@ 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; @@ -132,8 +126,8 @@ 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 = {}; @@ -160,6 +154,9 @@ class _MaizeBusCoreState extends State { // store persistent bottom sheet controller PersistentBottomSheetController? _bottomSheetController; + final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); + final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); + // GoogleMaps styles String _darkMapStyle = "{}"; String _lightMapStyle = "{}"; @@ -176,16 +173,35 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); + baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); + + + // TODO: Make sure this still works when moved to line 197 + // // Only update bus markers when buses change + // final busProvider = Provider.of(context, listen: false); + // WidgetsBinding.instance.addPostFrameCallback((_) { + // if (busProvider.buses.isNotEmpty) { + // _updateDisplayedBuses(busProvider.buses); + // } + // }); + + 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) { @@ -197,6 +213,23 @@ class _MaizeBusCoreState extends State { }); } + void onStopClicked(BusStop stop) { + try { + Haptics.vibrate(HapticsType.light); + } catch (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(); @@ -234,6 +267,7 @@ class _MaizeBusCoreState extends State { // still keep context @override void didChangeDependencies() { + // debugPrint("******** Got didChangeDependencies call"); super.didChangeDependencies(); if (_dataLoadingFuture == null) { _dataLoadingFuture = _loadAllData(); @@ -241,18 +275,33 @@ class _MaizeBusCoreState extends State { } Future _loadAllData() async { + + // debugPrint("******* Loading all data"); + ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); await theme.loadTheme(); + // debugPrint("******* Loaded theme"); + screenRadius = await ScreenCornerRadius.get(); // load screen radius screenRadiusLoaded = true; + + // debugPrint("******* Loaded screenRadius"); //Trying to find the location of the user to set initial position. If not found, defaults to _defaultCenter - Position? pos = await Geolocator.getLastKnownPosition(); - if (pos != null){ - startLatLng = LatLng(pos.latitude, pos.longitude); + 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); + } } + // debugPrint("******* Got geolocator position"); + + + // debugPrint("******* Loading canVibrate"); canVibrate = await Haptics.canVibrate(); @@ -289,7 +338,7 @@ class _MaizeBusCoreState extends State { ); } - + // debugPrint("******* Loading all the data in parallel"); // loading all this data in parallel await Future.wait([ _loadCustomMarkers(), @@ -300,9 +349,13 @@ 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) { @@ -323,6 +376,9 @@ class _MaizeBusCoreState extends State { busProvider.startBusUpdates(); busProvider.startRouteUpdates(); await Future.delayed(const Duration(milliseconds: 180)); + + // debugPrint("******* FINISHED ALL LOADING!!!!"); + } // need this to make sure that the stop names exist in the cache @@ -412,23 +468,26 @@ 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')); + // [These were moved to composite_map_widget.dart] + // _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 MapImageService.resizeImage(await rootBundle.load('assets/getOn.png')); + _getOff = await MapImageService.resizeImage(await rootBundle.load('assets/getOff.png')); + // TODO: Move this into map_image_service.dart // Load route specific bus icons - await _loadRouteSpecificBusIcons(); + // await _loadRouteSpecificBusIcons(); + await MapImageService.loadData(); // TODO: This was already called inside loadAllData. Do we need to call it again? // Refresh markers with new icons if (mounted) { @@ -436,98 +495,84 @@ class _MaizeBusCoreState extends State { } } 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, - ); + // _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; - } - } + // // 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, + // ); + // } + // } + + + + + + // // 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 { @@ -559,106 +604,41 @@ 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 - } - } + // // 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; + // } - // 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 { @@ -741,6 +721,7 @@ class _MaizeBusCoreState extends State { } void _updateAvailableRoutes(List routes) { + // debugPrint("****** Got _updateAvailableRoutes call!!"); final Map routeIdToName = {}; for (final r in routes) { if (!routeIdToName.containsKey(r.routeId)) { @@ -748,13 +729,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(() { @@ -847,9 +822,13 @@ 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 {} + + + } Future _removeFavoriteStop(String stpid, String name) async { @@ -861,6 +840,7 @@ 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); } @@ -978,45 +958,45 @@ class _MaizeBusCoreState extends State { } 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(); + // // 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) { @@ -1024,18 +1004,8 @@ class _MaizeBusCoreState extends State { 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), - ); - } + BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); + _displayedJourneyBusMarkers.add( Marker( @@ -1054,8 +1024,10 @@ class _MaizeBusCoreState extends State { } setState(() { - _displayedBusMarkers = selectedBusMarkers; - _updateAllDisplayedMarkers(); + // _displayedBusMarkers = selectedBusMarkers; + _updateAllDisplayedMarkers(); // TODO: Do we still need this? + + liveBusesLayer.reload(); }); } @@ -1067,12 +1039,6 @@ class _MaizeBusCoreState extends State { .union(_searchLocationMarker != null ? {_searchLocationMarker!} : {}); } - /// Convert a Color to a BitmapDescriptor hue value - double _colorToHue(Color color) { - final hsl = HSLColor.fromColor(color); - return hsl.hue; - } - // Show a red pin marker at search location void _showSearchLocationMarker(double lat, double lon) { _searchLocationMarker = Marker( @@ -1091,27 +1057,15 @@ class _MaizeBusCoreState extends State { } void _refreshAllMarkers() { + // TODO: Should all this be moved inside the MapImageService now that we're encapsulating everything in that? final busProvider = Provider.of(context, listen: false); _refreshCachedStopMarkers(); - _refreshRouteBusIcons(); + // _refreshRouteBusIcons(); + MapImageService.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(); @@ -1138,46 +1092,9 @@ class _MaizeBusCoreState extends State { ); } - 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( @@ -1194,8 +1111,9 @@ class _MaizeBusCoreState extends State { setState(() { _selectedRoutes.clear(); _selectedRoutes.addAll(newSelection); + baseRoutesLayer.reload(); }); - _updateDisplayedRoutes(); + // _updateDisplayedRoutes(); // Save the new selection await _saveSelectedRoutes(); @@ -1410,6 +1328,26 @@ class _MaizeBusCoreState extends State { }, ); } + + // TODO: Put this into composite_map_widget.dart + // 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), + // ); + // } // Display a Journey on the map void _displayJourneyOnMap(Journey journey, Color walkLineColor) async { @@ -1494,7 +1432,7 @@ class _MaizeBusCoreState extends State { icon: _getOn ?? BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), + colorToHue(RouteColorService.getRouteColor(leg.rt!)), ), ), Marker( @@ -1506,7 +1444,7 @@ class _MaizeBusCoreState extends State { icon: _getOff ?? BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), + colorToHue(RouteColorService.getRouteColor(leg.rt!)), ), ), ]); @@ -1535,7 +1473,7 @@ class _MaizeBusCoreState extends State { icon: _stopIcon ?? BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), + colorToHue(RouteColorService.getRouteColor(leg.rt!)), ), ), ); @@ -1702,7 +1640,7 @@ class _MaizeBusCoreState extends State { 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)); + _displayedJourneyBusMarkers.add(liveBusesLayer.createBusMarker(bus)); } } @@ -2067,14 +2005,7 @@ 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 @@ -2146,68 +2077,76 @@ class _MaizeBusCoreState extends State { }, child: Stack( children: [ + CompositeMapWidget( + initialCenter: startLatLng, + mapLayers: [ + baseRoutesLayer, + liveBusesLayer + ], + ), + // underlying map layer (different ios and android) - Platform.isIOS - ? MapWidget( - initialCenter: startLatLng, - 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: startLatLng, - polylines: _journeyOverlayActive - ? _displayedJourneyPolylines - : _displayedPolylines.union( - _displayedJourneyPolylines, - ), - staticMarkers: _journeyOverlayActive - ? _displayedJourneyMarkers.union( - _searchLocationMarker != null - ? {_searchLocationMarker!} - : {}, - ) - : _displayedStopMarkers.values.toSet() - .union(_displayedFavoriteStopMarkers.values.toSet()) - .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, - ), + // Platform.isIOS + // ? MapWidget( + // initialCenter: startLatLng, + // 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: startLatLng, + // polylines: _journeyOverlayActive + // ? _displayedJourneyPolylines + // : _displayedPolylines.union( + // _displayedJourneyPolylines, + // ), + // staticMarkers: _journeyOverlayActive + // ? _displayedJourneyMarkers.union( + // _searchLocationMarker != null + // ? {_searchLocationMarker!} + // : {}, + // ) + // : _displayedStopMarkers.values.toSet() + // .union(_displayedFavoriteStopMarkers.values.toSet()) + // .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, + // ), Padding( padding: EdgeInsets.only( @@ -2757,7 +2696,7 @@ class _MaizeBusCoreState extends State { ); } _showBusRoutesModal( - busProvider.routes, + _busProviderRef!.routes, ); }, heroTag: 'routes_fab', diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart new file mode 100644 index 0000000..c957f45 --- /dev/null +++ b/lib/services/map_image_service.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'dart:ui'; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class MapImageService { + + // Route specific bus icons + static Map _routeBusIcons = {}; + static BitmapDescriptor? _busIcon; + + // TODO: Maybe make this manage stop icons too? + + 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 void ensureRouteIconIsLoaded(String routeId) { + // Load bus icon for this route if not already loaded + if (!_routeBusIcons.containsKey(routeId)) { + final imageUrl = RouteColorService.getRouteImageUrl(routeId); + if (imageUrl != null) { + _loadRouteBusIcon(routeId, imageUrl); + } + } + } + + // 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(); + } + + // FUTURE: Maybe wrap this into a map_image_service.dart file? + 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 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 { + return BitmapDescriptor.defaultMarkerWithHue( + colorToHue(routeColor), + ); + } + } + + + + static Future loadData() async { + await _loadRouteSpecificBusIcons(); + } + +} \ No newline at end of file diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart new file mode 100644 index 0000000..1414170 --- /dev/null +++ b/lib/widgets/composite_map_widget.dart @@ -0,0 +1,462 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bluebus/models/bus.dart'; +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:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:haptic_feedback/haptic_feedback.dart'; + +// 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), +// ); +// } + + + +// TODO: Add a Z-index to each thing in each CompositeMapLayer +// to explicitly define how things should be ordered + +// Define the CompositeMapLayer +abstract class CompositeMapLayer { + // Every CompositeMapLayer must have these four things + bool get isVisible; + Set get polylines; + Set get markers; + Function() get onUpdate; + void setOnUpdate(Function() fn); +} + +class BaseRoutesLayer 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"); + }; + + List routesCache = []; + + Set favoriteStops = {}; + Set selectedRoutes = {}; + + BitmapDescriptor? _stopIcon; + BitmapDescriptor? _rideStopIcon; + BitmapDescriptor? _favStopIcon; + BitmapDescriptor? _favRideStopIcon; + + Map> markersCache = {}; // TODO: Merge this with polylines variable? + Map polylinesCache = {}; + + void setOnUpdate(Function() callback) { + debugPrint("****** got setOnUpdate call!"); + onUpdate = callback; + } + + void init(Set favoriteStops_in, + Set selectedRoutes_in, + Function(BusStop) onStopClicked_in) { + favoriteStops = favoriteStops_in; + selectedRoutes = selectedRoutes_in; + onStopClicked = onStopClicked_in; + _loadCustomMarkers(); + } + + 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'), + ); + + // Refresh markers with new icons + // TODO: See if we need this! + // 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, + ); + } + } + + void reload() { + debugPrint("****** Reloading everything in busRoutesLayer"); + reloadMarkers(); + reloadPolylines(); + onUpdate(); + } + + void reloadMarkers() { + // set force to reload all the markers, regardless of whether they're already in the cache or not. Useful if a marker changes state (e.g. becomes a favorite) but is already in the cache + + debugPrint("***** Got reloadMarkers call"); + + markersCache.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 (!markersCache.containsKey(routeKey)) { + markersCache[routeKey] = {}; + for (final stop in r.stops) { // iterate through all stops in this route + // TODO: Implement favorite stops + // final isFavorite = _favoriteStops.contains(stop.id); + + final marker = Marker( + markerId: MarkerId( + 'stop_${stop.id}_${Object.hashAll(r.points)}', + ), + position: stop.location, + flat: true, + // icon: BitmapDescriptor.defaultMarker, + icon: favoriteStops.contains(stop.id) // Used to be 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: () { + onStopClicked(stop); + }, + rotation: stop.rotation, + anchor: Offset(0.5, 0.5), + ); + // _routeStopMarkers[routeKey]?[stop.id] = marker; + + markersCache[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; + } + } + } + + // markers = {}; + markers = markersCache.values.expand((Map m) { + return m.values; + }).toSet(); + } + + void reloadPolylines() { + + 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); + + // TODO: Implement this + if (!polylinesCache.containsKey(routeKey)) { + polylinesCache[routeKey] = Polyline( + polylineId: PolylineId(routeKey), + points: r.points, + color: routeColor, + width: 4, + ); + } + } + + polylines = polylinesCache.values.toSet(); + + } + + void cacheRoutes(List routes) { + debugPrint("******* Got cacheRoutes call!!"); + // Called from inside _loadAllData() inside map_screen.dart + routesCache = routes; + + // TODO: Make the parent (map_screen.dart) pass in the list of filtered route IDs and as soon as that list changes call some sort of reloadMarkers() + + // TODO: Update the map controller here + debugPrint("Calling onUpdate: ${onUpdate}"); + + reloadMarkers(); + reloadPolylines(); + + onUpdate(); + } + + +} + +class LiveBusesLayer extends CompositeMapLayer { + @override + bool isVisible = true; + + @override + Set markers = {}; + + @override + Function() onUpdate = () { + debugPrint("Error: onUpdate called but callback was not registered!"); + }; + + @override + Set polylines = {}; + + List buses = []; + Set selectedRoutes = {}; + Function(Bus b) onBusClicked = (Bus b) { + debugPrint("Error: onBusClicked callback was called but never intiialized"); + }; + + @override + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + void init(List buses_in, + Set selectedRoutes_in, + Function(Bus b) onBusClicked_in) { + buses = buses_in; + selectedRoutes = selectedRoutes_in; + onBusClicked = onBusClicked_in; + MapImageService.loadData(); + } + + 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: () => onBusClicked(bus), + ); + } + + void reload() { // Similar to how _updateDisplayedBuses() worked before +// null case or error contacting server case + if (buses == []) return; + + markers = buses + .where((bus) => selectedRoutes.contains(bus.routeId)) + .map((bus) { + // 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! + + 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(); + } + +} + + +class CompositeMapWidget extends StatefulWidget { + // final LatLongNew.LatLng initialCenter = LatLongNew.LatLng(42.277849, -83.7352536); + // final Set polylines; + // final Set markers; + // final void Function(GoogleMapController)? onMapCreated; + // final void Function(CameraPosition)? onCameraMove; + // final bool myLocationEnabled; + // final bool myLocationButtonEnabled; + // final bool zoomControlsEnabled; + // final bool mapToolbarEnabled; + // Function(BusStop stop) onStopClicked; + // Function(Bus bus) onBusClicked; + + final LatLng initialCenter; + final List mapLayers; + +// TODO: Implement these methods + // 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), + // ); + // } + // } + // } + + + // final UniversalMapController universalController; + + CompositeMapWidget({ + required this.initialCenter, + required this.mapLayers + }); + + @override + State createState() { + // TODO: implement createState + return CompositeMapWidgetState(); + } + + +} + + +class CompositeMapWidgetState extends State { + GoogleMapController? _mapController; + Set allMarkers = {}; + Set allPolylines = {}; + + + void reloadMap() { + debugPrint("******* Got reloadMap() call!"); + // _mapController. + setState(() {}); // Rebuild with updated markers + } + + @override + initState() { + super.initState(); + widget.mapLayers.forEach((CompositeMapLayer layer) { + layer.setOnUpdate(reloadMap); + }); + + } + + @override + Widget build(BuildContext context) { + // widget.mapLayers.forEach((CompositeMapLayer layer) { + // if (!layer.isVisible) return; + // allallMarkers.union(other) + // }); + 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(); + + // allmarkers = + + debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); + + + + return RepaintBoundary( + child: GoogleMap( + compassEnabled: false, + myLocationEnabled: true, + mapToolbarEnabled: false, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + markers: allMarkers, + polylines: allPolylines, + // controller: + 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: 15.0, + ), + onMapCreated:(controller) { + _mapController = controller; + }, + ) + ); + } + + +} \ No newline at end of file diff --git a/lib/widgets/route_selector_modal.dart b/lib/widgets/route_selector_modal.dart index 13877b1..dce7da4 100644 --- a/lib/widgets/route_selector_modal.dart +++ b/lib/widgets/route_selector_modal.dart @@ -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) { From cbf7ab05fc251cc6c88389099af67c475bf234f7 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:21:56 -0400 Subject: [PATCH 028/121] Started implementing SmoothBus --- lib/screens/map_screen.dart | 1 + lib/widgets/composite_map_widget.dart | 310 ++++++++++++++++++++++++-- 2 files changed, 292 insertions(+), 19 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 572b4fa..29b3c9f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -958,6 +958,7 @@ class _MaizeBusCoreState extends State { } void _updateDisplayedBuses(List allBuses) { + debugPrint("****** Updating displayed buses"); // // null case or error contacting server case // if (allBuses == []) return; diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 1414170..20995cb 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -44,6 +45,7 @@ abstract class CompositeMapLayer { Set get markers; Function() get onUpdate; void setOnUpdate(Function() fn); + void dispose() {} } class BaseRoutesLayer extends CompositeMapLayer { @@ -152,6 +154,7 @@ class BaseRoutesLayer extends CompositeMapLayer { // final isFavorite = _favoriteStops.contains(stop.id); final marker = Marker( + zIndexInt: 10, // Put bus stops on top of buses markerId: MarkerId( 'stop_${stop.id}_${Object.hashAll(r.points)}', ), @@ -247,6 +250,20 @@ class BaseRoutesLayer extends CompositeMapLayer { } +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; + BusAnimationState({ + required this.bus, + required this.busIcon, + required this.markerId, + this.lastUpdated = 0 + }); +} class LiveBusesLayer extends CompositeMapLayer { @override bool isVisible = true; @@ -262,8 +279,22 @@ class LiveBusesLayer extends CompositeMapLayer { @override Set polylines = {}; + bool isAnimating = false; + late Animation animation; + int nextAnimationFrameTime = 0; + int animationStartedTime = 0; + static const int FRAME_DURATION = 70; // Frame duration in ms for animations + static const int ANIMATION_DURATION = 8000; //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"); }; @@ -273,12 +304,24 @@ class LiveBusesLayer extends CompositeMapLayer { onUpdate = callback; } + void initWithTickerProvider(TickerProvider tickerProviderIn) { + debugPrint("******* Initting with animation controller!!"); + tickerProvider = tickerProviderIn; + controller = AnimationController(duration: const Duration(milliseconds: ANIMATION_DURATION), vsync: tickerProvider!); + // controller?.repeat(); + // NEXT STEPS TODO: Finish the AnimationController integration into Project SmoothBus! + + } + void init(List buses_in, Set selectedRoutes_in, Function(Bus b) onBusClicked_in) { buses = buses_in; selectedRoutes = selectedRoutes_in; onBusClicked = onBusClicked_in; + + + MapImageService.loadData(); } @@ -296,36 +339,252 @@ class LiveBusesLayer extends CompositeMapLayer { ); } - void reload() { // Similar to how _updateDisplayedBuses() worked before -// null case or error contacting server case - if (buses == []) return; + void updateAnimation() { + + // debugPrint("* updateAnimation call! busAnimationCache has ${busAnimationCache.keys.length} keys"); + // debugPrint(" Animation value is ${animation.value}"); + // debugPrint("* selectedRoutes is ${selectedRoutes}"); + + DateTime now = DateTime.now(); - markers = buses - .where((bus) => selectedRoutes.contains(bus.routeId)) - .map((bus) { - // Use route specific bus icon if available, otherwise fallback to default - BitmapDescriptor? busIcon = MapImageService.getBusIcon(bus); + markers = busAnimationCache.keys.where((String busId) { + // debugPrint("Checking to see if we should add marker ${busAnimationCache[busId]?.bus.routeId}: ${selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId)}"); + return selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId); + }) + .map((String busId) { + LatLng interpolatedPosition; + // debugPrint("Adding marker for ${busId}"); + double interpolatedHeading = busAnimationCache[busId]!.bus.heading; + double animatedPercentage = min((now.millisecondsSinceEpoch - busAnimationCache[busId]!.lastUpdated) / ANIMATION_DURATION, 1.0); - NEXT STEPS TODO: Get bus animations working on android, and get the live updating to work! + // debugPrint("animatedPercentage is ${animatedPercentage.toStringAsFixed(2)}"); + + 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]?.prevBus?.position; + LatLng? newPosition = busAnimationCache[busId]?.bus.position; + + interpolatedPosition = LatLng( + animatedPercentage * (newPosition!.latitude - oldPosition!.latitude) + oldPosition!.latitude, + animatedPercentage * (newPosition!.longitude - oldPosition!.longitude) + oldPosition!.longitude + ); + + busAnimationCache[busId]?.lastInterpolatedPosition = interpolatedPosition; + // NEXT STEPS TODO: Okay, so the problem right now is that some buses' animation cycles aren't done before we get new bus position data, so it creates a weird "jump". I'd like to find some way of animating between the last interpolated position and updating the new position. So maybe store the last interpolated position somewhere and when new data comes in, check if the bus is animating--if it's still in the middle of its animation, set the old position to the last interpolated position instead of the old bus position. + // * Or we could just do that every time--the last interpolated position should equal the final position if the bus animation is complete. + // * So probably save the last interpolated position inside the BusAnimationState and whenever the new data comes in, it sets the oldPosition to be the old interpolatedPosition and sets the newPosition to be whatever was received from the API + + // 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]!.bus.heading - busAnimationCache[busId]!.prevBus!.heading); + + if (headingDelta.abs() > (360 + headingDelta).abs()) { + 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]!.bus.heading - busAnimationCache[busId]!.prevBus!.heading) + busAnimationCache[busId]!.prevBus!.heading; + } + } return Marker( flat: true, - markerId: MarkerId('bus_${bus.id}'), + zIndexInt: 1, + markerId: busAnimationCache[busId]!.markerId, consumeTapEvents: true, - position: bus.position, - icon: busIcon, - rotation: bus.heading, + position: interpolatedPosition, + icon: busAnimationCache[busId]!.busIcon, + rotation: interpolatedHeading, anchor: const Offset(0.5, 0.5), // Center the icon on the position onTap: () { try { Haptics.vibrate(HapticsType.light); } catch (e) {} - onBusClicked(bus); + onBusClicked(busAnimationCache[busId]!.bus); // _showBusSheet(bus.id); }, ); - }) - .toSet(); + + // return Marker(); + }).toSet(); + + // debugPrint("***** Finished updateAnimation() call, we now have ${markers.length} markers"); + + // markers = buses + // 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 + } + + // debugPrint("* Starting animation! Last animation was ${(now.millisecondsSinceEpoch - animationStartedTime) / 1000}s ago"); + if (controller == null) return; + // if (controller!.isAnimating) return; //Animation runs infinitely, so we only start it once + + 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(() { + // debugPrint("tick"); + DateTime now = DateTime.now(); + if (now.millisecondsSinceEpoch < nextAnimationFrameTime) return; + nextAnimationFrameTime = now.millisecondsSinceEpoch + FRAME_DURATION; // 100ms frametimes + + // debugPrint("****** Got animation tick!"); + updateAnimation(); + onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) + }); + + animation.addStatusListener((AnimationStatus status) { + // if (status == AnimationStatus.completed) { + // debugPrint("********* RESTARTING ANIMATION"); + // controller?.forward(); + // } + }); + + controller?.forward(); + controller?.repeat(); + + + debugPrint("***** Finished starting animation"); + } + + 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) { + // debugPrint(">>>> Bus position has not changed! Skipping animation for ${bus.id}"); + // 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; + } else { + busAnimationCache[bus.id] = BusAnimationState( + bus: bus, + busIcon: MapImageService.getBusIcon(bus), + markerId: MarkerId('bus_${bus.id}'), + lastUpdated: now.millisecondsSinceEpoch + ); + } + }); + + //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(); } } @@ -387,14 +646,14 @@ class CompositeMapWidget extends StatefulWidget { } -class CompositeMapWidgetState extends State { +class CompositeMapWidgetState extends State with SingleTickerProviderStateMixin { GoogleMapController? _mapController; Set allMarkers = {}; Set allPolylines = {}; void reloadMap() { - debugPrint("******* Got reloadMap() call!"); + // debugPrint("******* Got reloadMap() call!"); // _mapController. setState(() {}); // Rebuild with updated markers } @@ -404,6 +663,9 @@ class CompositeMapWidgetState extends State { super.initState(); widget.mapLayers.forEach((CompositeMapLayer layer) { layer.setOnUpdate(reloadMap); + if (layer is LiveBusesLayer) { + layer.initWithTickerProvider(this); + } }); } @@ -425,7 +687,7 @@ class CompositeMapWidgetState extends State { // allmarkers = - debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); + // debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); @@ -458,5 +720,15 @@ class CompositeMapWidgetState extends State { ); } + @override + void dispose() { + super.dispose(); + // widget.mapLayers.forEach((CompositeMapLayer l) { + // l.dispose(); + // }); + for (CompositeMapLayer l in widget.mapLayers) { + l.dispose(); + } + } } \ No newline at end of file From ecd9d90bd47721c6152619a5711d24fbc565f5ca Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:29:03 -0400 Subject: [PATCH 029/121] Fixed some animations and polylines Just finished refactoring BaseRoutesLayer and LiveBusesLayer. Next, to tackle JourneyLayer and give the MapController back to map_screen.dart! --- lib/widgets/composite_map_widget.dart | 57 ++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 20995cb..8ff7db6 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -47,7 +47,7 @@ abstract class CompositeMapLayer { void setOnUpdate(Function() fn); void dispose() {} } - +// TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff class BaseRoutesLayer extends CompositeMapLayer { @override bool isVisible = true; @@ -147,7 +147,7 @@ class BaseRoutesLayer extends CompositeMapLayer { // Use backend color if available, otherwise fallback to service final routeColor = r.color ?? RouteColorService.getRouteColor(r.routeId); - if (!markersCache.containsKey(routeKey)) { + if (!markersCache.containsKey(routeKey)) { // Prevent duplicate copies of the same stop on top of each other markersCache[routeKey] = {}; for (final stop in r.stops) { // iterate through all stops in this route // TODO: Implement favorite stops @@ -208,6 +208,8 @@ class BaseRoutesLayer extends CompositeMapLayer { void reloadPolylines() { + polylinesCache.clear(); + for (final r in routesCache) { if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes @@ -216,7 +218,6 @@ class BaseRoutesLayer extends CompositeMapLayer { // Use backend color if available, otherwise fallback to service final routeColor = r.color ?? RouteColorService.getRouteColor(r.routeId); - // TODO: Implement this if (!polylinesCache.containsKey(routeKey)) { polylinesCache[routeKey] = Polyline( polylineId: PolylineId(routeKey), @@ -256,13 +257,23 @@ class BusAnimationState { 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 @@ -283,7 +294,7 @@ class LiveBusesLayer extends CompositeMapLayer { late Animation animation; int nextAnimationFrameTime = 0; int animationStartedTime = 0; - static const int FRAME_DURATION = 70; // Frame duration in ms for animations + static const int FRAME_DURATION = 100; // Frame duration in ms for animations static const int ANIMATION_DURATION = 8000; //4000; // Animation duration in ms AnimationController? controller; @@ -308,8 +319,6 @@ class LiveBusesLayer extends CompositeMapLayer { debugPrint("******* Initting with animation controller!!"); tickerProvider = tickerProviderIn; controller = AnimationController(duration: const Duration(milliseconds: ANIMATION_DURATION), vsync: tickerProvider!); - // controller?.repeat(); - // NEXT STEPS TODO: Finish the AnimationController integration into Project SmoothBus! } @@ -372,15 +381,14 @@ class LiveBusesLayer extends CompositeMapLayer { ); busAnimationCache[busId]?.lastInterpolatedPosition = interpolatedPosition; - // NEXT STEPS TODO: Okay, so the problem right now is that some buses' animation cycles aren't done before we get new bus position data, so it creates a weird "jump". I'd like to find some way of animating between the last interpolated position and updating the new position. So maybe store the last interpolated position somewhere and when new data comes in, check if the bus is animating--if it's still in the middle of its animation, set the old position to the last interpolated position instead of the old bus position. - // * Or we could just do that every time--the last interpolated position should equal the final position if the bus animation is complete. - // * So probably save the last interpolated position inside the BusAnimationState and whenever the new data comes in, it sets the oldPosition to be the old interpolatedPosition and sets the newPosition to be whatever was received from the API + // 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]!.bus.heading - busAnimationCache[busId]!.prevBus!.heading); if (headingDelta.abs() > (360 + headingDelta).abs()) { + // Might need to fix this headingDelta = 360 + headingDelta; // Turn the tightest direction possible } @@ -391,6 +399,9 @@ class LiveBusesLayer extends CompositeMapLayer { } } + busAnimationCache[busId]?.lastInterpolatedHeading = interpolatedHeading; + busAnimationCache[busId]?.lastInterpolatedPosition = interpolatedPosition; + return Marker( flat: true, zIndexInt: 1, @@ -541,7 +552,15 @@ class LiveBusesLayer extends CompositeMapLayer { busAnimationCache[bus.id]?.prevBus = busAnimationCache[bus.id]?.bus; busAnimationCache[bus.id]?.bus = 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), @@ -589,6 +608,24 @@ class LiveBusesLayer extends CompositeMapLayer { } +class JourneyLayer extends CompositeMapLayer { + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + + void setOnUpdate(Function() callback) { + debugPrint("****** got setOnUpdate call!"); + onUpdate = callback; + } + + + +} class CompositeMapWidget extends StatefulWidget { // final LatLongNew.LatLng initialCenter = LatLongNew.LatLng(42.277849, -83.7352536); From 8f40588aeea493309732a147db3f99e197aac5bb Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:58:19 -0400 Subject: [PATCH 030/121] Added journeys to compositemapwidget --- assets/destination.png | Bin 0 -> 5193 bytes assets/getOff.png | Bin 2228 -> 5991 bytes assets/getOn.png | Bin 2254 -> 5738 bytes assets/start.png | Bin 0 -> 4679 bytes lib/constants.dart | 8 +- lib/screens/map_screen.dart | 593 ++++------------- lib/widgets/composite_map_widget.dart | 881 +++++++++++++++++++++++++- pubspec.yaml | 1 + 8 files changed, 997 insertions(+), 486 deletions(-) create mode 100644 assets/destination.png create mode 100644 assets/start.png diff --git a/assets/destination.png b/assets/destination.png new file mode 100644 index 0000000000000000000000000000000000000000..a4eb08472c0323bc11e2e7067f44bfb8a242c647 GIT binary patch literal 5193 zcmcIo2|Sc*+ecZWI*AjBX^Mz3yD?)c%h$>mX>~Y>?tsti= zCnhGQU~6OP3f;+~OGXO%4cCAE3v`oZ+jw!s#1<)vE^)D}BP*b}T86tP&(qO?L}i8P z12h%|)aQq>A!#u&V>3P*poV}v7zGSuFiqg2WjEk32F(QSfps(b7Ruz3_$KgKy(H*aG>wGAW<_`*Ch(0SgD_7=XP5Y zflMA3l10zUvV&MW7B`6X4b}6n|7HL}*U@p_#y|QJ7B+8!%d?7v!k7c(AEmkO+t?t| z738uaI8@Lo5^{6RmuR>=SMV=9e_%NzKD(GrvfzLKkHv9ku|mHj+WAYoFbq~757Tx8 zs0^kE0UeROxh0?_zynR-P|^($C>#Qfc1Pn$C=3aMg@z;)>a(aLi^iaD`@1LxC3R4Up4fyJOP2!a8QiZG;u1Oy5V8sGpNjRL*=qRolJ zfGQOT{aUNYD;lH`q!WpB8WxYB0W<>y4)OzGNHoAA&^S6CLm=R=L<~_>472IbQ|91~>rJra%tY1WxC$!eH}Mfbm!`fXxnNPytcs$Vet_ zt`E#73;sM+nNYQIpwpM3vZa}rn4Ffa5>L0%gi9`$$no4SL@YlcpHZBiqimHIVG!W622qkzrRjW7 z17Uu^lM?q5c4MtX>hD!D3pMMtR%gE&*2oSp*fuE~si$B3V=zh!+YxjB`s8EmfWrtB&6cnO8re$FG~ z6KaOEWS8?3v6bG>1RLB>*C;Gy2AI75wH^pjsE&g#PdL5hz4`HC&k4nTGi@n6KdzZ6}Edf~WnOx4 zY-W2*Wiq35o#w&-Uu=sT|Ia699LoMo-jGDteZi?BU^FU7F%ngYOD#>;GF7mtIKRn8vriLk**UDFEzu)W-dOyq7 zoNrp$xM_OqUjDZ5k%rYVv|YRNI~op1YJP~AT=&tlTRG)QG0^*ANouCNQm(gfCcZGv zP{+w(dH##;do$RAiFMANucOD%N2lD-1$2uA-H#euNJHD3dIg~TN7L66l;i1Dzj&7D zml;vgCD>2jg@1&75m}~DYOUR#lo9Km{atJPbi{I#6i%{hg;iGe{&~rFa<8AaemMlo znOT~FSgEt~quvFrRGemzKMcGp=DMtRTg<4fioNfrkc&Ru)}8 zy5H^2a<3D=5nl0eJAW<|nyz0{7VmlJc3WH1gK?KS>zwqyo9puj=!uqhD=N=kJxmU~ zq+R*sYESb%m-CU5qs+m=cJ9osuDjO=!L<>2a&LL|s0GUv`R?Cz#UKZ2n0R?Ac8uM1?dSHb}hpZn;7 zjA?(^bh^nES$ST}{BkFv5@oXQl@4Ra0Qq&&3%SYZDSt|i-np!e`FgW{1?MgWFSUCH5qZob9W3 zaBRz-YFHUF{bsnv{zd}&O7sd{|6W*p--@M-w5Ah{L({zz38rB;RUa3iJaes%_Z|?x z61rjUpsa;L;Z4L~@l9d!#<6C+cb}>M)pp%i{OePS6KcY%(Dap$Z}4_HBPGw?uoeYe1qh)*y^!*dfF#Cx(ALmiII!f#S?xiS_wgIl69%nCE{9qt5xDn z%Ns#K=>^4(XIC5`>HyV4IdW$=5i&GI*@Dgu{In^PNH zO6pH*CP~=c9pq-Bs}e)p9<6=Ae&*OWoH)7IT^jG@5BIbPEizD0ND4};XdMm@?e^$L zHm;>C-McyxU)FK0(2iWdC5DEr(%>6KNMCtLx4()TSQ-9ki%WG$^70dNdcB-ruaJJZ2PRw#<-VvPI!# z-qwR2ZDWNAf@pY%XAizg$u)ZHrdzs)T}wcSs=<9TnTo(ut(cL6EFobH^%xM*l;mph z!6)Iew)P>+Ig;+cw(@u(Q`E-?E+`J1e6;{q*5&Ex+W$n+?x?Dcm7@7{y!u8DbZYh+Q!&3osav4L8XaQw@E4Y8?Q4&1 z_@lmbihJccJfA$~Vb7MaIn<=6NC`KNu&x4=Jk-om$2+3-E;}Z^7z%BeW_jCNB1Irn zF1I><&9Li%<-~=Ca+hU9sp*Dhy93C4N6qy7Jn8}kGtp`m9(P;guRQ+1NX}7G+%Wj`p6nUDnH~j)Rf6o(N|TV~@JBb`eI})an~ej;svfsF)#oncLTjMj94X<8X8h@$X(1iTdm*1qq3^-E{#8e0t z?EW(={ZRa|2ZZ*MR^vwityc83aK}{zU;1K&2#*tF}_AwLGivVl?hSS4|he1S;-Ff cZ~Y|cON*(!>7f@a`m@j0YLjJwdBCoJ0nM^d#sB~S literal 0 HcmV?d00001 diff --git a/assets/getOff.png b/assets/getOff.png index 6ea4ffa4d2b708102b8f01e8ae3a5f7b39a41d98..814b15e2e5e484c5208678b120fe0f259ee3f85a 100644 GIT binary patch literal 5991 zcmc&&2{@Ep-ydtCg=C3hj1U>Km@!L9vhNv7RMb5iW0{#|MrKeUN<|5U8udgJBFWR9 zBvDyIlte`fS+XlF?>&;I=lkmIxxV-Nu5Yec?sLw6{hi zM*LtlC=G$EHsiAaN+68~CDZ&EOjFq4^;<9~gK7%ffOSGTu`Ou+4BIdc%`ME?oe~yE zAy8pvYoM$7M9@GmjR!#a!9h$ek#7o{wo3$`MbiivbXtTLXbLkIIfQySt%q8$I5a5E z2o0wokw_?xV1y!@U;!L~j4_0wk!UmmX@Wo*!_jCW5(U1YGcOqEkVB;tNtV_#?!b{L z%%8_&6A_5e&`_gLV24+e5S}Rv4E9?V!R%SHOzsR)AZ7?Yz($~qkfN-n1*w!-Id%vqXxcfIf}jP_f@w@1 z7nDWK%Ch}gJQmlV^$XLp%YSnKqU+=|>*J@f1P9N$;PR}tgK5kF@>6N9yMRqYkZ4?1 z2!}$m+75=P{XH8lk3{ z1%0Q_`tK-0(Z)tt=n5x*!eEMU)fZ(kqe8O;cr;TONF@_E5(7t}+)-E}5=}%KgF_+` z`AyV`MP<+he-}mL-BAQ0D1^oRO%$Xu72pBC6{b>%bQUKV0C#~A4EWIyY^EO!I=i<- z3sw+|13CtyGoFsZ$%$ykk*!$D(mK zj4=UCfI&t3O$5z=ApxQaGlijMCcQ+w3kvx*i9>@yArUCi_sqKYZ`S`>_FytVh(QQM zjQNJD8`!@#-%u^5pI$dEzfbuN`iin>yDr{z&@so}GXOMj}8|qI3!TL9>{U*j`(RrZ&hi2{v zBK!~P0`bf6SjGg`q98s5Ah$QgQS{}F*F(qPM~AKGQps+Z~$jahGQ{koCz95r{j&m ziuh?d|GoYEpQl6lEen3L@gi=_66M#hQE42;4?OshYqKFhr&;&E!0Xx_UZ`^W-qTaCRn>1 z@M=nW`k@{InWtxGY3|M!_6x(^JwlYL%zW4SZdfb!N{hN8F_Y}jgc&l=5#LV^6;@W# zHy*nZGo= zbL(~t`xlf3UWf@FXPx{!6*lmtzq4a1^kaMJj^hK7r0gCEOR=sD~@t2lgjXX2A7`bu_vb(Bp?zcYalri68-LiW%zkZB}u%EWAj&y*_FSj zSmB{2CBM)R`{~`w2K9DxxkRx(2pU|5+ZcqZqU?`UF7cXo&U`YDr&k00MD zepUteOZ&6#JEx<8n=_0pf5Yd^fN)V#%9b570!0yQ>$D#&8bVlzZ2I_b@X z8VRE8l(1Y|ux6+40Ees?nfgj{`AJm^VX;lX*0?QZ`f`7pB17{YdDn1FYJzkPEqIgk$ z-#rumNIe96--Y^ejS2?`a`(8u!dx!P=K2V>n_4fPRbjoeDBvj7@@aFUyZt6Xpcv8GxzzZ(gI;=dF7#teBzLAlFG9~$(e^9 zT?@La_6UE~=?z6S&J_Yyp(fPTak%uoxuZH(H8NQLjQ3UTCS4LSYpf;M*Uq%IZQ{_A zf@(Ez)r;xjgke7%h){tr$w>}=36jq@dMw-Zb@U3QK(eFbYx32%%`f=mOJ}PfQ4&h+ zTJ*xSlr#BqBf9gXpzp}~wFL_b)n;=R2X=u>G{PRvBjMF{5&mjymJ%l*%LQ`VDxTP z(<7s+C5IGjI{aURGx!5KwdsJZn3mpKPxx;h{T!m$Z!kU4J!j^RWTnYk`KgaE7_hl z)Q+Ayjy=1wXmo5h^~B0dP_Tw;RTH)*wmRFRnBvUKQ|>*QcsHOIfO-Wm*N)*_O~_Chc}5 zbU2y?+BJGBdxQ<~-aQ5+?q72c81+^sEV)0Ts`i$Gem!=}QCo{pyIjj-W3JzLdhoq1Zn>f z$^Db@!@XgPlA7>`xwYNLY96$?B&pVGw8`w1;wK+o?Q&EYq0p&7xMH@2<-EuT32w^r zFYtscvsBd`g(*g3eEYi6m6@9zP?(0j@!f(7y)&*6clg)WM_5$7Rnqh>aNE#X6Rk2n zG}MdNxQ1ICnWNl8Fpv-Ha9kgF+uF5fpgegf%lm|ULaNOcI|AZRsi8~U>-&=r`wy28 zN)Bs}cR${Ltx|dCE$r?cdc$Jf0WM*Fl4QpL-<4a6#J3J!BRHyQNJjQXvB|9rt5>8} zJ@lG&k00tfEqze)#;vAT`h#ATC+5$R;rlOwULRbTb;)u5BUP#Gy7}2L*L=t(c14i`C~?55V%> z8|9|>0d=Y$N274#8AmWHwvDK)#^R1Wz5F6!)L6ImE-$pQVXbCy=TS$rc%lz4*;}1t zDHqhGEq%&oRn>|?;U>c+pJ=Nj-c%H(c=qZWp4VxXmvoAohdivG*KmQ}>gr7qEA(t0 z?h9|^kgpY3?exZSO^!zU17EdS7b}%9a`V&Q43-?WjQzMs9eypm zNbbTzEpX75Z4t*JS%r^fuG?YfW7bN2)Q86@2wkDgXRR(5?X6S8nKHK+9jh;vJJ@h_ z{4*u9=iz&(W+Sdnu|Hz+bv)I=56*Tz)s(j>%7VOTiJn}1OPEdN;)BhC+_R0{7q3g) zu=ecHlA_s z&BCwkti$K9Y4QqX^|7J%WX3&`O3lNR99Eq`w0;nriHL4MwnKHt2BJo+cdc5rWJ8FL zP*}6cNb9M0Y61Vng*jeGhAMboIcg|WMBUmqnBG>@wesPZwB&-4tt+ndl=K>8+mA?P z^j?POanEm?-0T`2%xKD*xH$4ouB4=c?3iZeW-YyGb(FsLZablTW%?(MFk`V{`V!iZ z7lAJreYM-;qG6J0L}6U=)tptq65}F^$U+}IXhgQ7yrNRg7pIW|%;iuRlajYthg0aB z?>wiAxp!Vn`6Zcyn^({WCo;D^KQF8AE#RCMRz2VHhXiVm!B^#6*ZO{9K%rvs28+&y z8ntw&;-pPg6F1{#r$)?z0L2vdUhkVDai5jPcb#i?32jtHCIF1&T~ z-hiWPM;LayAARJ-WtCT!Exa`lda-YBNw2%)F75q(9fX?nh$lI{zOg4|4fwA!?r>mJ z^SLI`?$Dv->+;XY^r~(0ntYj>DQNB3^Qkz%eN$s@V2UHxUAyLyWM+lxN8gi)jh>`j zzq%aEa7}km*rMqJKZQQ<{4P4fbB9%5H1#dxZQ4$U-Y`G_H*Oik>L-p#e@YC^Sn_Uy zp3bVy+PmHPko!l^fLDN*$!;G;uXl7Me&sITJK61imC2TOZaSBuSk&!x z5XY9>_i~r|POB^=m70#RDgTe0JTqqTNSG)ac7!ZqS^1Dyl-l$D45;&qN}et_9U60c@P^}HMC@$?htK;|myR_a zz(2=^eaTi4%thQRT6Li>@pS&}M1{G7@jd(EJx{#oxUN*Tw7S(c-$L|PxSf@=W#PK5(fJlIDY^Eb5ch_0Itp)=>Px#1ZP1_K>z@;j|==^1pojDNl8RORCodH zoK0*KM-<23tZhmO6iY1>wG!F%5=2B24sfU%$+V&>g{px7Dgue*1~C!jP-wZ3TtIMv zikMPWYTPPOff5J;ia1b=;82l6lOS49CE_4~)CQyt!4ycm(|>tmJ96w9?}yj+HtJ6T zJD%BS{m+{>Z)VSq-J!DvVR)>BSh(^IvoHH^v8Y* z_Ct{%P(b|ko&IretPv=D3S0!@hrFN~1tmf#ob%04BHD@VQ78}v5oY%KqXA0EKK3>M z1%v|W^??5USbtATFF1^&!j!EGH3Fe1hzi9e3Y4_R&IH5Y5RM3ww2)3Hz95-6CQx3& z%&h-f^?2eaNB~>~3Hy9Xdp>XA5Y|dVgB&T90L6_)Me3_OUB>nd;BP z*c_;YHOt>&f-oW2&vvOf55~WGj|bHj>OJ1w}M4136mT;XW83YqrP=j zZ^Pyli{O)G6?usvqbqU;4xGAaR-k^L&$HIb&@wGdQk1J;;9$$=&w=B?54bSJz=A+g zCe8}8;(wSD#*8R9WSUo3LvVFXK`g|G{Y;$wr}~V#nHI-T7&D@vm7Li9F?Xh=mVIU$?(2Y){dg=-MC!XhOx(}wU{FdC!DvD-_a?w{&Qx>5mB2^n1o-M_)QkP$wWDS z^B!yu{bDr`6Y}(Q5S+k39F6&#r6PZ$h#&J9VbCe_cfEkP(|4)>W$Wd(d2XitypHs$$e@L7Y5@Nc0?hJaHos4q_bH* zeFyyYhk2O7h4CnQK-A04*0L>h1-5lu0XyW{BO3Sm8u672Lp*rylTF=XcR9}#D1S;9 z+sVVd7(3!57MqpgMw-xq3&Vy{K-4FP$(0P8Zc&sJcE!Q1RtbD}QsKv@EHut+MOEE4 z-6o(}pV$#6$NsY)HBp!=4#+NaBN$I%u$hTfaZnW5cVxq;Rv}az>DE=f1#ZPDaqt*N z1l9Fgg`NM_2rqI`I4{Bpaqw(Qgntf1RR{s$_QKp*#oy4jO1|PODHs3o*(Ozumr}7X z3+G9f>5k#lPG4QsLw*>S4vK>u-yvNB@RJQ0xQJ9m4dq4H+}_i9BkKz}iIF%9<$E-$ z0@ED3an7_ChGQdjVcH9W`@K1~8l<3)iGsk-U@AtT{S5B%iFW_2a_r(D-+zqH#7LC; z^6l8D^rUR`*-I!iPQ*&McCsT1GCcA`)}$#!A;J_LcDIrmX9Xm>gk`s>6X`B)Tky;EP z5ha<1u4T6I#K0l<`I*sJc7NuE9Z@jimiQPAHA%%WLaVvZcMseKrk$jpMca*2y3=+6 z(_!0S*YGtlT@yIRJ_%%Y>biQDuVCG_(oPq`jwlFbeu^NJ@br6bF}{iMu&5K7+WDJ9 z;6_joyP|Z-P9qsuyn(M=7_GMzeN05uQD$=gWey0OH^PR|xm2#qrC%PnP#gT`N)2F3P(@g+mtx zEM+|8yQC<@(GA8x(mWpg?rCK0RFpMzptn^sKgZ*SOYQZ-BL|Fk-u?U}IEhm^cqwot zy}0=YS#vbjv!f#YgMY0380Kr5L|5Vrwdv5$!XbsL-z;UT{uANt3M5D}U!U@$819N7zZ_PFZ^{ zaY~JmCr(%i3HW94pJg9N|pxD}|NP zKTK#WR_-1SXMbF>l%(`8g_UB_LJGvUL;8ji6}9FTxP>bgO~+`qN(TkPBuC8W%V!Es z)nH#>XGqwon$mE;iy>6Gv(AelsQ5u9A_ z<;DMxNs&;H2kT%Z3l?q2>Jyv-hYa&2?M_%o*6)S_!hMjU2QVd{>R7Lk|D%`)A^o^% z?VBhITb*gFP*7nq;aTXE2H4q+%7z@K(fX6gk!nWQDE`MYF?1NFG~SO#$;Etl*T3fv W!d>~xEe+xT00000E2_EaiSC`u)?5aR!ik#GfwGzzuYLLy*-fv_0Og8eu=Gt9dS6&N%JGQ+GQF>nllHSEu^3lqX_VUF%# zSRhD)Fc#+M#S%KAfD4P6XbCrnC!$NtFr#|u$h&MBi$RZyhy%?qRx*QVPlhwvnlFUW z6cYjf;&3=Lg=T_hkx5Jnjb&<#Cg2DJERKxD69EE&j>9AW=&?TxVo(UN>8>`mW9E>N z8OC2M7SOTS(9lqmP@)N6=!eDAXf!O2fF%$BL;?_P;E9>1y?YxmM0oR3c(C3VG6K#6PzrnQ9%eCmlFgFgGP-*AQldSxiC*G zLS*sdvI2j;m@o3@|3vlp^4|;~=rS1NHh$;}mpg7kBwiYVgfRxl52Z!!8w4=c6&CS> zg&@2%1aVXUTQnlEEBqIp|6n^JKE7GZVgF>WY~^o3!4l3-L}e@Eq5`_L5N3+`LU%qt z=o@{UzoCdG5KTzv1q>$0;mL3{l0`AL1-4;|VKWRuB{G0B1@L%xJc*7Y&K62)MtAB26gXU_AB+|7{4nV8v!z?} zgZM(kFyb9?)C&fKZqE~mnLH4-w=u&YA((JD5SRcXPH6(6*Pqb5=^1s z32ZzGg2%Nv2{}mqGlRa@D)S0LG{S5ejSUe=0K|mIfGGsQ0F_220(et4i9n&45@`e) z1}!^pI-&+~i7Cr4GYo!g(o@E}py02Q6atd`I4oZFJ+|)ki}k;jSMoRr!~_gl#+a|D zx*_!oft5@m5lLh7&KwCGK8ezlw?YY;h=42wV9f2>*k; z!2Z;IJ}(3oeiwj%DP$s58y1SpFl-^8iyr4NTFgf?1%ez(4WckgOOVg`yYgZgV|IZ1%?0?k%@#uX3+p9#5M(pBsNH6u}LIT7Lq1E4ClX> zpa1i4z+bZ97Y8rn#yC-a4jTjuIp6W%d#;VUfF5Pt|Bk(1vhJVN*#C!P!pd5buVn}O zUn|nrvCUU3ZDlz+dTY~vRD1p@50Pt}j$9ePxI{=)3Z6{FfdHFA0|7GLlngNO6cWHB zu<%3>1_>l8TgGacHvWHliO6cnC=B0qYTu@i_D}XY-g+W~@#YogA-O9=T2m}!{~l?5 z7uwrcxl7XDrf+n1@0C%C9>lGcFx8nQm@y0Ca} zSzlLE)TET!&`6hy9qEIEo6Gu!Zklxrg@>48{&Z>JR%&WA|tO+9YLzUs7Nt#ytSd>tD&T>BPuJ!JQE!LG9midpM(w0MJUXlaI z>Ida^xkW({0cBy^e8dx!rQAxnqFO;?+SM}K*i1@*6t2{&T2;Moft-(ANGc=Jk+1nd z1;4eqU#)vq!n4!(Th2<0x#ZdAefJWURZR>7jk}e)j~$ioA(+c~q%k5FhE6GEAbN%; zOQuc?UKuJbjj!6@Vi2o)b-$gQp50CpPw~>d9*f$^i}t;~zGp2b^UT2fRh>ZBi;}6srP0p#3@QzBiW)evanTB zyYg(n4$I<4&@s2%%v2d*^20HI%=Bw-x|6qt5cg`jZLUt#ngv5$D%zns9d<5fwR+T_ zSryefAGh_Ki9kVBqN+T}G0@P-Z+o%s*7L;KZTFuv2G#(^emCN??@{}f?vtjlcefN0 zt&Sq0v`u^#Xt}<47wqW!zSqlVBka8+=SCgBB%)eb@hNROpv>OSh+I8SZz)Tm46jC= ze7Lsu<<>J7%UQEl5a(016P&vHuFT784$)kZmbJ}X^}2_!X4tW-Xtts&#(EpAApB0I zo}zE<8w1t6bvBTHL6WCd_xU90o9WU>5ROzH&tm?(35K~ z=tW5nS?8$Bk~AAt#Ix`^&!jcCeaoMGSkU8=v`=};!hABp@2%lI(%HHg(+PccC*6go z?s`CWv$c-3v}trq4Rb}cW?$~_ul_uF%DtMH?Rq!w%c;s@#Z-y67&LpQT-y9w_7lza z%4g-b>MwRwIUN zJRy;b(57MU(T(KGa#exX>XXaws88!?-lp3Tc&#==ZoWNd`0b;pO}ckJEnC(B1atyV z`~$Lcn*KQJ=J1$98deA(ZJ)HoX4%9-^LZr&>2TWAx)h6DFQYG@23FTBiQMyH&we?* z>btyor@AMs8!AcK4L6iZ%IuSUAb(t6{9Jp4Y8D5RZSHj+LOpkw>l9YE;T%6VCc6A1 zvB0o>m5{PJ+i4|j<~wHNrT2M*1qny?s_qMZow}MP=~P#CTFYvR+Ohb+7w`c##a3D| zH}J#p<#P{IRN2N_NHZy_@`X7D?^b9oy?PjLGWF*4pqpmZo!qDCq`kA94GjAWXYFn$_b0@bW$tP7>KQGC_-vT%9lVhyYv=Uxl_=O#+ zI+t;4GnakRGJb1T-Wf8tajHX1wK&gV`MeqX56g8W6X}bpOB_oqGQ4pqXLlYAgTVSO z{=+ka+pkpI$~Z?jz%PXVO)vzDsy~ z8>!|DyUONf-7a3qG9qGv=s~K%i(a2P zc0gADi*(}VIa7yCR^8!=a^FOR7g?aaPUMvz^4#UJ{qF0w&H#l0UeTKzlSDE~xB z(bEN|6)3A8zrE`1z0xT#O8C3a@u4}_NYP7q%W^u?LMkq3cZO!C?K!QVtX;5mp2wAR zi>|igif-NSx0QT4ka+(_bH9-jknXTbLuPpJrCk zcOXr{aZvxkv3d3H)@8ifyyVDy`mhuI%6_{`TV|+bw_V@Jx}z9Sd(m)jXvU_>u7L+D zlQs}{-G16$;dIUX%Z*A}MSi{sQhYT$@2y*+aV&>5*oIVK_xq%xfE9AfcAi+N7y)7w zdnwPU30bp{i+FFwoPi@GW3ET*zIO?M!t2De$+V_se+%_0&&Ma9f9aTj6mGl7f%2;S zjv`Mg$tU?uIe%e)b?)cFhCba>&YeSVfP-lAsZ(*5SqWtuS(fG8E_pL#dwXzz8@H(wZAI!zBcT1^aO#RfEa zO$TcncN+3+Cib{6gFNXcJ02er?W4JbFdTyq#!b5L_~WLkRdsT&jPTF-o`=JHx6Sr? z-MeRD;-nk%uY?!83!zFqABI#WW(9oO1k6?rKb5e?GvB6eL(s6|fEt+F%TKBgYx$CW zldh*Mi~7L=g^0?716>)f5AJ#t_t5ICG&O9}Ev`lD=ZVS_k!RM7S9|ZCnpPjLiS!kd zBvy_a{jBg8R9EzpkDY!k`0e*ee8!I{=vAvO~nuU zpy&F9s695lTv72h?|}D|=#v?${+xGE&$WXt>sR*nW-h6|Z~GzbQ~h0&v~Yi$^jvv^ znA0E2%=P?E9%;V3b;?GgP@-Skq&*F$1-@5&{IsziNE@@lRRh}HQXKyZ5H>6dOiLNO zt6L6KIs0dVucV)KE3ZtC-hIYzQU z4o{#KTA$~k?py>IPNr^k=&8_tn$4wkNX139nJ*2>$`fBF|LK=RZ0gfK=OY=}pQ=#a zM-~RRMV*XL>1P%loABhZ&z!@sPp7ADc|+0L>Vpv*6XgxD=Lc?tbd+fH?J(573t|*p zTQQmxdhD?l`gSy6y{!sjtyp7-v`wq{t4jqriCJ~pU9(0~r8CVN6Ph*174PZW-cQ`- zcYk2Ik>vI5oyO0wlZOszyoiqSsWIHN^0Jros7mpcCvg(?bxY_>9k47xUawIc$C{pK zelbw9RVm7`YPqkzuhsRu;E{CH&B4y@{1N%f2Ly9f!P^?LUxV$JI@;tf@!k3_lfoXw delta 2222 zcmV;f2vPUyEY1;-IDY^Eb5ch_0Itp)=>Px#1ZP1_K>z@;j|==^1pojDV@X6oRCodH zoL_8HRUF5E=iXM3@~4S{Mw6}cWdy>u7l;ooDKQ!mh=T#ajD#&QQ8yOYi$q?wy+GiF z8K|0=$RsQRG8l14d{9d8!LXMNL?I@uTY`yBlRHOoFnW*YcYoT>_TF>a-uB$~hWJTF zOV7E@?YF<*@BI1wP6?Ee!1hQ3BoYlo^VOS_h6vDxR8z>(HOBr7{3Apgr6wH%9Y*6{ zb&NupAmAdwrW3(AU%VA4yaikY5`>bV1_dojC|vYErA3X?@5jI;ToGpRwqs#RNjG~M z1{a|~hIBB$n131U90J8C6(+DX(h7v4AR#C(F`%S3e!in06rn_zl!cst@(W@`nLs58 zv#{xtkgg|CkT7@(((m_cy`{vUqJ??7IkHy+v&Tt54F(p#n$WXQ7kUQjYL@fo%N9Ka z)hm`}^_%$PZ^6D_WxqJcIzGtT)7R`e!j=VnRKjv_)hB<8k z&d{Z!mDH6|)B6*$b`_!^1;&QFprznFWm;~&_ckeX{d0a#pQugfN*Gr(4Toj2P}HtU z6a?ocIDsn_oCpq&jAu;`(j%kn{Rx4W1NEEF%>H!Au(oNU3v}J=md$88Hq|K;MKu_7 z-+zsKpY;Piv+n02`I6@Kp$!ng>w zzGx4=$!!B6mwTF&&r#6a5A1-VK{r7$GBNHyWNWbh?i26%OEz+D=pN$1-b!j`peFOR zEMZdC5&{9%z@`cmrj%JS23V^wB5`#Y!MEVl@uq({&NsD(O4)B%Pv$!6%)m4 z;-Dz9&&b@UK_gT=(`{V+0;t4`;(uToM+7y^294!^>xCE9sNbganmCx-5@7-fX>3nI zxV@0M2vdod#931<{_@%GUA>P|u`pH3?Kh4i%+4d1!A(#cq#W~{V2+&voZy8-4b_5` z^mP1DK`Yr(DVI1^@;#c61|9{)nPfTaO5s^ya1~t1l#I_|e`TrH>G&+$z<>Tr*oLB1 zaxI0RFfkCs87#%H#u+}M^fp1Mq%(#h$|=gEJYyM?y3+K6N8r}Y$kM3gwv>q}X$&(o zq7h++bWEd^iY;bb92kqii=rU1 z3?(Im#TZ?ex{@_B!ocy;?^AXxb+f>6TC@43bJAJ6rRI-#k3eSI8Mk*D&YY+ym7tQ-vwZ-3w;7seRs!Wa`3 zbsX?O9!NEZGBOsKFgLoGuFadD`4U2VMV*s{J??Kcl#5)}qJ!3-em*bE{d1jBNUob~ z;1+<2q1y|{tc4Mq8El_F-jlt$rleO?cZUi`hR0l~cqr8(os_9z;!j%Yil03LTXABb z`8Y<~ko9$*A7U%(I)BBrysC;5Gr4#29otBXv+q^yCuNt!;)8p#3Wsc~YZhzTL|5Vn z_4bi(JnvGNYqyG$o_$NPSPNnkCKYRIb8@yzv$?Wa6cxI z(9voPQ5!~e)uyik+*!mLLF;xX-fQI2nB*#I;DIkui@X4O+kf$WY+yesYqw7Gi$!&1 zcqY~=3mcTQ$qstuJY=!B`B)#zRPL3vs}jcz@`*x#esq7ZQ+gOJ=OK$lwXgqZYWf@O z>GPuYtgC$v7Wy6UtGfbP;J(kU=5Qa*Xne49ORf&ej^&8_yWHTp$(?NERB}AT?2>ZCZYrBnDE13dSiTwr*fSiNPnjWTi^7?&xsFer#BdRuTJM#_|38x=!IdA@!ILa_XhU{BK^5pSESB`D zun^mOgNyJOq?utX$)_fsS1A3bmd;(kC^B|L{r-6DBB4jAAOeoIl?6FXBn&4t)pWF8}}l07*qoM6N<$f}7<$?*IS* diff --git a/assets/start.png b/assets/start.png new file mode 100644 index 0000000000000000000000000000000000000000..fa26cd9e085bb51129145bcfe0bf1e091f213f2a GIT binary patch literal 4679 zcmcIo2Ut^A8wMOGphXbGfy5vJN|KvB5D^FL3KkKtB4ueQZY^a9R&g*=;4xmlC`75FV`!@R=<4jtu+Y&lXcO~;Rlz)O z4kD8fVN@o-h%pj5kk-+e?;InCkqAtM6JR2-)Cu2JasiJMqfYoBIuGK>-LP=+qBsTS zALqkI;vx_>ig#Xsn;*jg3M7~c#>Ge?rAkhW6Mjf92fS;hNqF3lh$_Mf@1ik?3+DOa z+++$2$0Sk+2n0bmCYwkWFz7IoEucBzC=i80f*2$+l|Z3zATs#Jjr`$(K?N%0_;Ec( z%z=>;K3t`eb4aA<=xAazl_*n)NMtsfO@b&S3WWe92+CNg3XUO2mDaB{a4{vK5X)6! znG~nd2n%FUDknT}b~p-&T&q^996<_TMv8&uBr*}wL^ULcB3e0llp=D-IEs+4NKArB zRZ1XB*2>DmWh$96T=pK-+U2(l0Caggt&Mm3B9UlKC{^xi5XJ}~@03>ZW91mh4^zsb z6bR<725wrvjz+2S!~VkaBenx^?Pir&_@2F*mA8VzV#Mze)vRbmDj0GgXjqgd zL&d__w?!!wK84DmFgO(GEm1&a6js3>6h;w_P^OT;APZs%EW$`~sR)nL=9c3oioW1fp1sa!?wbE}&5v1S%b&5@<9glfa??DlFm9 zt!=474C)__d{e8&D->wNglx7DrP2u~j4}u`6vYTEHiJqa(}Z*ilS!kpDQrAWlWz`C z16+bN73PE|k4y$@co!KpJjtYh;)h6N&G*Q9$OqQ{S`Ltk0mJ|T4P%B;^#}b6z{;T0 zsq~R~UvUf;8O#*}bd*DmFoC6^9YZT2Z?9ESQLUlfPo63p$JpJ`nz!6YCr#aILHTC@PWi@xS=J=`(Z;d zh4>8~yva4K3%DWH{eSFzkaZu|W8crZe=SWU&6Q-h?U4SvBMpza!&rJ~YINw-=De%- zd@K*aG0p);#s>}&jKN@2sR%?s$V@VUfzW6KmXM5qa)p^Fm5E^j78*FVjx_UZ`++RG~@1+}XHSJPDCMtdC{eSI&k3qK~KBO@Y`?>@PDT?4bh zP|sycSD`9-&c;Qd;|eWqR_OCh9tBS3rXD|dvc>d}M~c6Q5CptP7ugI}d?>Al}>TK_9(!ca-$J)KrzS_O(I_nN(vi&`A z#<n4^9^LLndEL?1%YY&e;oH%Ja74NZ{inr>*kx?;tnt{GP+ zp3$vd@8Q<>(&RMDvbzSMo$d2zaXDyUx262n>Vp&eE3P_Kw|~jw)+SzSY^H5=&Cy?s zKoyf#n)-?!9WYcoZcBppHI$5wNpA79xqU{LG`;wt(lPUwxm(+M&GowU`hITXXP#bv z%&6%G`~|f^qvS!^nzG=~86KN;bI7VIb>Wuo1RyNbp_lEGA5aQK&|tCGeu$v8GED*F9LzdwN>b-S^3+>9+%0#BP0wgDvBhjsND{d7qV2e6z3r zxh3fwzsaxSe1W0;ygKW7)=kg$Zpec7#UJZ`CfAR;JM-abW&DZOj#0a(6<42rQC7)y z@LsetVd1%eyCMFzJ>@%0%y*k@KhXAN`26*)hPu7X==@ljA)$Kd$@$kuH%?7rP14v3 zDbsD47;?ehG^2FDm)#dI`PVN$+qP`9US7$y>Uoa)SoIS2!_ynq7&TFi>nu`V%|3q7 zb!VkAIlMl9>ye&zvoU3@>yPwo5rbi+lF(DxFurPfvqkIoWPqfRaqf%9;)%^6^ljB$ z2WCngCi#cny_{^tfIfHO?0K-cbAW$K6kMLT_Tdco-*#o}O0DY^b)nZXT(Pg!-IdXn z^Yb6z1`~cbL_A;S;MO-IsC!DM=i~U%hYlZO_xBH!nBWb{4zV}2$8-|w?=3Jteyef$ zkxvTqK@LMFG|tJFeHL=DJYO#$nhe7sy3>OKYl<7k76p86Ki+ilz2?dFCohNPgg|bU z0kt6oE5`;rAcpKY>)Mlg=tY%-b&-s+rL@~;Rp6B%m8lhY*vd-%Q6ipYWq+bP$82iz z+Muc|ejI@dI#;CTK)+6tk0-Ic}Zdxa@= z?M92kVt2dPj5?A#Kcl|<40g0;rA_T~Vakmj+tf_)`Aa*EP|;Cp*vk%80$ADl_{o!E zMd_z}uZ^>px5fccOqg_YlI3-s-TGWL1s?;gGB43vq5N{Nt;zc)rzHk zcAIz3c&YxBwXT($llYhx5$W80_EWCc#xCyJ8kFOlXWnL8P<1=Ez{n_U+Eed8TcA^6 zB6s19feqh5clXm@{P9J2Z>C?e!^Ih5XB)>qUM&ZjkGh~N`QaZa$I&vpPwUIW<%z4B zy`MI#>1RSLSLz zyPvyxPc-e?uS;{ii>jT=mRQj*7ndRr+yX2zS4Q2p^HKj1Qqr;g(Ek2Bb;GRsU#^dB z|MpqdZ13Y=^#-Jd$ySM;Zmt)&+{M345gQj<<@3;FKB!N}ySPtvERy-45g-+HcV z;36CtnR~2od2vgFk0gHHJjR{S4%1WqW@GRqpO-qpHc$NnIu8k-zf7Om-`B4n6W1{7 z$^8g}GX2e+v9uX?Q_XD`xa3XqTNLK+Sa7=ojZV1II~y`y-!x%?@Ki%K{_>r&wAQaC z?cv+D^vx6Fuhe>AX*$`0CWC3dcS z)kah^U+lO*`8+Ya%f!1RPVbJ-Oi!tL>^b(ulF{*lZZFlvUlckMua%6Rl2#m#pT!Sc zwj^(wWAo>u<_qtfzZBdgU!xO~*$*D0`fIN`74qiXj>ezdFrn-xvc)~Syh%SRFEu8; zbOs$j&q3dO)WEW$CwjeljuV=!n>KCPT{KP!+R{@!Dg68Sh?JI){I6T^abLAW-TQic zbIGgQSDkj(XM|ORr7ziZ^8#e7Icl`$&Tm$~@golITpp^g`Kiat-G`g&x^m-x0N4i+ A8~^|S literal 0 HcmV?d00001 diff --git a/lib/constants.dart b/lib/constants.dart index a44eaa0..2558280 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -96,6 +96,8 @@ enum ColorType { // all the buttons except for the main map buttons importantButtonBackground, importantButtonText, secondaryButtonBackground, secondaryButtonText, + + mapWalkingLine // Color for the walking line on the map } const Map lightColors = { @@ -105,7 +107,7 @@ const Map lightColors = { ColorType.background: Colors.white, ColorType.backgroundGradientStart: Color.fromARGB(0, 255, 255, 255), // same as background but transparent - ColorType.mapButtonPrimary: maizeBusBlue, + 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), @@ -129,6 +131,8 @@ 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) }; const Map darkColors = { @@ -162,6 +166,8 @@ const Map darkColors = { 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) }; // returns true if the current theme is dark mode diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 29b3c9f..ce30e8f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -135,7 +135,7 @@ class _MaizeBusCoreState extends State { // Whether a journey search overlay is currently active (shows only journey path) bool _journeyOverlayActive = 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 @@ -156,6 +156,7 @@ class _MaizeBusCoreState extends State { final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); + final JourneyLayer journeyLayer = JourneyLayer(); // GoogleMaps styles String _darkMapStyle = "{}"; @@ -174,6 +175,9 @@ class _MaizeBusCoreState extends State { _setupConnectivityMonitoring(); baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); + journeyLayer.init(_showBusSheet, _activeJourneyBusIds, _activeJourneyRoutes, context); + + hideJourney(); // Hide the journey layer until we're ready to use it // TODO: Make sure this still works when moved to line 197 @@ -192,6 +196,7 @@ class _MaizeBusCoreState extends State { _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) { @@ -481,8 +486,8 @@ class _MaizeBusCoreState extends State { // _favRideStopIcon = await resizeImage( // await rootBundle.load('assets/favbusStopRide.png'), // ); - _getOn = await MapImageService.resizeImage(await rootBundle.load('assets/getOn.png')); - _getOff = await MapImageService.resizeImage(await rootBundle.load('assets/getOff.png')); + // _getOn = await MapImageService.resizeImage(await rootBundle.load('assets/getOn.png')); + // _getOff = await MapImageService.resizeImage(await rootBundle.load('assets/getOff.png')); // TODO: Move this into map_image_service.dart // Load route specific bus icons @@ -692,6 +697,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)) { @@ -999,30 +1006,32 @@ class _MaizeBusCoreState extends State { // }) // .toSet(); + journeyLayer.refreshLiveBusMarkers(allBuses); + // 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)) { - BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); + // 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)) { + // BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); - _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), - ), - ); - } - } - } + // _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; @@ -1127,6 +1136,7 @@ class _MaizeBusCoreState extends State { } void _showSearchSheet() { + debugPrint(">>>>>>> SHOWING SEARCH SHEEEEEET"); showModalBottomSheet( context: context, isScrollControlled: true, @@ -1199,6 +1209,7 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) {hideJourney();}); } void _showDirectionsSheet( @@ -1273,10 +1284,17 @@ class _MaizeBusCoreState extends State { } }, onSelectJourney: (journey) { - _displayJourneyOnMap( - journey, - getColor(context, ColorType.opposite), - ); + + 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 @@ -1289,9 +1307,11 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) {hideJourney();}); } _showJourneySheetOnReopen() { + debugPrint(">>>>> Showing journey sheet on reopen"); showModalBottomSheet( context: context, isScrollControlled: true, @@ -1327,7 +1347,10 @@ class _MaizeBusCoreState extends State { }, ); }, - ); + ).whenComplete(() { + debugPrint("***** Modal bottom sheet is complete!!"); + hideJourney(); + }); } // TODO: Put this into composite_map_widget.dart @@ -1351,439 +1374,97 @@ 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(liveBusesLayer.createBusMarker(bus)); - } - } - - // Final debug check - // Journey display complete (silently updated internal state) - - setState(() { - _updateAllDisplayedMarkers(); - }); - - // 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; - } + // void _displayJourneyOnMap(Journey journey, Color walkLineColor) async { + + // } - // 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 + void showJourney() { + debugPrint("**** showJourney call"); + journeyLayer.isVisible = true; + baseRoutesLayer.isVisible = false; + liveBusesLayer.isVisible = false; + } - final bounds = LatLngBounds( - southwest: LatLng(adjustedSouth, west), - northeast: LatLng(adjustedNorth, east), - ); + void hideJourney() { + debugPrint("**** hideJourney call"); + journeyLayer.isVisible = false; + baseRoutesLayer.isVisible = true; + liveBusesLayer.isVisible = true; + } - 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; + // Clear/hide the currently displayed journey overlays and return to normal route view + // void _clearJourneyOverlays() { + // journeyLayer.clearJourney(); + // // 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(() {}); + // } - // Offset the center significantly north to place in top 1/3 - final offsetLat = centerLat + 0.008; // Roughly 800m north + // // 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; + // } - await _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition(target: LatLng(offsetLat, centerLon), zoom: 13), - ), - ); - } - } - } - } + // // 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; + // } + // } + // return [bestIdx, bestDist]; + // } - // 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 _onMapCreated(GoogleMapController controller) { + _mapController = controller; } - // 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 _onCameraMove(CameraPosition position) async { + _currentCameraPos = position; } - // 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 _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), + ); } } - 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 _showBusSheet(String busID) { showModalBottomSheet( @@ -1888,7 +1569,7 @@ 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 @@ -2064,9 +1745,10 @@ class _MaizeBusCoreState extends State { canPop: false, onPopInvokedWithResult: (didPop, result) { // when journey is showing and pop was attempted, clear journey - if (_journeyOverlayActive) { - _clearJourneyOverlays(); - } + // if (_journeyOverlayActive) { + // _clearJourneyOverlays(); + // } + hideJourney(); // Hide the journey if it's showing right now // If showing a persistent bottom sheet, close it. // Fix android back button for buildings sheet and journey sheet (doesn't work without this) @@ -2082,8 +1764,10 @@ class _MaizeBusCoreState extends State { initialCenter: startLatLng, mapLayers: [ baseRoutesLayer, - liveBusesLayer + liveBusesLayer, + journeyLayer ], + onMapCreated: _onMapCreated, ), // underlying map layer (different ios and android) @@ -2623,7 +2307,10 @@ class _MaizeBusCoreState extends State { ), ), child: ElevatedButton.icon( - onPressed: _clearJourneyOverlays, + onPressed: () { + hideJourney(); + // _clearJourneyOverlays + }, style: ElevatedButton.styleFrom( backgroundColor: getColor( context, diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 8ff7db6..ebcd41f 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -1,16 +1,23 @@ import 'dart:math'; +import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; +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'; +import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_image_service.dart'; import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; +import 'package:widget_to_marker/widget_to_marker.dart'; // Create a bus marker from a Bus model // Marker _createBusMarker(Bus bus) { @@ -130,7 +137,7 @@ class BaseRoutesLayer extends CompositeMapLayer { debugPrint("****** Reloading everything in busRoutesLayer"); reloadMarkers(); reloadPolylines(); - onUpdate(); + if (isVisible) onUpdate(); } void reloadMarkers() { @@ -220,6 +227,9 @@ class BaseRoutesLayer extends CompositeMapLayer { if (!polylinesCache.containsKey(routeKey)) { polylinesCache[routeKey] = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, polylineId: PolylineId(routeKey), points: r.points, color: routeColor, @@ -245,7 +255,7 @@ class BaseRoutesLayer extends CompositeMapLayer { reloadMarkers(); reloadPolylines(); - onUpdate(); + if (isVisible) onUpdate(); } @@ -287,6 +297,7 @@ class LiveBusesLayer extends CompositeMapLayer { debugPrint("Error: onUpdate called but callback was not registered!"); }; + @override Set polylines = {}; @@ -295,7 +306,7 @@ class LiveBusesLayer extends CompositeMapLayer { int nextAnimationFrameTime = 0; int animationStartedTime = 0; static const int FRAME_DURATION = 100; // Frame duration in ms for animations - static const int ANIMATION_DURATION = 8000; //4000; // Animation duration in ms + static const int ANIMATION_DURATION = 11000; //4000; // Animation duration in ms AnimationController? controller; List buses = []; @@ -372,8 +383,8 @@ class LiveBusesLayer extends CompositeMapLayer { // 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]?.prevBus?.position; - LatLng? newPosition = busAnimationCache[busId]?.bus.position; + LatLng? oldPosition = busAnimationCache[busId]?.fromPosition; + LatLng? newPosition = busAnimationCache[busId]?.toPosition; interpolatedPosition = LatLng( animatedPercentage * (newPosition!.latitude - oldPosition!.latitude) + oldPosition!.latitude, @@ -385,7 +396,7 @@ class LiveBusesLayer extends CompositeMapLayer { // 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]!.bus.heading - busAnimationCache[busId]!.prevBus!.heading); + double headingDelta = (busAnimationCache[busId]!.fromHeading! - busAnimationCache[busId]!.toHeading!); if (headingDelta.abs() > (360 + headingDelta).abs()) { // Might need to fix this @@ -395,7 +406,7 @@ class LiveBusesLayer extends CompositeMapLayer { 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]!.bus.heading - busAnimationCache[busId]!.prevBus!.heading) + busAnimationCache[busId]!.prevBus!.heading; + interpolatedHeading = animatedPercentage * (busAnimationCache[busId]!.toHeading! - busAnimationCache[busId]!.fromHeading!) + busAnimationCache[busId]!.fromHeading!; } } @@ -505,7 +516,7 @@ class LiveBusesLayer extends CompositeMapLayer { // debugPrint("****** Got animation tick!"); updateAnimation(); - onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) + if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) }); animation.addStatusListener((AnimationStatus status) { @@ -552,6 +563,7 @@ class LiveBusesLayer extends CompositeMapLayer { 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; @@ -609,6 +621,10 @@ class LiveBusesLayer extends CompositeMapLayer { } 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 @@ -617,14 +633,791 @@ class JourneyLayer extends CompositeMapLayer { 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) { debugPrint("****** got setOnUpdate call!"); 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) { + for (BusRouteLine l in routes) { + routesCache[l.routeId] = l; + } + } + + + + // 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; + } + + // 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; + } + } + 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, + ) { + debugPrint("extractRouteSegment call!!!"); + final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); + final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); + debugPrint("*** sRes = ${sRes}, eRes = ${eRes}"); + 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; + + debugPrint("We have valid coords!"); + + 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); + + BusRouteLine? line = routesCache[leg.rt]; + + 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); + } + + debugPrint("Trying to add markers"); + // 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 + debugPrint("Can add start/end markers!"); + + 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), + ), + ); + + // markers.add( + // Marker( + // flat: true, + // markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), + // position: segment?.first ?? startLatLng, + // icon: + // _getOn ?? + // BitmapDescriptor.defaultMarkerWithHue( + // colorToHue(RouteColorService.getRouteColor(leg.rt!)), + // ), + // ), + // ); + } + 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); + + debugPrint("**** Adding walking leg markers! from ${startLatLng} to ${endLatLng}"); + + // 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 + + // TODO: Handle these edge cases + + // 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 + // } + // } + + + // 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); + } + + // 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]; + // } + + 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' + + debugPrint("************ got setJourney call"); + + // 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.originID == "VIRTUAL_ORIGIN" && leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { + // addRouteStartMarker(leg.pathCoords!.first, journey); + // } + 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); + + // 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, + // ); + // polylines.add(polyline); + + // // add stop markers at endpoints of the segment (boarding/getting off) + // markers.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 { + + addWalkingLegMarkersAndPolylines(leg, journey, legIndex); + // TODO: Add support for these edge cases + + // // 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(liveBusesLayer.createBusMarker(bus)); + // } + // } + + // // Final debug check + // // Journey display complete (silently updated internal state) + + // setState(() { + // _updateAllDisplayedMarkers(); + // }); + + // // 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), + // ), + // ); + // } + // } + // } + + if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update + } + + void clearJourney() { + markers.clear(); + polylines.clear(); + if (isVisible) onUpdate(); + } - } class CompositeMapWidget extends StatefulWidget { @@ -642,35 +1435,17 @@ class CompositeMapWidget extends StatefulWidget { final LatLng initialCenter; final List mapLayers; + final Function(GoogleMapController) onMapCreated; // TODO: Implement these methods - // 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), - // ); - // } - // } - // } // final UniversalMapController universalController; CompositeMapWidget({ required this.initialCenter, - required this.mapLayers + required this.mapLayers, + required this.onMapCreated }); @override @@ -695,9 +1470,22 @@ class CompositeMapWidgetState extends State with SingleTicke setState(() {}); // Rebuild with updated markers } + // 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); if (layer is LiveBusesLayer) { @@ -750,8 +1538,15 @@ class CompositeMapWidgetState extends State with SingleTicke target: widget.initialCenter, zoom: 15.0, ), - onMapCreated:(controller) { + 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); }, ) ); @@ -768,4 +1563,26 @@ class CompositeMapWidgetState extends State with SingleTicke } } -} \ No newline at end of file +} + +// 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? \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 78e9dc9..8985e3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: flutter_staggered_animations: ^1.1.1 youtube_player_flutter: ^9.1.3 screen_corner_radius: ^3.0.0 + widget_to_marker: ^1.0.6 dev_dependencies: flutter_test: From cea197adec5ccf8797a0e0b38ef4c12a069ab8a8 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 6 May 2026 21:27:14 -0400 Subject: [PATCH 031/121] Fixed the extra-long detour bug --- lib/bluebus_api.dart | 51 ++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index e2ef6f7..aab6ded 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -12,7 +12,7 @@ import 'services/route_color_service.dart'; // (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 / π + const double radToDeg = 57.29577951308232; // 180 / π double dLat = lat2 - lat1; double dLon = lon2 - lon1; @@ -46,9 +46,9 @@ class BlueBusApi { for (final subroute in subroutes) { final points = []; final stops = []; - + // Cast to list to be able to be able to get different elements - final pointList = subroute['pt'] as List; + final pointList = subroute['pt'] as List; for (int i = 0; i < pointList.length; i++) { final point = pointList[i]; @@ -61,7 +61,7 @@ class BlueBusApi { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + if (isLast) { // use the previous 2 points to calculate rotation double stopRotation = pointRotation( pointList[i - 2]['lat']?.toDouble() ?? 0, @@ -70,7 +70,6 @@ class BlueBusApi { 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( @@ -81,7 +80,6 @@ class BlueBusApi { ); stops.add(BusStop.fromJson(point, routeId, stopRotation, false)); } - } } @@ -105,11 +103,12 @@ class BlueBusApi { final detourStops = []; // Cast to list to be able to be able to get different elements - final detourPointList = subroute['dtrpt'] as List; + 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 + final isLast = + i == detourPointList.length - 1; // bool to check if last detourPoints.add( LatLng( @@ -119,25 +118,28 @@ class BlueBusApi { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + 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, + 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), ); - detourStops.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, + 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), ); - detourStops.add(BusStop.fromJson(point, routeId, stopRotation, false)); } } } @@ -160,7 +162,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 = []; @@ -185,10 +189,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 From 77a6756646181e8d695cd26e36f55166a0b8e479 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 10 May 2026 21:19:29 -0400 Subject: [PATCH 032/121] Finally fixed (hopefully) invalid markers --- ios/Runner.xcodeproj/project.pbxproj | 12 +- lib/constants.dart | 215 ++++---- lib/services/map_image_service.dart | 77 ++- lib/widgets/composite_map_widget.dart | 698 ++++++++++++++------------ pubspec.yaml | 6 +- 5 files changed, 567 insertions(+), 441 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index cda52c7..e1a37b4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -494,7 +494,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 4LLPM7NY5C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -505,7 +505,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.ishankumar.maizebus; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -690,7 +690,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 4LLPM7NY5C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -701,7 +701,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.ishankumar.maizebus; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -723,7 +723,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 4LLPM7NY5C; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -734,7 +734,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.ishankumar.maizebus; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/lib/constants.dart b/lib/constants.dart index 2558280..f97ec1d 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -2,16 +2,18 @@ import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; // UPDATE WHEN RELAUNCH -final String currentVersion = '2.0.0'; +final String currentVersion = '2.0.1'; 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/"); @@ -54,7 +56,10 @@ 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(); + return rawStopName + .replaceAll('%', '') + .replaceAll(_whitespacePattern, ' ') + .trim(); } String getPrettyRouteName(String code) { @@ -78,26 +83,39 @@ 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 + mapWalkingLine, // Color for the walking line on the map } const Map lightColors = { @@ -105,24 +123,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: Color.fromARGB(255, 11, 83, 148), + 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, @@ -132,7 +155,7 @@ const Map lightColors = { ColorType.secondaryButtonBackground: Color.fromARGB(255, 215, 228, 241), ColorType.secondaryButtonText: maizeBusBlue, - ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97) + ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97), }; const Map darkColors = { @@ -140,26 +163,31 @@ 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), @@ -167,7 +195,7 @@ const Map darkColors = { 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.mapWalkingLine: Color.fromARGB(255, 178, 219, 255), }; // returns true if the current theme is dark mode @@ -203,7 +231,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; } @@ -218,12 +246,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, @@ -232,13 +260,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], ); } @@ -248,38 +276,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; @@ -310,7 +339,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 @@ -323,15 +358,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( @@ -341,43 +374,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 @@ -406,17 +431,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 { @@ -425,7 +448,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 { @@ -436,11 +465,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/services/map_image_service.dart b/lib/services/map_image_service.dart index c957f45..ae46f00 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -6,12 +6,12 @@ import 'dart: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:google_maps_flutter/google_maps_flutter.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; class MapImageService { - // Route specific bus icons static Map _routeBusIcons = {}; static BitmapDescriptor? _busIcon; @@ -38,27 +38,32 @@ class MapImageService { // Check if cached assets need to be refreshed based on backend version static Future _shouldRefreshCachedAssets() async { + debugPrint(" HELLO THIS IS _shouldRefreshCachedAssets()"); int frontEndVer; frontEndVer = await getFrontEndImageVer(); try { final backendImageVersion = await _getBackendImageVersion(); if (backendImageVersion == null) { + debugPrint(" Couldn't reach server! Forcing a refresh"); return true; // if you can't reach the server give up } if (int.parse(backendImageVersion) == frontEndVer) { + debugPrint(" Images are up-to-date, no refresh needed"); return false; } else { + debugPrint(" New images available, forcing a refresh"); await setFrontEndImageVer(int.parse(backendImageVersion)); return true; } } catch (e) { // On error, assume refresh needed + debugPrint("_shouldRefreshCachedAssets error: ${e.toString()}"); return true; } } - // Get minimum supported version from backend + // Get minimum supported version from backend static Future _getBackendImageVersion() async { try { final response = await http.get( @@ -69,6 +74,7 @@ class MapImageService { return data['bus_image_version'] as String?; } } catch (e) { + debugPrint(" getBackendImageVersion error: $e"); // Return null on error - will trigger refresh } return null; @@ -89,7 +95,7 @@ class MapImageService { return null; } - // Save bus icon to cache + // Save bus icon to cache static Future _cacheBusIcon(String routeId, Uint8List bytes) async { try { final prefs = await SharedPreferences.getInstance(); @@ -100,8 +106,9 @@ class MapImageService { } } - // Set a fallback bus icon for a route + // Set a fallback bus icon for a route static void _setFallbackBusIcon(String routeId) { + debugPrint(" Setting fallback bus icon for route ${routeId}"); try { final routeColor = RouteColorService.getRouteColor(routeId); _routeBusIcons[routeId] = BitmapDescriptor.defaultMarkerWithHue( @@ -112,7 +119,7 @@ class MapImageService { } } - // Load a specific route's bus icon + // Load a specific route's bus icon static Future _loadRouteBusIcon(String routeId, String imageUrl) async { try { final response = await http.get(Uri.parse(imageUrl)); @@ -163,21 +170,37 @@ class MapImageService { await RouteColorService.initialize(); } + debugPrint(" About to set shouldRefreshAssets variable"); // Check if we need to update cached assets based on version final shouldRefreshAssets = await _shouldRefreshCachedAssets(); + debugPrint(" Finished setting shouldRefreshAssets variable"); final routeIds = RouteColorService.definedRouteIds; for (final routeId in routeIds) { + debugPrint( + "Loading icon for route ${routeId}. Should refresh assets? $shouldRefreshAssets", + ); + + // VERY SOON TODO: Uncomment this to make sure it doesn't try to load icons that are alerady in the cache? + // if (_routeBusIcons.containsKey(routeId)) { + // debugPrint("* Icon already exists, no need to fetch it again!"); + // continue; + // } + // Try to load from cache first if not forcing refresh if (!shouldRefreshAssets) { + debugPrint(" * Attempting to load from cache"); final cachedIcon = await _loadCachedBusIcon(routeId); if (cachedIcon != null) { + debugPrint(" * Cache hit!"); _routeBusIcons[routeId] = cachedIcon; continue; } } + debugPrint(" * Loading from backend..."); + // Load from backend if cache miss or forcing refresh final imageUrl = RouteColorService.getRouteImageUrl(routeId); if (imageUrl != null) { @@ -194,14 +217,28 @@ class MapImageService { } } - static void ensureRouteIconIsLoaded(String routeId) { + 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) { - _loadRouteBusIcon(routeId, imageUrl); - } + // 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 @@ -234,25 +271,29 @@ class MapImageService { 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 { - return BitmapDescriptor.defaultMarkerWithHue( - colorToHue(routeColor), + debugPrint( + "WARN: getBusIcon found no icon currently loaded, returning defaultMarkerWithHue", ); + return BitmapDescriptor.defaultMarkerWithHue(colorToHue(routeColor)); } } - - static Future loadData() async { await _loadRouteSpecificBusIcons(); } - -} \ No newline at end of file +} diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index ebcd41f..3bd3d78 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -39,8 +39,6 @@ import 'package:widget_to_marker/widget_to_marker.dart'; // ); // } - - // TODO: Add a Z-index to each thing in each CompositeMapLayer // to explicitly define how things should be ordered @@ -54,6 +52,7 @@ abstract class CompositeMapLayer { void setOnUpdate(Function() fn); void dispose() {} } + // TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff class BaseRoutesLayer extends CompositeMapLayer { @override @@ -78,7 +77,8 @@ class BaseRoutesLayer extends CompositeMapLayer { BitmapDescriptor? _favStopIcon; BitmapDescriptor? _favRideStopIcon; - Map> markersCache = {}; // TODO: Merge this with polylines variable? + Map> markersCache = + {}; // TODO: Merge this with polylines variable? Map polylinesCache = {}; void setOnUpdate(Function() callback) { @@ -86,9 +86,11 @@ class BaseRoutesLayer extends CompositeMapLayer { onUpdate = callback; } - void init(Set favoriteStops_in, + void init( + Set favoriteStops_in, Set selectedRoutes_in, - Function(BusStop) onStopClicked_in) { + Function(BusStop) onStopClicked_in, + ) { favoriteStops = favoriteStops_in; selectedRoutes = selectedRoutes_in; onStopClicked = onStopClicked_in; @@ -148,27 +150,30 @@ class BaseRoutesLayer extends CompositeMapLayer { markersCache.clear(); for (final r in routesCache) { - if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes + 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 (!markersCache.containsKey(routeKey)) { // Prevent duplicate copies of the same stop on top of each other + if (!markersCache.containsKey(routeKey)) { + // Prevent duplicate copies of the same stop on top of each other markersCache[routeKey] = {}; - for (final stop in r.stops) { // iterate through all stops in this route + for (final stop in r.stops) { + // iterate through all stops in this route // TODO: Implement favorite stops // final isFavorite = _favoriteStops.contains(stop.id); - + final marker = Marker( - zIndexInt: 10, // Put bus stops on top of buses - markerId: MarkerId( - 'stop_${stop.id}_${Object.hashAll(r.points)}', - ), + zIndexInt: + 2000, // Put bus stops on top of buses, since all bus Z-indexes are between 0 and 999 + markerId: MarkerId('stop_${stop.id}_${Object.hashAll(r.points)}'), position: stop.location, flat: true, // icon: BitmapDescriptor.defaultMarker, - icon: favoriteStops.contains(stop.id) // Used to be isFavorite + icon: + favoriteStops.contains(stop.id) // Used to be isFavorite ? (stop.isRide ? _favRideStopIcon ?? BitmapDescriptor.defaultMarkerWithHue( @@ -198,7 +203,7 @@ class BaseRoutesLayer extends CompositeMapLayer { markersCache[routeKey]?[stop.id] = marker; - // gets first marker of this stop and adds it to the favorited stop markers + // gets first marker of this stop and adds it to the favorited stop markers // if (isFavorite && !_displayedFavoriteStopMarkers.containsKey(stop.id)) { // _displayedFavoriteStopMarkers[stop.id] = marker; // } @@ -214,11 +219,11 @@ class BaseRoutesLayer extends CompositeMapLayer { } void reloadPolylines() { - polylinesCache.clear(); for (final r in routesCache) { - if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes + 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)}'; @@ -239,7 +244,6 @@ class BaseRoutesLayer extends CompositeMapLayer { } polylines = polylinesCache.values.toSet(); - } void cacheRoutes(List routes) { @@ -257,14 +261,13 @@ class BaseRoutesLayer extends CompositeMapLayer { if (isVisible) onUpdate(); } - - } class BusAnimationState { - Bus? prevBus; // Used to animate from the previous position to current position + Bus? + prevBus; // Used to animate from the previous position to current position Bus bus; - BitmapDescriptor busIcon; + // BitmapDescriptor busIcon; MarkerId markerId; int lastUpdated = 0; @@ -277,14 +280,15 @@ class BusAnimationState { BusAnimationState({ required this.bus, - required this.busIcon, + // required this.busIcon, required this.markerId, - this.lastUpdated = 0 + this.lastUpdated = 0, }) { toHeading = bus.heading; toPosition = bus.position; } } + class LiveBusesLayer extends CompositeMapLayer { @override bool isVisible = true; @@ -297,7 +301,6 @@ class LiveBusesLayer extends CompositeMapLayer { debugPrint("Error: onUpdate called but callback was not registered!"); }; - @override Set polylines = {}; @@ -306,16 +309,16 @@ class LiveBusesLayer extends CompositeMapLayer { 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 + 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 + Map busAnimationCache = + {}; // Maps Bus ID -> BusAnimationState Function(Bus b) onBusClicked = (Bus b) { debugPrint("Error: onBusClicked callback was called but never intiialized"); @@ -329,20 +332,22 @@ class LiveBusesLayer extends CompositeMapLayer { void initWithTickerProvider(TickerProvider tickerProviderIn) { debugPrint("******* Initting with animation controller!!"); tickerProvider = tickerProviderIn; - controller = AnimationController(duration: const Duration(milliseconds: ANIMATION_DURATION), vsync: tickerProvider!); - + controller = AnimationController( + duration: const Duration(milliseconds: ANIMATION_DURATION), + vsync: tickerProvider!, + ); } - void init(List buses_in, + void init( + List buses_in, Set selectedRoutes_in, - Function(Bus b) onBusClicked_in) { + Function(Bus b) onBusClicked_in, + ) { buses = buses_in; selectedRoutes = selectedRoutes_in; onBusClicked = onBusClicked_in; - - - MapImageService.loadData(); + // 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) { @@ -360,133 +365,157 @@ class LiveBusesLayer extends CompositeMapLayer { } void updateAnimation() { - // debugPrint("* updateAnimation call! busAnimationCache has ${busAnimationCache.keys.length} keys"); // debugPrint(" Animation value is ${animation.value}"); // debugPrint("* selectedRoutes is ${selectedRoutes}"); DateTime now = DateTime.now(); - markers = busAnimationCache.keys.where((String busId) { - // debugPrint("Checking to see if we should add marker ${busAnimationCache[busId]?.bus.routeId}: ${selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId)}"); - return selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId); - }) - .map((String busId) { - LatLng interpolatedPosition; - // debugPrint("Adding marker for ${busId}"); - double interpolatedHeading = busAnimationCache[busId]!.bus.heading; - double animatedPercentage = min((now.millisecondsSinceEpoch - busAnimationCache[busId]!.lastUpdated) / ANIMATION_DURATION, 1.0); - - // debugPrint("animatedPercentage is ${animatedPercentage.toStringAsFixed(2)}"); - - 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 + markers = busAnimationCache.keys + .where((String busId) { + // debugPrint("Checking to see if we should add marker ${busAnimationCache[busId]?.bus.routeId}: ${selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId)}"); + return selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId); + }) + .map((String busId) { + LatLng interpolatedPosition; + // debugPrint("Adding marker for ${busId}"); + double interpolatedHeading = busAnimationCache[busId]!.bus.heading; + double animatedPercentage = min( + (now.millisecondsSinceEpoch - + busAnimationCache[busId]!.lastUpdated) / + ANIMATION_DURATION, + 1.0, ); - 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 + // debugPrint("animatedPercentage is ${animatedPercentage.toStringAsFixed(2)}"); + + 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 + } - double headingDelta = (busAnimationCache[busId]!.fromHeading! - busAnimationCache[busId]!.toHeading!); + if ((headingDelta).abs() < 120) { + // Don't animate heading changes of more than 120 degrees to avoid weird spinning if the bus turns 180 - if (headingDelta.abs() > (360 + headingDelta).abs()) { - // Might need to fix this - headingDelta = 360 + headingDelta; // Turn the tightest direction possible + interpolatedHeading = + animatedPercentage * + (busAnimationCache[busId]!.toHeading! - + busAnimationCache[busId]!.fromHeading!) + + busAnimationCache[busId]!.fromHeading!; + } } - 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; - busAnimationCache[busId]?.lastInterpolatedHeading = interpolatedHeading; - busAnimationCache[busId]?.lastInterpolatedPosition = interpolatedPosition; - - return Marker( - flat: true, - zIndexInt: 1, - markerId: busAnimationCache[busId]!.markerId, - consumeTapEvents: true, - position: interpolatedPosition, - icon: busAnimationCache[busId]!.busIcon, - rotation: interpolatedHeading, - anchor: const Offset(0.5, 0.5), // Center the icon on the position - onTap: () { - try { - Haptics.vibrate(HapticsType.light); - } catch (e) {} - onBusClicked(busAnimationCache[busId]!.bus); - // _showBusSheet(bus.id); - }, - ); + 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: () { + try { + Haptics.vibrate(HapticsType.light); + } catch (e) {} + onBusClicked(busAnimationCache[busId]!.bus); + // _showBusSheet(bus.id); + }, + ); - // return Marker(); - }).toSet(); + // return Marker(); + }) + .toSet(); // debugPrint("***** Finished updateAnimation() call, we now have ${markers.length} markers"); // markers = buses - // 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(); + // 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) { + if (animationStartedTime + ANIMATION_DURATION > + now.millisecondsSinceEpoch) { return; // Prevent starting the same animation twice if startAnimation() gets multiple calls } @@ -498,7 +527,6 @@ class LiveBusesLayer extends CompositeMapLayer { // 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???? @@ -512,11 +540,13 @@ class LiveBusesLayer extends CompositeMapLayer { // debugPrint("tick"); DateTime now = DateTime.now(); if (now.millisecondsSinceEpoch < nextAnimationFrameTime) return; - nextAnimationFrameTime = now.millisecondsSinceEpoch + FRAME_DURATION; // 100ms frametimes + nextAnimationFrameTime = + now.millisecondsSinceEpoch + FRAME_DURATION; // 100ms frametimes // debugPrint("****** Got animation tick!"); updateAnimation(); - if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) + if (isVisible) + onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) }); animation.addStatusListener((AnimationStatus status) { @@ -528,12 +558,12 @@ class LiveBusesLayer extends CompositeMapLayer { controller?.forward(); controller?.repeat(); - - + debugPrint("***** Finished starting animation"); } - void reload() { // Called when parent has new live bus GPS data to tell us about! + 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; @@ -541,90 +571,104 @@ class LiveBusesLayer extends CompositeMapLayer { 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) { - // debugPrint(">>>> Bus position has not changed! Skipping animation for ${bus.id}"); - // If the bus position hasn't changed and the bus was updated recently, skip it! - return; - } - - busAnimationCache[bus.id]!.lastUpdated = now.millisecondsSinceEpoch; - + 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) { + // debugPrint(">>>> Bus position has not changed! Skipping animation for ${bus.id}"); + // If the bus position hasn't changed and the bus was updated recently, skip it! + return; + } - busAnimationCache[bus.id]?.prevBus = busAnimationCache[bus.id]?.bus; - busAnimationCache[bus.id]?.bus = bus; - busAnimationCache[bus.id]?.busIcon = MapImageService.getBusIcon(bus); + busAnimationCache[bus.id]!.lastUpdated = now.millisecondsSinceEpoch; - 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; + busAnimationCache[bus.id]?.prevBus = busAnimationCache[bus.id]?.bus; + busAnimationCache[bus.id]?.bus = bus; + // busAnimationCache[bus.id]?.busIcon = MapImageService.getBusIcon(bus); - } 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]?.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 - ); - } - }); + 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: 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(); } - } 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 @@ -634,7 +678,9 @@ class JourneyLayer extends CompositeMapLayer { @override Function() onUpdate = () {}; - Function(String s) _showBusSheet = (String s) {debugPrint("Error: _showBusSheet was called but callback was never set");}; + Function(String s) _showBusSheet = (String s) { + debugPrint("Error: _showBusSheet was called but callback was never set"); + }; BitmapDescriptor? _getOn; BitmapDescriptor? _getOff; @@ -654,7 +700,12 @@ class JourneyLayer extends CompositeMapLayer { _mapController = mapController_in; } - void init(Function(String s) showBusSheet_in, Set activeJourneyBusIds_in, Set activeJourneyRoutes_in, BuildContext context_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 @@ -664,12 +715,20 @@ class JourneyLayer extends CompositeMapLayer { } 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')); + _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) { debugPrint("****** got setOnUpdate call!"); onUpdate = callback; @@ -681,7 +740,7 @@ class JourneyLayer extends CompositeMapLayer { // 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, @@ -704,8 +763,6 @@ class JourneyLayer extends CompositeMapLayer { } } - - // Haversine distance between two LatLngs in meters double _haversineDistanceMeters(LatLng a, LatLng b) { const R = 6371000; // Earth radius in meters @@ -775,8 +832,11 @@ class JourneyLayer extends CompositeMapLayer { } } - - Future addBusLegMarkersAndPolylines(Leg leg, Journey journey, int legIndex) async { + 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 @@ -791,7 +851,11 @@ class JourneyLayer extends CompositeMapLayer { final LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); if (startLatLng != null && endLatLng != null && line?.points != null) { - List? segment = _extractRouteSegment(line!.points, startLatLng, endLatLng); + List? segment = _extractRouteSegment( + line!.points, + startLatLng, + endLatLng, + ); if (segment == null) { debugPrint("ERROR: Line segment is null!"); @@ -826,7 +890,9 @@ class JourneyLayer extends CompositeMapLayer { // Making sure the marker has a valid location debugPrint("Can add start/end markers!"); - BitmapDescriptor iconBitmap = await RouteIcon.small(leg.rt!).toBitmapDescriptor(); + 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) @@ -838,7 +904,6 @@ class JourneyLayer extends CompositeMapLayer { icon: // _getOn ?? iconBitmap ?? - BitmapDescriptor.defaultMarkerWithHue( colorToHue(RouteColorService.getRouteColor(leg.rt!)), ), @@ -876,18 +941,21 @@ class JourneyLayer extends CompositeMapLayer { // ); } } - - - } - void addWalkingLegMarkersAndPolylines(Leg leg, Journey journey, int legIndex) { + 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); - debugPrint("**** Adding walking leg markers! from ${startLatLng} to ${endLatLng}"); + debugPrint( + "**** Adding walking leg markers! from ${startLatLng} to ${endLatLng}", + ); // Walking leg information @@ -926,7 +994,6 @@ class JourneyLayer extends CompositeMapLayer { // } // } - // 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) { @@ -995,7 +1062,9 @@ class JourneyLayer extends CompositeMapLayer { jointType: JointType.round, polylineId: PolylineId('walking_${journey.hashCode}_$legIndex'), points: pathCoords, - color: (context != null) ? getColor(context!, ColorType.mapWalkingLine) : Colors.black, // Walk line color + color: (context != null) + ? getColor(context!, ColorType.mapWalkingLine) + : Colors.black, // Walk line color width: 8, // line width patterns: [ PatternItem.dot, @@ -1005,7 +1074,6 @@ class JourneyLayer extends CompositeMapLayer { ); polylines.add(walkingPolyline); - } void addRouteStartMarker(LatLng position, Journey journey) { @@ -1016,9 +1084,7 @@ class JourneyLayer extends CompositeMapLayer { position: position, icon: _start ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueGreen, - ), + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), ), ); } @@ -1027,20 +1093,17 @@ class JourneyLayer extends CompositeMapLayer { markers.add( Marker( flat: true, - markerId: MarkerId( - 'journey_final_destination_${journey.hashCode}', - ), + markerId: MarkerId('journey_final_destination_${journey.hashCode}'), position: position, icon: - _destination ?? - BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueRed, - ), + _destination ?? + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), ), ); } - void setJourney(Journey journey, Color walkLineColor) { // Don't stop believin' + void setJourney(Journey journey, Color walkLineColor) { + // Don't stop believin' debugPrint("************ got setJourney call"); @@ -1060,7 +1123,9 @@ class JourneyLayer extends CompositeMapLayer { // if (leg.originID == "VIRTUAL_ORIGIN" && leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { // addRouteStartMarker(leg.pathCoords!.first, journey); // } - if (leg.destinationID == "VIRTUAL_DESTINATION" && leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { + if (leg.destinationID == "VIRTUAL_DESTINATION" && + leg.pathCoords != null && + leg.pathCoords!.isNotEmpty) { addRouteEndMarker(leg.pathCoords!.last, journey); } @@ -1069,7 +1134,6 @@ class JourneyLayer extends CompositeMapLayer { // Determine leg type for processing if (isBusLeg) { - addBusLegMarkersAndPolylines(leg, journey, legIndex); // Add route ID and vehicle ID to active sets for bus filtering @@ -1157,44 +1221,43 @@ class JourneyLayer extends CompositeMapLayer { 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); - // } + // 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 { - addWalkingLegMarkersAndPolylines(leg, journey, legIndex); // TODO: Add support for these edge cases @@ -1417,7 +1480,6 @@ class JourneyLayer extends CompositeMapLayer { polylines.clear(); if (isVisible) onUpdate(); } - } class CompositeMapWidget extends StatefulWidget { @@ -1437,33 +1499,29 @@ class CompositeMapWidget extends StatefulWidget { final List mapLayers; final Function(GoogleMapController) onMapCreated; -// TODO: Implement these methods - - + // TODO: Implement these methods + // final UniversalMapController universalController; - + CompositeMapWidget({ required this.initialCenter, required this.mapLayers, - required this.onMapCreated + required this.onMapCreated, }); - + @override State createState() { // TODO: implement createState return CompositeMapWidgetState(); } - - } - -class CompositeMapWidgetState extends State with SingleTickerProviderStateMixin { +class CompositeMapWidgetState extends State + with SingleTickerProviderStateMixin { GoogleMapController? _mapController; Set allMarkers = {}; Set allPolylines = {}; - void reloadMap() { // debugPrint("******* Got reloadMap() call!"); // _mapController. @@ -1492,7 +1550,6 @@ class CompositeMapWidgetState extends State with SingleTicke layer.initWithTickerProvider(this); } }); - } @override @@ -1501,20 +1558,18 @@ class CompositeMapWidgetState extends State with SingleTicke // if (!layer.isVisible) return; // allallMarkers.union(other) // }); - 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(); - - // allmarkers = + 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(); - // debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); - + // allmarkers = + // debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); return RepaintBoundary( child: GoogleMap( @@ -1525,12 +1580,18 @@ class CompositeMapWidgetState extends State with SingleTicke myLocationButtonEnabled: false, markers: allMarkers, polylines: allPolylines, - // controller: + // controller: 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, + -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), @@ -1539,7 +1600,7 @@ class CompositeMapWidgetState extends State with SingleTicke zoom: 15.0, ), style: isDarkMode(context) ? _darkMapStyle : _lightMapStyle, - onMapCreated:(GoogleMapController controller) { + onMapCreated: (GoogleMapController controller) { _mapController = controller; widget.mapLayers.forEach((CompositeMapLayer layer) { if (layer is JourneyLayer) { @@ -1548,7 +1609,7 @@ class CompositeMapWidgetState extends State with SingleTicke }); widget.onMapCreated(controller); }, - ) + ), ); } @@ -1561,7 +1622,6 @@ class CompositeMapWidgetState extends State with SingleTicke for (CompositeMapLayer l in widget.mapLayers) { l.dispose(); } - } } @@ -1585,4 +1645,4 @@ class CompositeMapWidgetState extends State with SingleTicke // 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? \ No newline at end of file +// POSSIBLE OPTIMIZATION: Only run animation updates for buses that are visible in the viewport? diff --git a/pubspec.yaml b/pubspec.yaml index 8985e3c..a84596b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: bluebus description: "A new Flutter project." -publish_to: 'none' -version: 2.0.0+6 +publish_to: "none" +version: 2.0.1+7 environment: sdk: ^3.8.0 @@ -15,7 +15,7 @@ dependencies: 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 From 891f6912efbfe0712885a40d04b53a52e65a6c85 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 10 May 2026 22:06:45 -0400 Subject: [PATCH 033/121] Cleaned up code Made each layer a separate file and cleaned up some extra comments and debugPrints --- assets/getOff.png | Bin 5991 -> 2228 bytes assets/getOn.png | Bin 5738 -> 2254 bytes lib/screens/map_screen.dart | 568 +------ lib/services/map_image_service.dart | 23 - .../map_layers/base_routes_layer.dart | 198 +++ lib/services/map_layers/journey_layer.dart | 392 +++++ lib/services/map_layers/live_buses_layer.dart | 389 +++++ lib/widgets/composite_map_widget.dart | 1480 +---------------- 8 files changed, 1060 insertions(+), 1990 deletions(-) create mode 100644 lib/services/map_layers/base_routes_layer.dart create mode 100644 lib/services/map_layers/journey_layer.dart create mode 100644 lib/services/map_layers/live_buses_layer.dart diff --git a/assets/getOff.png b/assets/getOff.png index 814b15e2e5e484c5208678b120fe0f259ee3f85a..6ea4ffa4d2b708102b8f01e8ae3a5f7b39a41d98 100644 GIT binary patch delta 2196 zcmV;F2y6G}F0>JlIDY^Eb5ch_0Itp)=>Px#1ZP1_K>z@;j|==^1pojDNl8RORCodH zoK0*KM-<23tZhmO6iY1>wG!F%5=2B24sfU%$+V&>g{px7Dgue*1~C!jP-wZ3TtIMv zikMPWYTPPOff5J;ia1b=;82l6lOS49CE_4~)CQyt!4ycm(|>tmJ96w9?}yj+HtJ6T zJD%BS{m+{>Z)VSq-J!DvVR)>BSh(^IvoHH^v8Y* z_Ct{%P(b|ko&IretPv=D3S0!@hrFN~1tmf#ob%04BHD@VQ78}v5oY%KqXA0EKK3>M z1%v|W^??5USbtATFF1^&!j!EGH3Fe1hzi9e3Y4_R&IH5Y5RM3ww2)3Hz95-6CQx3& z%&h-f^?2eaNB~>~3Hy9Xdp>XA5Y|dVgB&T90L6_)Me3_OUB>nd;BP z*c_;YHOt>&f-oW2&vvOf55~WGj|bHj>OJ1w}M4136mT;XW83YqrP=j zZ^Pyli{O)G6?usvqbqU;4xGAaR-k^L&$HIb&@wGdQk1J;;9$$=&w=B?54bSJz=A+g zCe8}8;(wSD#*8R9WSUo3LvVFXK`g|G{Y;$wr}~V#nHI-T7&D@vm7Li9F?Xh=mVIU$?(2Y){dg=-MC!XhOx(}wU{FdC!DvD-_a?w{&Qx>5mB2^n1o-M_)QkP$wWDS z^B!yu{bDr`6Y}(Q5S+k39F6&#r6PZ$h#&J9VbCe_cfEkP(|4)>W$Wd(d2XitypHs$$e@L7Y5@Nc0?hJaHos4q_bH* zeFyyYhk2O7h4CnQK-A04*0L>h1-5lu0XyW{BO3Sm8u672Lp*rylTF=XcR9}#D1S;9 z+sVVd7(3!57MqpgMw-xq3&Vy{K-4FP$(0P8Zc&sJcE!Q1RtbD}QsKv@EHut+MOEE4 z-6o(}pV$#6$NsY)HBp!=4#+NaBN$I%u$hTfaZnW5cVxq;Rv}az>DE=f1#ZPDaqt*N z1l9Fgg`NM_2rqI`I4{Bpaqw(Qgntf1RR{s$_QKp*#oy4jO1|PODHs3o*(Ozumr}7X z3+G9f>5k#lPG4QsLw*>S4vK>u-yvNB@RJQ0xQJ9m4dq4H+}_i9BkKz}iIF%9<$E-$ z0@ED3an7_ChGQdjVcH9W`@K1~8l<3)iGsk-U@AtT{S5B%iFW_2a_r(D-+zqH#7LC; z^6l8D^rUR`*-I!iPQ*&McCsT1GCcA`)}$#!A;J_LcDIrmX9Xm>gk`s>6X`B)Tky;EP z5ha<1u4T6I#K0l<`I*sJc7NuE9Z@jimiQPAHA%%WLaVvZcMseKrk$jpMca*2y3=+6 z(_!0S*YGtlT@yIRJ_%%Y>biQDuVCG_(oPq`jwlFbeu^NJ@br6bF}{iMu&5K7+WDJ9 z;6_joyP|Z-P9qsuyn(M=7_GMzeN05uQD$=gWey0OH^PR|xm2#qrC%PnP#gT`N)2F3P(@g+mtx zEM+|8yQC<@(GA8x(mWpg?rCK0RFpMzptn^sKgZ*SOYQZ-BL|Fk-u?U}IEhm^cqwot zy}0=YS#vbjv!f#YgMY0380Kr5L|5Vrwdv5$!XbsL-z;UT{uANt3M5D}U!U@$819N7zZ_PFZ^{ zaY~JmCr(%i3HW94pJg9N|pxD}|NP zKTK#WR_-1SXMbF>l%(`8g_UB_LJGvUL;8ji6}9FTxP>bgO~+`qN(TkPBuC8W%V!Es z)nH#>XGqwon$mE;iy>6Gv(AelsQ5u9A_ z<;DMxNs&;H2kT%Z3l?q2>Jyv-hYa&2?M_%o*6)S_!hMjU2QVd{>R7Lk|D%`)A^o^% z?VBhITb*gFP*7nq;aTXE2H4q+%7z@K(fX6gk!nWQDE`MYF?1NFG~SO#$;Etl*T3fv W!d>~xEe+xT0000Km@!L9vhNv7RMb5iW0{#|MrKeUN<|5U8udgJBFWR9 zBvDyIlte`fS+XlF?>&;I=lkmIxxV-Nu5Yec?sLw6{hi zM*LtlC=G$EHsiAaN+68~CDZ&EOjFq4^;<9~gK7%ffOSGTu`Ou+4BIdc%`ME?oe~yE zAy8pvYoM$7M9@GmjR!#a!9h$ek#7o{wo3$`MbiivbXtTLXbLkIIfQySt%q8$I5a5E z2o0wokw_?xV1y!@U;!L~j4_0wk!UmmX@Wo*!_jCW5(U1YGcOqEkVB;tNtV_#?!b{L z%%8_&6A_5e&`_gLV24+e5S}Rv4E9?V!R%SHOzsR)AZ7?Yz($~qkfN-n1*w!-Id%vqXxcfIf}jP_f@w@1 z7nDWK%Ch}gJQmlV^$XLp%YSnKqU+=|>*J@f1P9N$;PR}tgK5kF@>6N9yMRqYkZ4?1 z2!}$m+75=P{XH8lk3{ z1%0Q_`tK-0(Z)tt=n5x*!eEMU)fZ(kqe8O;cr;TONF@_E5(7t}+)-E}5=}%KgF_+` z`AyV`MP<+he-}mL-BAQ0D1^oRO%$Xu72pBC6{b>%bQUKV0C#~A4EWIyY^EO!I=i<- z3sw+|13CtyGoFsZ$%$ykk*!$D(mK zj4=UCfI&t3O$5z=ApxQaGlijMCcQ+w3kvx*i9>@yArUCi_sqKYZ`S`>_FytVh(QQM zjQNJD8`!@#-%u^5pI$dEzfbuN`iin>yDr{z&@so}GXOMj}8|qI3!TL9>{U*j`(RrZ&hi2{v zBK!~P0`bf6SjGg`q98s5Ah$QgQS{}F*F(qPM~AKGQps+Z~$jahGQ{koCz95r{j&m ziuh?d|GoYEpQl6lEen3L@gi=_66M#hQE42;4?OshYqKFhr&;&E!0Xx_UZ`^W-qTaCRn>1 z@M=nW`k@{InWtxGY3|M!_6x(^JwlYL%zW4SZdfb!N{hN8F_Y}jgc&l=5#LV^6;@W# zHy*nZGo= zbL(~t`xlf3UWf@FXPx{!6*lmtzq4a1^kaMJj^hK7r0gCEOR=sD~@t2lgjXX2A7`bu_vb(Bp?zcYalri68-LiW%zkZB}u%EWAj&y*_FSj zSmB{2CBM)R`{~`w2K9DxxkRx(2pU|5+ZcqZqU?`UF7cXo&U`YDr&k00MD zepUteOZ&6#JEx<8n=_0pf5Yd^fN)V#%9b570!0yQ>$D#&8bVlzZ2I_b@X z8VRE8l(1Y|ux6+40Ees?nfgj{`AJm^VX;lX*0?QZ`f`7pB17{YdDn1FYJzkPEqIgk$ z-#rumNIe96--Y^ejS2?`a`(8u!dx!P=K2V>n_4fPRbjoeDBvj7@@aFUyZt6Xpcv8GxzzZ(gI;=dF7#teBzLAlFG9~$(e^9 zT?@La_6UE~=?z6S&J_Yyp(fPTak%uoxuZH(H8NQLjQ3UTCS4LSYpf;M*Uq%IZQ{_A zf@(Ez)r;xjgke7%h){tr$w>}=36jq@dMw-Zb@U3QK(eFbYx32%%`f=mOJ}PfQ4&h+ zTJ*xSlr#BqBf9gXpzp}~wFL_b)n;=R2X=u>G{PRvBjMF{5&mjymJ%l*%LQ`VDxTP z(<7s+C5IGjI{aURGx!5KwdsJZn3mpKPxx;h{T!m$Z!kU4J!j^RWTnYk`KgaE7_hl z)Q+Ayjy=1wXmo5h^~B0dP_Tw;RTH)*wmRFRnBvUKQ|>*QcsHOIfO-Wm*N)*_O~_Chc}5 zbU2y?+BJGBdxQ<~-aQ5+?q72c81+^sEV)0Ts`i$Gem!=}QCo{pyIjj-W3JzLdhoq1Zn>f z$^Db@!@XgPlA7>`xwYNLY96$?B&pVGw8`w1;wK+o?Q&EYq0p&7xMH@2<-EuT32w^r zFYtscvsBd`g(*g3eEYi6m6@9zP?(0j@!f(7y)&*6clg)WM_5$7Rnqh>aNE#X6Rk2n zG}MdNxQ1ICnWNl8Fpv-Ha9kgF+uF5fpgegf%lm|ULaNOcI|AZRsi8~U>-&=r`wy28 zN)Bs}cR${Ltx|dCE$r?cdc$Jf0WM*Fl4QpL-<4a6#J3J!BRHyQNJjQXvB|9rt5>8} zJ@lG&k00tfEqze)#;vAT`h#ATC+5$R;rlOwULRbTb;)u5BUP#Gy7}2L*L=t(c14i`C~?55V%> z8|9|>0d=Y$N274#8AmWHwvDK)#^R1Wz5F6!)L6ImE-$pQVXbCy=TS$rc%lz4*;}1t zDHqhGEq%&oRn>|?;U>c+pJ=Nj-c%H(c=qZWp4VxXmvoAohdivG*KmQ}>gr7qEA(t0 z?h9|^kgpY3?exZSO^!zU17EdS7b}%9a`V&Q43-?WjQzMs9eypm zNbbTzEpX75Z4t*JS%r^fuG?YfW7bN2)Q86@2wkDgXRR(5?X6S8nKHK+9jh;vJJ@h_ z{4*u9=iz&(W+Sdnu|Hz+bv)I=56*Tz)s(j>%7VOTiJn}1OPEdN;)BhC+_R0{7q3g) zu=ecHlA_s z&BCwkti$K9Y4QqX^|7J%WX3&`O3lNR99Eq`w0;nriHL4MwnKHt2BJo+cdc5rWJ8FL zP*}6cNb9M0Y61Vng*jeGhAMboIcg|WMBUmqnBG>@wesPZwB&-4tt+ndl=K>8+mA?P z^j?POanEm?-0T`2%xKD*xH$4ouB4=c?3iZeW-YyGb(FsLZablTW%?(MFk`V{`V!iZ z7lAJreYM-;qG6J0L}6U=)tptq65}F^$U+}IXhgQ7yrNRg7pIW|%;iuRlajYthg0aB z?>wiAxp!Vn`6Zcyn^({WCo;D^KQF8AE#RCMRz2VHhXiVm!B^#6*ZO{9K%rvs28+&y z8ntw&;-pPg6F1{#r$)?z0L2vdUhkVDai5jPcb#i?32jtHCIF1&T~ z-hiWPM;LayAARJ-WtCT!Exa`lda-YBNw2%)F75q(9fX?nh$lI{zOg4|4fwA!?r>mJ z^SLI`?$Dv->+;XY^r~(0ntYj>DQNB3^Qkz%eN$s@V2UHxUAyLyWM+lxN8gi)jh>`j zzq%aEa7}km*rMqJKZQQ<{4P4fbB9%5H1#dxZQ4$U-Y`G_H*Oik>L-p#e@YC^Sn_Uy zp3bVy+PmHPko!l^fLDN*$!;G;uXl7Me&sITJK61imC2TOZaSBuSk&!x z5XY9>_i~r|POB^=m70#RDgTe0JTqqTNSG)ac7!ZqS^1Dyl-l$D45;&qN}et_9U60c@P^}HMC@$?htK;|myR_a zz(2=^eaTi4%thQRT6Li>@pS&}M1{G7@jd(EJx{#oxUN*Tw7S(c-$L|PxSf@=W#PK5(fPx#1ZP1_K>z@;j|==^1pojDV@X6oRCodH zoL_8HRUF5E=iXM3@~4S{Mw6}cWdy>u7l;ooDKQ!mh=T#ajD#&QQ8yOYi$q?wy+GiF z8K|0=$RsQRG8l14d{9d8!LXMNL?I@uTY`yBlRHOoFnW*YcYoT>_TF>a-uB$~hWJTF zOV7E@?YF<*@BI1wP6?Ee!1hQ3BoYlo^VOS_h6vDxR8z>(HOBr7{3Apgr6wH%9Y*6{ zb&NupAmAdwrW3(AU%VA4yaikY5`>bV1_dojC|vYErA3X?@5jI;ToGpRwqs#RNjG~M z1{a|~hIBB$n131U90J8C6(+DX(h7v4AR#C(F`%S3e!in06rn_zl!cst@(W@`nLs58 zv#{xtkgg|CkT7@(((m_cy`{vUqJ??7IkHy+v&Tt54F(p#n$WXQ7kUQjYL@fo%N9Ka z)hm`}^_%$PZ^6D_WxqJcIzGtT)7R`e!j=VnRKjv_)hB<8k z&d{Z!mDH6|)B6*$b`_!^1;&QFprznFWm;~&_ckeX{d0a#pQugfN*Gr(4Toj2P}HtU z6a?ocIDsn_oCpq&jAu;`(j%kn{Rx4W1NEEF%>H!Au(oNU3v}J=md$88Hq|K;MKu_7 z-+zsKpY;Piv+n02`I6@Kp$!ng>w zzGx4=$!!B6mwTF&&r#6a5A1-VK{r7$GBNHyWNWbh?i26%OEz+D=pN$1-b!j`peFOR zEMZdC5&{9%z@`cmrj%JS23V^wB5`#Y!MEVl@uq({&NsD(O4)B%Pv$!6%)m4 z;-Dz9&&b@UK_gT=(`{V+0;t4`;(uToM+7y^294!^>xCE9sNbganmCx-5@7-fX>3nI zxV@0M2vdod#931<{_@%GUA>P|u`pH3?Kh4i%+4d1!A(#cq#W~{V2+&voZy8-4b_5` z^mP1DK`Yr(DVI1^@;#c61|9{)nPfTaO5s^ya1~t1l#I_|e`TrH>G&+$z<>Tr*oLB1 zaxI0RFfkCs87#%H#u+}M^fp1Mq%(#h$|=gEJYyM?y3+K6N8r}Y$kM3gwv>q}X$&(o zq7h++bWEd^iY;bb92kqii=rU1 z3?(Im#TZ?ex{@_B!ocy;?^AXxb+f>6TC@43bJAJ6rRI-#k3eSI8Mk*D&YY+ym7tQ-vwZ-3w;7seRs!Wa`3 zbsX?O9!NEZGBOsKFgLoGuFadD`4U2VMV*s{J??Kcl#5)}qJ!3-em*bE{d1jBNUob~ z;1+<2q1y|{tc4Mq8El_F-jlt$rleO?cZUi`hR0l~cqr8(os_9z;!j%Yil03LTXABb z`8Y<~ko9$*A7U%(I)BBrysC;5Gr4#29otBXv+q^yCuNt!;)8p#3Wsc~YZhzTL|5Vn z_4bi(JnvGNYqyG$o_$NPSPNnkCKYRIb8@yzv$?Wa6cxI z(9voPQ5!~e)uyik+*!mLLF;xX-fQI2nB*#I;DIkui@X4O+kf$WY+yesYqw7Gi$!&1 zcqY~=3mcTQ$qstuJY=!B`B)#zRPL3vs}jcz@`*x#esq7ZQ+gOJ=OK$lwXgqZYWf@O z>GPuYtgC$v7Wy6UtGfbP;J(kU=5Qa*Xne49ORf&ej^&8_yWHTp$(?NERB}AT?2>ZCZYrBnDE13dSiTwr*fSiNPnjWTi^7?&xsFer#BdRuTJM#_|38x=!IdA@!ILa_XhU{BK^5pSESB`D zun^mOgNyJOq?utX$)_fsS1A3bmd;(kC^B|L{r-6DBB4jAAOeoIl?6FXBn&4t)pWF8}}l07*qoM6N<$f}7<$?*IS* literal 5738 zcmc&&2{@GN+aK9FDoG0wjiIExW;5%_7Kt+UL`i1e_Z0E2_EaiSC`u)?5aR!ik#GfwGzzuYLLy*-fv_0Og8eu=Gt9dS6&N%JGQ+GQF>nllHSEu^3lqX_VUF%# zSRhD)Fc#+M#S%KAfD4P6XbCrnC!$NtFr#|u$h&MBi$RZyhy%?qRx*QVPlhwvnlFUW z6cYjf;&3=Lg=T_hkx5Jnjb&<#Cg2DJERKxD69EE&j>9AW=&?TxVo(UN>8>`mW9E>N z8OC2M7SOTS(9lqmP@)N6=!eDAXf!O2fF%$BL;?_P;E9>1y?YxmM0oR3c(C3VG6K#6PzrnQ9%eCmlFgFgGP-*AQldSxiC*G zLS*sdvI2j;m@o3@|3vlp^4|;~=rS1NHh$;}mpg7kBwiYVgfRxl52Z!!8w4=c6&CS> zg&@2%1aVXUTQnlEEBqIp|6n^JKE7GZVgF>WY~^o3!4l3-L}e@Eq5`_L5N3+`LU%qt z=o@{UzoCdG5KTzv1q>$0;mL3{l0`AL1-4;|VKWRuB{G0B1@L%xJc*7Y&K62)MtAB26gXU_AB+|7{4nV8v!z?} zgZM(kFyb9?)C&fKZqE~mnLH4-w=u&YA((JD5SRcXPH6(6*Pqb5=^1s z32ZzGg2%Nv2{}mqGlRa@D)S0LG{S5ejSUe=0K|mIfGGsQ0F_220(et4i9n&45@`e) z1}!^pI-&+~i7Cr4GYo!g(o@E}py02Q6atd`I4oZFJ+|)ki}k;jSMoRr!~_gl#+a|D zx*_!oft5@m5lLh7&KwCGK8ezlw?YY;h=42wV9f2>*k; z!2Z;IJ}(3oeiwj%DP$s58y1SpFl-^8iyr4NTFgf?1%ez(4WckgOOVg`yYgZgV|IZ1%?0?k%@#uX3+p9#5M(pBsNH6u}LIT7Lq1E4ClX> zpa1i4z+bZ97Y8rn#yC-a4jTjuIp6W%d#;VUfF5Pt|Bk(1vhJVN*#C!P!pd5buVn}O zUn|nrvCUU3ZDlz+dTY~vRD1p@50Pt}j$9ePxI{=)3Z6{FfdHFA0|7GLlngNO6cWHB zu<%3>1_>l8TgGacHvWHliO6cnC=B0qYTu@i_D}XY-g+W~@#YogA-O9=T2m}!{~l?5 z7uwrcxl7XDrf+n1@0C%C9>lGcFx8nQm@y0Ca} zSzlLE)TET!&`6hy9qEIEo6Gu!Zklxrg@>48{&Z>JR%&WA|tO+9YLzUs7Nt#ytSd>tD&T>BPuJ!JQE!LG9midpM(w0MJUXlaI z>Ida^xkW({0cBy^e8dx!rQAxnqFO;?+SM}K*i1@*6t2{&T2;Moft-(ANGc=Jk+1nd z1;4eqU#)vq!n4!(Th2<0x#ZdAefJWURZR>7jk}e)j~$ioA(+c~q%k5FhE6GEAbN%; zOQuc?UKuJbjj!6@Vi2o)b-$gQp50CpPw~>d9*f$^i}t;~zGp2b^UT2fRh>ZBi;}6srP0p#3@QzBiW)evanTB zyYg(n4$I<4&@s2%%v2d*^20HI%=Bw-x|6qt5cg`jZLUt#ngv5$D%zns9d<5fwR+T_ zSryefAGh_Ki9kVBqN+T}G0@P-Z+o%s*7L;KZTFuv2G#(^emCN??@{}f?vtjlcefN0 zt&Sq0v`u^#Xt}<47wqW!zSqlVBka8+=SCgBB%)eb@hNROpv>OSh+I8SZz)Tm46jC= ze7Lsu<<>J7%UQEl5a(016P&vHuFT784$)kZmbJ}X^}2_!X4tW-Xtts&#(EpAApB0I zo}zE<8w1t6bvBTHL6WCd_xU90o9WU>5ROzH&tm?(35K~ z=tW5nS?8$Bk~AAt#Ix`^&!jcCeaoMGSkU8=v`=};!hABp@2%lI(%HHg(+PccC*6go z?s`CWv$c-3v}trq4Rb}cW?$~_ul_uF%DtMH?Rq!w%c;s@#Z-y67&LpQT-y9w_7lza z%4g-b>MwRwIUN zJRy;b(57MU(T(KGa#exX>XXaws88!?-lp3Tc&#==ZoWNd`0b;pO}ckJEnC(B1atyV z`~$Lcn*KQJ=J1$98deA(ZJ)HoX4%9-^LZr&>2TWAx)h6DFQYG@23FTBiQMyH&we?* z>btyor@AMs8!AcK4L6iZ%IuSUAb(t6{9Jp4Y8D5RZSHj+LOpkw>l9YE;T%6VCc6A1 zvB0o>m5{PJ+i4|j<~wHNrT2M*1qny?s_qMZow}MP=~P#CTFYvR+Ohb+7w`c##a3D| zH}J#p<#P{IRN2N_NHZy_@`X7D?^b9oy?PjLGWF*4pqpmZo!qDCq`kA94GjAWXYFn$_b0@bW$tP7>KQGC_-vT%9lVhyYv=Uxl_=O#+ zI+t;4GnakRGJb1T-Wf8tajHX1wK&gV`MeqX56g8W6X}bpOB_oqGQ4pqXLlYAgTVSO z{=+ka+pkpI$~Z?jz%PXVO)vzDsy~ z8>!|DyUONf-7a3qG9qGv=s~K%i(a2P zc0gADi*(}VIa7yCR^8!=a^FOR7g?aaPUMvz^4#UJ{qF0w&H#l0UeTKzlSDE~xB z(bEN|6)3A8zrE`1z0xT#O8C3a@u4}_NYP7q%W^u?LMkq3cZO!C?K!QVtX;5mp2wAR zi>|igif-NSx0QT4ka+(_bH9-jknXTbLuPpJrCk zcOXr{aZvxkv3d3H)@8ifyyVDy`mhuI%6_{`TV|+bw_V@Jx}z9Sd(m)jXvU_>u7L+D zlQs}{-G16$;dIUX%Z*A}MSi{sQhYT$@2y*+aV&>5*oIVK_xq%xfE9AfcAi+N7y)7w zdnwPU30bp{i+FFwoPi@GW3ET*zIO?M!t2De$+V_se+%_0&&Ma9f9aTj6mGl7f%2;S zjv`Mg$tU?uIe%e)b?)cFhCba>&YeSVfP-lAsZ(*5SqWtuS(fG8E_pL#dwXzz8@H(wZAI!zBcT1^aO#RfEa zO$TcncN+3+Cib{6gFNXcJ02er?W4JbFdTyq#!b5L_~WLkRdsT&jPTF-o`=JHx6Sr? z-MeRD;-nk%uY?!83!zFqABI#WW(9oO1k6?rKb5e?GvB6eL(s6|fEt+F%TKBgYx$CW zldh*Mi~7L=g^0?716>)f5AJ#t_t5ICG&O9}Ev`lD=ZVS_k!RM7S9|ZCnpPjLiS!kd zBvy_a{jBg8R9EzpkDY!k`0e*ee8!I{=vAvO~nuU zpy&F9s695lTv72h?|}D|=#v?${+xGE&$WXt>sR*nW-h6|Z~GzbQ~h0&v~Yi$^jvv^ znA0E2%=P?E9%;V3b;?GgP@-Skq&*F$1-@5&{IsziNE@@lRRh}HQXKyZ5H>6dOiLNO zt6L6KIs0dVucV)KE3ZtC-hIYzQU z4o{#KTA$~k?py>IPNr^k=&8_tn$4wkNX139nJ*2>$`fBF|LK=RZ0gfK=OY=}pQ=#a zM-~RRMV*XL>1P%loABhZ&z!@sPp7ADc|+0L>Vpv*6XgxD=Lc?tbd+fH?J(573t|*p zTQQmxdhD?l`gSy6y{!sjtyp7-v`wq{t4jqriCJ~pU9(0~r8CVN6Ph*174PZW-cQ`- zcYk2Ik>vI5oyO0wlZOszyoiqSsWIHN^0Jros7mpcCvg(?bxY_>9k47xUawIc$C{pK zelbw9RVm7`YPqkzuhsRu;E{CH&B4y@{1N%f2Ly9f!P^?LUxV$JI@;tf@!k3_lfoXw diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index ce30e8f..1029361 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -9,6 +9,9 @@ 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/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_layer.dart'; import 'package:bluebus/widgets/building_sheet.dart'; import 'package:bluebus/widgets/bus_sheet.dart'; import 'package:bluebus/widgets/composite_map_widget.dart'; @@ -68,8 +71,6 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { return angle; } - - class MaizeBusCore extends StatefulWidget { const MaizeBusCore({super.key}); @@ -111,7 +112,6 @@ class _MaizeBusCoreState extends State { // In memory cache of favorited stop ids for quick lookup and immediate UI updates final Set _favoriteStops = {}; - Marker? _searchLocationMarker; final Set _selectedRoutes = {}; List> _availableRoutes = []; @@ -131,7 +131,8 @@ class _MaizeBusCoreState extends State { // Memoization caches final Map _routePolylines = {}; - final Map> _routeStopMarkers = {}; // maps from route to a map of stopID to marker + 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; // maximum allowed distance (meters) from a stop to a candidate polyline point @@ -175,27 +176,24 @@ class _MaizeBusCoreState extends State { _setupConnectivityMonitoring(); baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); - journeyLayer.init(_showBusSheet, _activeJourneyBusIds, _activeJourneyRoutes, context); + journeyLayer.init( + _showBusSheet, + _activeJourneyBusIds, + _activeJourneyRoutes, + context, + ); hideJourney(); // Hide the journey layer until we're ready to use it - - - // TODO: Make sure this still works when moved to line 197 - // // Only update bus markers when buses change - // final busProvider = Provider.of(context, listen: false); - // WidgetsBinding.instance.addPostFrameCallback((_) { - // if (busProvider.buses.isNotEmpty) { - // _updateDisplayedBuses(busProvider.buses); - // } - // }); - 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 - + 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); @@ -272,7 +270,6 @@ class _MaizeBusCoreState extends State { // still keep context @override void didChangeDependencies() { - // debugPrint("******** Got didChangeDependencies call"); super.didChangeDependencies(); if (_dataLoadingFuture == null) { _dataLoadingFuture = _loadAllData(); @@ -280,35 +277,24 @@ class _MaizeBusCoreState extends State { } Future _loadAllData() async { - - // debugPrint("******* Loading all data"); - ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); - await theme.loadTheme(); - - // debugPrint("******* Loaded theme"); + await theme.loadTheme(); screenRadius = await ScreenCornerRadius.get(); // load screen radius screenRadiusLoaded = true; - // debugPrint("******* Loaded screenRadius"); - //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) { + if (permission == LocationPermission.whileInUse || + permission == LocationPermission.always) { // permission = await Geolocator.requestPermission(); Position? pos = await Geolocator.getLastKnownPosition(); - if (pos != null){ + if (pos != null) { startLatLng = LatLng(pos.latitude, pos.longitude); } } - // debugPrint("******* Got geolocator position"); - - // debugPrint("******* Loading canVibrate"); - - canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -342,11 +328,10 @@ class _MaizeBusCoreState extends State { content: startupData.persistantMessage, ); } - - // debugPrint("******* Loading all the data in parallel"); + // loading all this data in parallel await Future.wait([ - _loadCustomMarkers(), + // _loadCustomMarkers(), busProvider.loadRoutes(), _loadSelectedRoutes(), _loadFavoriteStops(), @@ -358,7 +343,7 @@ class _MaizeBusCoreState extends State { // await _loadRouteSpecificBusIcons(); _updateAvailableRoutes(busProvider.routes); _cacheRouteOverlays(busProvider.routes); - + debugPrint("******* Caching routes"); baseRoutesLayer.cacheRoutes(busProvider.routes); @@ -381,9 +366,6 @@ class _MaizeBusCoreState extends State { busProvider.startBusUpdates(); busProvider.startRouteUpdates(); await Future.delayed(const Duration(milliseconds: 180)); - - // debugPrint("******* FINISHED ALL LOADING!!!!"); - } // need this to make sure that the stop names exist in the cache @@ -470,115 +452,6 @@ class _MaizeBusCoreState extends State { ); } - Future _loadCustomMarkers() async { - try { - // Load stop icons - // [These were moved to composite_map_widget.dart] - // _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 MapImageService.resizeImage(await rootBundle.load('assets/getOn.png')); - // _getOff = await MapImageService.resizeImage(await rootBundle.load('assets/getOff.png')); - // TODO: Move this into map_image_service.dart - - // Load route specific bus icons - // await _loadRouteSpecificBusIcons(); - await MapImageService.loadData(); // TODO: This was already called inside loadAllData. Do we need to call it again? - - // 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, - // ); - // } - // } - - - - - - // // 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 { @@ -609,42 +482,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; - // } - - - - - - Future _loadFavoriteStops() async { try { final prefs = await SharedPreferences.getInstance(); @@ -728,7 +565,6 @@ class _MaizeBusCoreState extends State { } void _updateAvailableRoutes(List routes) { - // debugPrint("****** Got _updateAvailableRoutes call!!"); final Map routeIdToName = {}; for (final r in routes) { if (!routeIdToName.containsKey(r.routeId)) { @@ -764,13 +600,12 @@ class _MaizeBusCoreState extends State { } if (!_routeStopMarkers.containsKey(routeKey)) { _routeStopMarkers[routeKey] = {}; - for (final stop in r.stops) { // iterate through all stops in this route + 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)}', - ), + markerId: MarkerId('stop_${stop.id}_${Object.hashAll(r.points)}'), position: stop.location, flat: true, icon: isFavorite @@ -810,8 +645,9 @@ class _MaizeBusCoreState extends State { ); _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)) { + // 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,13 +665,11 @@ 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 + baseRoutesLayer + .reload(); // Reload the markers to include the new favorite }); _setStopFavorited(stpid, true); } else {} - - - } Future _removeFavoriteStop(String stpid, String name) async { @@ -847,7 +681,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 + baseRoutesLayer + .reload(); // Reload the markers to include the new favorite }); _setStopFavorited(stpid, false); } @@ -867,31 +702,31 @@ class _MaizeBusCoreState extends State { 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, - )), + ? (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 + // gets first marker of this stop id and adds it to the favorited stop markers if (favored && !_displayedFavoriteStopMarkers.containsKey(stpid)) { _displayedFavoriteStopMarkers[stpid] = newMarker; } @@ -925,8 +760,6 @@ class _MaizeBusCoreState extends State { }); } } - _displayedStopMarkers = selectedStopMarkers; - _updateAllDisplayedMarkers(); }); } @@ -945,7 +778,7 @@ class _MaizeBusCoreState extends State { if (polyline != null) selectedPolylines.add(polyline); final stops = _routeStopMarkers[routeKey]; if (stops == null) continue; - + stops.forEach((key, value) { if (!selectedStopMarkers.containsKey(key)) { selectedStopMarkers[key] = value; @@ -954,99 +787,17 @@ class _MaizeBusCoreState extends State { } } - setState(() { - _displayedPolylines = selectedPolylines; - _displayedStopMarkers = selectedStopMarkers; - _updateAllDisplayedMarkers(); - }); + baseRoutesLayer.reload(); + liveBusesLayer.reload(); + _updateDisplayedBuses( Provider.of(context, listen: false).buses, ); } void _updateDisplayedBuses(List allBuses) { - debugPrint("****** Updating displayed buses"); - // // 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(); - journeyLayer.refreshLiveBusMarkers(allBuses); - - // 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)) { - // BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); - - - // _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(); // TODO: Do we still need this? - - liveBusesLayer.reload(); - }); - } - - void _updateAllDisplayedMarkers() { - _allDisplayedStopMarkers = _displayedStopMarkers.values.toSet() - .union(_displayedFavoriteStopMarkers.values.toSet()) - .union(_displayedBusMarkers) - .union(_displayedJourneyMarkers) - .union(_searchLocationMarker != null ? {_searchLocationMarker!} : {}); + liveBusesLayer.reload(); } // Show a red pin marker at search location @@ -1066,16 +817,6 @@ class _MaizeBusCoreState extends State { setState(() {}); } - void _refreshAllMarkers() { - // TODO: Should all this be moved inside the MapImageService now that we're encapsulating everything in that? - final busProvider = Provider.of(context, listen: false); - _refreshCachedStopMarkers(); - // _refreshRouteBusIcons(); - MapImageService.refreshRouteBusIcons(); - _updateDisplayedRoutes(); - _updateDisplayedBuses(busProvider.buses); - } - // Save selected routes to persistent storage Future _saveSelectedRoutes() async { final prefs = await SharedPreferences.getInstance(); @@ -1102,10 +843,6 @@ class _MaizeBusCoreState extends State { ); } - - - - void _showBusRoutesModal(List allRouteLines) { showModalBottomSheet( context: context, @@ -1136,7 +873,6 @@ class _MaizeBusCoreState extends State { } void _showSearchSheet() { - debugPrint(">>>>>>> SHOWING SEARCH SHEEEEEET"); showModalBottomSheet( context: context, isScrollControlled: true, @@ -1209,7 +945,9 @@ class _MaizeBusCoreState extends State { ); }, ); - _bottomSheetController?.closed.then((_) {hideJourney();}); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } void _showDirectionsSheet( @@ -1284,10 +1022,12 @@ class _MaizeBusCoreState extends State { } }, onSelectJourney: (journey) { - currDisplayed = journey; showJourney(); - journeyLayer.setJourney(journey, getColor(context, ColorType.opposite)); + journeyLayer.setJourney( + journey, + getColor(context, ColorType.opposite), + ); // TODO: Figure out how to change the visibility of the layers @@ -1307,11 +1047,12 @@ class _MaizeBusCoreState extends State { ); }, ); - _bottomSheetController?.closed.then((_) {hideJourney();}); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } _showJourneySheetOnReopen() { - debugPrint(">>>>> Showing journey sheet on reopen"); showModalBottomSheet( context: context, isScrollControlled: true, @@ -1348,101 +1089,22 @@ class _MaizeBusCoreState extends State { ); }, ).whenComplete(() { - debugPrint("***** Modal bottom sheet is complete!!"); hideJourney(); }); } - - // TODO: Put this into composite_map_widget.dart - // 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), - // ); - // } - - // Display a Journey on the map - // void _displayJourneyOnMap(Journey journey, Color walkLineColor) async { - - // } void showJourney() { - debugPrint("**** showJourney call"); journeyLayer.isVisible = true; baseRoutesLayer.isVisible = false; liveBusesLayer.isVisible = false; } void hideJourney() { - debugPrint("**** hideJourney call"); journeyLayer.isVisible = false; baseRoutesLayer.isVisible = true; liveBusesLayer.isVisible = true; } - // Clear/hide the currently displayed journey overlays and return to normal route view - // void _clearJourneyOverlays() { - // journeyLayer.clearJourney(); - // // 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(() {}); - // } - - // // 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; - // } - - // // 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; - // } - // } - // return [bestIdx, bestDist]; - // } - void _onMapCreated(GoogleMapController controller) { _mapController = controller; } @@ -1464,8 +1126,6 @@ class _MaizeBusCoreState extends State { } } - - void _showBusSheet(String busID) { showModalBottomSheet( context: context, @@ -1550,7 +1210,6 @@ class _MaizeBusCoreState extends State { 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); }, @@ -1569,7 +1228,9 @@ class _MaizeBusCoreState extends State { }, ); }, - ).then((_) { hideJourney(); }); // Hide any displayed journey when the sheet is closed + ).then((_) { + hideJourney(); + }); // Hide any displayed journey when the sheet is closed } // lighter function for when we need to get location @@ -1601,8 +1262,7 @@ class _MaizeBusCoreState extends State { ), ); return null; - } - else { + } else { //Center map once right after user grants location permissions _centerOnLocation(true); } @@ -1687,7 +1347,6 @@ class _MaizeBusCoreState extends State { @override Widget build(BuildContext context) { - if (!globallPaddingHasBeenSet) { // set all padding // first, getting all the padding values @@ -1697,29 +1356,28 @@ class _MaizeBusCoreState extends State { // 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 + // 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 + 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){ + 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 + // out of the safe area so we'll just use them globalBottomPadding = perfectPadding; globalLeftRightPadding = perfectPadding; } @@ -1744,10 +1402,6 @@ 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 showing a persistent bottom sheet, close it. @@ -1765,74 +1419,10 @@ class _MaizeBusCoreState extends State { mapLayers: [ baseRoutesLayer, liveBusesLayer, - journeyLayer + journeyLayer, ], onMapCreated: _onMapCreated, ), - - // underlying map layer (different ios and android) - // Platform.isIOS - // ? MapWidget( - // initialCenter: startLatLng, - // 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: startLatLng, - // polylines: _journeyOverlayActive - // ? _displayedJourneyPolylines - // : _displayedPolylines.union( - // _displayedJourneyPolylines, - // ), - // staticMarkers: _journeyOverlayActive - // ? _displayedJourneyMarkers.union( - // _searchLocationMarker != null - // ? {_searchLocationMarker!} - // : {}, - // ) - // : _displayedStopMarkers.values.toSet() - // .union(_displayedFavoriteStopMarkers.values.toSet()) - // .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, - // ), - Padding( padding: EdgeInsets.only( top: globalTopPadding, diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index ae46f00..45215a0 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -38,27 +38,22 @@ class MapImageService { // Check if cached assets need to be refreshed based on backend version static Future _shouldRefreshCachedAssets() async { - debugPrint(" HELLO THIS IS _shouldRefreshCachedAssets()"); int frontEndVer; frontEndVer = await getFrontEndImageVer(); try { final backendImageVersion = await _getBackendImageVersion(); if (backendImageVersion == null) { - debugPrint(" Couldn't reach server! Forcing a refresh"); return true; // if you can't reach the server give up } if (int.parse(backendImageVersion) == frontEndVer) { - debugPrint(" Images are up-to-date, no refresh needed"); return false; } else { - debugPrint(" New images available, forcing a refresh"); await setFrontEndImageVer(int.parse(backendImageVersion)); return true; } } catch (e) { // On error, assume refresh needed - debugPrint("_shouldRefreshCachedAssets error: ${e.toString()}"); return true; } } @@ -74,7 +69,6 @@ class MapImageService { return data['bus_image_version'] as String?; } } catch (e) { - debugPrint(" getBackendImageVersion error: $e"); // Return null on error - will trigger refresh } return null; @@ -108,7 +102,6 @@ class MapImageService { // Set a fallback bus icon for a route static void _setFallbackBusIcon(String routeId) { - debugPrint(" Setting fallback bus icon for route ${routeId}"); try { final routeColor = RouteColorService.getRouteColor(routeId); _routeBusIcons[routeId] = BitmapDescriptor.defaultMarkerWithHue( @@ -170,37 +163,21 @@ class MapImageService { await RouteColorService.initialize(); } - debugPrint(" About to set shouldRefreshAssets variable"); // Check if we need to update cached assets based on version final shouldRefreshAssets = await _shouldRefreshCachedAssets(); - debugPrint(" Finished setting shouldRefreshAssets variable"); final routeIds = RouteColorService.definedRouteIds; for (final routeId in routeIds) { - debugPrint( - "Loading icon for route ${routeId}. Should refresh assets? $shouldRefreshAssets", - ); - - // VERY SOON TODO: Uncomment this to make sure it doesn't try to load icons that are alerady in the cache? - // if (_routeBusIcons.containsKey(routeId)) { - // debugPrint("* Icon already exists, no need to fetch it again!"); - // continue; - // } - // Try to load from cache first if not forcing refresh if (!shouldRefreshAssets) { - debugPrint(" * Attempting to load from cache"); final cachedIcon = await _loadCachedBusIcon(routeId); if (cachedIcon != null) { - debugPrint(" * Cache hit!"); _routeBusIcons[routeId] = cachedIcon; continue; } } - debugPrint(" * Loading from backend..."); - // Load from backend if cache miss or forcing refresh final imageUrl = RouteColorService.getRouteImageUrl(routeId); if (imageUrl != null) { 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..2c6064a --- /dev/null +++ b/lib/services/map_layers/base_routes_layer.dart @@ -0,0 +1,198 @@ +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 BaseRoutesLayer 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"); + }; + + List routesCache = []; + + 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, + ); + + Map> markersCache = + {}; // TODO: Merge this with polylines variable? + Map polylinesCache = {}; + + void cacheRoutes(List routes) { + // Called from inside _loadAllData() inside map_screen.dart + routesCache = routes; + + reloadMarkers(); + 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() { + reloadMarkers(); + reloadPolylines(); + if (isVisible) onUpdate(); + } + + void reloadMarkers() { + markersCache.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 (!markersCache.containsKey(routeKey)) { + // Prevent duplicate copies of the same stop on top of each other + markersCache[routeKey] = {}; + for (final stop in r.stops) { + // iterate through all stops in this route + // TODO: Implement favorite stops + // final isFavorite = _favoriteStops.contains(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_${stop.id}_${Object.hashAll(r.points)}'), + position: stop.location, + flat: true, + // icon: BitmapDescriptor.defaultMarker, + icon: + favoriteStops.contains(stop.id) // Used to be isFavorite + ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + : (stop.isRide ? _rideStopIcon : _stopIcon), + consumeTapEvents: true, + onTap: () { + onStopClicked(stop); + }, + rotation: stop.rotation, + anchor: Offset(0.5, 0.5), + ); + // _routeStopMarkers[routeKey]?[stop.id] = marker; + + markersCache[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; + } + } + } + + // markers = {}; + markers = markersCache.values.expand((Map m) { + return m.values; + }).toSet(); + } + + 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(); + } + + void setOnUpdate(Function() callback) { + onUpdate = 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'), + ); + + // Refresh markers with new icons + // TODO: See if we need this! + // if (mounted) { + // _refreshAllMarkers(); + // } + } 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/journey_layer.dart b/lib/services/map_layers/journey_layer.dart new file mode 100644 index 0000000..e45c546 --- /dev/null +++ b/lib/services/map_layers/journey_layer.dart @@ -0,0 +1,392 @@ +import 'dart:math' as math; + +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/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:bluebus/widgets/route_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.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) { + for (BusRouteLine l in routes) { + routesCache[l.routeId] = l; + } + } + + // 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; + } + + // 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; + } + } + 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, + ) { + // debugPrint("extractRouteSegment call!!!"); + final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); + final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); + // debugPrint("*** sRes = ${sRes}, eRes = ${eRes}"); + 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; + + // debugPrint("We have valid coords!"); + + 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); + + BusRouteLine? line = routesCache[leg.rt]; + + // 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..76b82fa --- /dev/null +++ b/lib/services/map_layers/live_buses_layer.dart @@ -0,0 +1,389 @@ +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 + 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; + } + + 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: () => 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: () { + 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(); + + debugPrint("***** Finished starting animation"); + } + + 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/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 3bd3d78..99d2302 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -10,6 +10,8 @@ import 'package:bluebus/models/bus_route_line.dart'; import 'package:bluebus/models/bus_stop.dart'; import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/map_layers/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_layer.dart'; import 'package:bluebus/services/route_color_service.dart'; import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; @@ -19,29 +21,6 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:widget_to_marker/widget_to_marker.dart'; -// 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), -// ); -// } - -// TODO: Add a Z-index to each thing in each CompositeMapLayer -// to explicitly define how things should be ordered - // Define the CompositeMapLayer abstract class CompositeMapLayer { // Every CompositeMapLayer must have these four things @@ -54,1455 +33,12 @@ abstract class CompositeMapLayer { } // TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff -class BaseRoutesLayer 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"); - }; - - List routesCache = []; - - Set favoriteStops = {}; - Set selectedRoutes = {}; - - BitmapDescriptor? _stopIcon; - BitmapDescriptor? _rideStopIcon; - BitmapDescriptor? _favStopIcon; - BitmapDescriptor? _favRideStopIcon; - - Map> markersCache = - {}; // TODO: Merge this with polylines variable? - Map polylinesCache = {}; - - void setOnUpdate(Function() callback) { - debugPrint("****** got setOnUpdate call!"); - onUpdate = callback; - } - - void init( - Set favoriteStops_in, - Set selectedRoutes_in, - Function(BusStop) onStopClicked_in, - ) { - favoriteStops = favoriteStops_in; - selectedRoutes = selectedRoutes_in; - onStopClicked = onStopClicked_in; - _loadCustomMarkers(); - } - - 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'), - ); - - // Refresh markers with new icons - // TODO: See if we need this! - // 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, - ); - } - } - - void reload() { - debugPrint("****** Reloading everything in busRoutesLayer"); - reloadMarkers(); - reloadPolylines(); - if (isVisible) onUpdate(); - } - - void reloadMarkers() { - // set force to reload all the markers, regardless of whether they're already in the cache or not. Useful if a marker changes state (e.g. becomes a favorite) but is already in the cache - - debugPrint("***** Got reloadMarkers call"); - - markersCache.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 (!markersCache.containsKey(routeKey)) { - // Prevent duplicate copies of the same stop on top of each other - markersCache[routeKey] = {}; - for (final stop in r.stops) { - // iterate through all stops in this route - // TODO: Implement favorite stops - // final isFavorite = _favoriteStops.contains(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_${stop.id}_${Object.hashAll(r.points)}'), - position: stop.location, - flat: true, - // icon: BitmapDescriptor.defaultMarker, - icon: - favoriteStops.contains(stop.id) // Used to be 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: () { - onStopClicked(stop); - }, - rotation: stop.rotation, - anchor: Offset(0.5, 0.5), - ); - // _routeStopMarkers[routeKey]?[stop.id] = marker; - - markersCache[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; - } - } - } - - // markers = {}; - markers = markersCache.values.expand((Map m) { - return m.values; - }).toSet(); - } - - 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(); - } - - void cacheRoutes(List routes) { - debugPrint("******* Got cacheRoutes call!!"); - // Called from inside _loadAllData() inside map_screen.dart - routesCache = routes; - - // TODO: Make the parent (map_screen.dart) pass in the list of filtered route IDs and as soon as that list changes call some sort of reloadMarkers() - - // TODO: Update the map controller here - debugPrint("Calling onUpdate: ${onUpdate}"); - - reloadMarkers(); - reloadPolylines(); - - if (isVisible) onUpdate(); - } -} - -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 - 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; - } - - void initWithTickerProvider(TickerProvider tickerProviderIn) { - debugPrint("******* Initting with animation controller!!"); - 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: () => onBusClicked(bus), - ); - } - - void updateAnimation() { - // debugPrint("* updateAnimation call! busAnimationCache has ${busAnimationCache.keys.length} keys"); - // debugPrint(" Animation value is ${animation.value}"); - // debugPrint("* selectedRoutes is ${selectedRoutes}"); - - DateTime now = DateTime.now(); - - markers = busAnimationCache.keys - .where((String busId) { - // debugPrint("Checking to see if we should add marker ${busAnimationCache[busId]?.bus.routeId}: ${selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId)}"); - return selectedRoutes.contains(busAnimationCache[busId]?.bus.routeId); - }) - .map((String busId) { - LatLng interpolatedPosition; - // debugPrint("Adding marker for ${busId}"); - double interpolatedHeading = busAnimationCache[busId]!.bus.heading; - double animatedPercentage = min( - (now.millisecondsSinceEpoch - - busAnimationCache[busId]!.lastUpdated) / - ANIMATION_DURATION, - 1.0, - ); - - // debugPrint("animatedPercentage is ${animatedPercentage.toStringAsFixed(2)}"); - - 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: () { - try { - Haptics.vibrate(HapticsType.light); - } catch (e) {} - onBusClicked(busAnimationCache[busId]!.bus); - // _showBusSheet(bus.id); - }, - ); - - // return Marker(); - }) - .toSet(); - - // debugPrint("***** Finished updateAnimation() call, we now have ${markers.length} markers"); - - // markers = buses - // 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 - } - - // debugPrint("* Starting animation! Last animation was ${(now.millisecondsSinceEpoch - animationStartedTime) / 1000}s ago"); - if (controller == null) return; - // if (controller!.isAnimating) return; //Animation runs infinitely, so we only start it once - - 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(() { - // debugPrint("tick"); - DateTime now = DateTime.now(); - if (now.millisecondsSinceEpoch < nextAnimationFrameTime) return; - nextAnimationFrameTime = - now.millisecondsSinceEpoch + FRAME_DURATION; // 100ms frametimes - - // debugPrint("****** Got animation tick!"); - updateAnimation(); - if (isVisible) - onUpdate(); // Tell the CompositeMapWidget to update (CompositeMapWidget calls setState inside onUpdate) - }); - - animation.addStatusListener((AnimationStatus status) { - // if (status == AnimationStatus.completed) { - // debugPrint("********* RESTARTING ANIMATION"); - // controller?.forward(); - // } - }); - - controller?.forward(); - controller?.repeat(); - - debugPrint("***** Finished starting animation"); - } - - 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) { - // debugPrint(">>>> Bus position has not changed! Skipping animation for ${bus.id}"); - // 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(); - } -} - -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) { - debugPrint("****** got setOnUpdate call!"); - 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) { - for (BusRouteLine l in routes) { - routesCache[l.routeId] = l; - } - } - - // 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; - } - - // 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; - } - } - 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, - ) { - debugPrint("extractRouteSegment call!!!"); - final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); - final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); - debugPrint("*** sRes = ${sRes}, eRes = ${eRes}"); - 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; - - debugPrint("We have valid coords!"); - - 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); - - BusRouteLine? line = routesCache[leg.rt]; - - 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); - } - - debugPrint("Trying to add markers"); - // 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 - debugPrint("Can add start/end markers!"); - - 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), - ), - ); - - // markers.add( - // Marker( - // flat: true, - // markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), - // position: segment?.first ?? startLatLng, - // icon: - // _getOn ?? - // BitmapDescriptor.defaultMarkerWithHue( - // colorToHue(RouteColorService.getRouteColor(leg.rt!)), - // ), - // ), - // ); - } - 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); - - debugPrint( - "**** Adding walking leg markers! from ${startLatLng} to ${endLatLng}", - ); - - // 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 - - // TODO: Handle these edge cases - - // 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 - // } - // } - - // 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); - } - - // 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]; - // } - - 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' - - debugPrint("************ got setJourney call"); - - // 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.originID == "VIRTUAL_ORIGIN" && leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { - // addRouteStartMarker(leg.pathCoords!.first, journey); - // } - 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); - - // 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, - // ); - // polylines.add(polyline); - - // // add stop markers at endpoints of the segment (boarding/getting off) - // markers.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 { - addWalkingLegMarkersAndPolylines(leg, journey, legIndex); - // TODO: Add support for these edge cases - - // // 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(liveBusesLayer.createBusMarker(bus)); - // } - // } - - // // Final debug check - // // Journey display complete (silently updated internal state) - - // setState(() { - // _updateAllDisplayedMarkers(); - // }); - - // // 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), - // ), - // ); - // } - // } - // } - - if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update - } - - void clearJourney() { - markers.clear(); - polylines.clear(); - if (isVisible) onUpdate(); - } -} class CompositeMapWidget extends StatefulWidget { - // final LatLongNew.LatLng initialCenter = LatLongNew.LatLng(42.277849, -83.7352536); - // final Set polylines; - // final Set markers; - // final void Function(GoogleMapController)? onMapCreated; - // final void Function(CameraPosition)? onCameraMove; - // final bool myLocationEnabled; - // final bool myLocationButtonEnabled; - // final bool zoomControlsEnabled; - // final bool mapToolbarEnabled; - // Function(BusStop stop) onStopClicked; - // Function(Bus bus) onBusClicked; - final LatLng initialCenter; final List mapLayers; final Function(GoogleMapController) onMapCreated; - // TODO: Implement these methods - - // final UniversalMapController universalController; - CompositeMapWidget({ required this.initialCenter, required this.mapLayers, @@ -1511,7 +47,6 @@ class CompositeMapWidget extends StatefulWidget { @override State createState() { - // TODO: implement createState return CompositeMapWidgetState(); } } @@ -1523,8 +58,6 @@ class CompositeMapWidgetState extends State Set allPolylines = {}; void reloadMap() { - // debugPrint("******* Got reloadMap() call!"); - // _mapController. setState(() {}); // Rebuild with updated markers } @@ -1554,10 +87,6 @@ class CompositeMapWidgetState extends State @override Widget build(BuildContext context) { - // widget.mapLayers.forEach((CompositeMapLayer layer) { - // if (!layer.isVisible) return; - // allallMarkers.union(other) - // }); allMarkers = widget.mapLayers.expand((CompositeMapLayer layer) { if (!layer.isVisible) return {}; return layer.markers; @@ -1567,10 +96,6 @@ class CompositeMapWidgetState extends State return layer.polylines; }).toSet(); - // allmarkers = - - // debugPrint("******* Got CompositeMapWidget build command! #markers is ${allMarkers.length}"); - return RepaintBoundary( child: GoogleMap( compassEnabled: false, @@ -1580,7 +105,6 @@ class CompositeMapWidgetState extends State myLocationButtonEnabled: false, markers: allMarkers, polylines: allPolylines, - // controller: cameraTargetBounds: CameraTargetBounds( LatLngBounds( southwest: LatLng( From 4b986c1d549950c76704ad0e5fc0dde4f144801d Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 31 May 2026 23:22:27 +0200 Subject: [PATCH 034/121] Added RepaintBoundary --- lib/screens/map_screen.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1029361..25b3493 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1414,14 +1414,16 @@ class _MaizeBusCoreState extends State { }, child: Stack( children: [ - CompositeMapWidget( - initialCenter: startLatLng, - mapLayers: [ - baseRoutesLayer, - liveBusesLayer, - journeyLayer, - ], - onMapCreated: _onMapCreated, + RepaintBoundary( + child: CompositeMapWidget( + initialCenter: startLatLng, + mapLayers: [ + baseRoutesLayer, + liveBusesLayer, + journeyLayer, + ], + onMapCreated: _onMapCreated, + ), ), Padding( padding: EdgeInsets.only( From 0a90b70692d04d7744dbf94629d70ad882bc2460 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:47:30 +0200 Subject: [PATCH 035/121] Added a basic framework --- lib/services/map_layers/navigation_layer.dart | 48 +++++++++++++++++++ .../navigation/navigation_manager.dart | 36 ++++++++++++++ lib/widgets/navigation_overlay_widget.dart | 40 ++++++++++++++++ lib/widgets/navigation_widget.dart | 30 ++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 lib/services/map_layers/navigation_layer.dart create mode 100644 lib/services/navigation/navigation_manager.dart create mode 100644 lib/widgets/navigation_overlay_widget.dart create mode 100644 lib/widgets/navigation_widget.dart diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart new file mode 100644 index 0000000..2d5385c --- /dev/null +++ b/lib/services/map_layers/navigation_layer.dart @@ -0,0 +1,48 @@ +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( + Set favoriteStops_in, + Set selectedRoutes_in, + Function(BusStop) onStopClicked_in, + ) { + //... + } + + void reload() { + reloadMarkers(); + reloadPolylines(); + if (isVisible) onUpdate(); + } + + 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..cfb3ca3 --- /dev/null +++ b/lib/services/navigation/navigation_manager.dart @@ -0,0 +1,36 @@ +import 'package:bluebus/services/map_layers/navigation_layer.dart'; + + + +sealed class NavigationStage { + String title = "..."; +} + +class NavWalking extends NavigationStage { + // ... +} + +class NavOnBus extends NavigationStage { + // ... +} + +class NavigationManager { + // TODO: Implement ChangeNotifier and learn how that works + + int currentStage = 0; // Stores the current navigation state index + List stageList = + []; // Stores all the states for users to page back and forth + NavigationLayer? mapLayer; + + // Some way for the navigation widget to + + void init() { + // Init as necessary + } + + NavigationStage getCurrentStage() { + return stageList[currentStage]; + } + + // TODO: Add start()/stop() methods +} diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart new file mode 100644 index 0000000..03ec437 --- /dev/null +++ b/lib/widgets/navigation_overlay_widget.dart @@ -0,0 +1,40 @@ + +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:flutter/material.dart'; + +class NavigationOverlayWidget extends StatefulWidget { + + final NavigationManager navigationManager; + + const NavigationOverlayWidget({ + 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() => _NavigationOverlayWidgetState(); + +} + +class _NavigationOverlayWidgetState extends State { + + + + @override + Widget build(BuildContext context) { + switch (widget.navigationManager.getCurrentStage()) { + case NavOnBus(): + // Do stuff + + case NavWalking(): + // TODO: Handle this case. + throw UnimplementedError(); + } + return Text(widget.navigationManager.getCurrentStage().title); + } + +} + +// QUESTION: Should navigation_manager \ 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..b0ec4ba --- /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 null; // TODO: Return some widget stuff + } +} From e158ea8a30fa4011b7a3965e1f4ea8d1c0de1f5f Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sat, 6 Jun 2026 11:51:24 -0400 Subject: [PATCH 036/121] Update navigation_manager.dart --- .../navigation/navigation_manager.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index cfb3ca3..c360452 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -14,6 +14,27 @@ class NavOnBus extends NavigationStage { // ... } +class ChooseBus extends NavigationStage{ + 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 +} + +//I believe this is just NavWalking but I'm doing it here to be sure. +class Walking extends NavigationStage{ + //Points in order, you can check if you are near a point to remove it from the route or start another leg + List points; + //This could be refreshed in intervals + LatLng currWalkingPos; + + +} + + class NavigationManager { // TODO: Implement ChangeNotifier and learn how that works From 31b887ccc6429b3e97d7487ecbe8f6f4038bc550 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:43:27 +0200 Subject: [PATCH 037/121] Added NavigationManager to map screen --- lib/screens/map_screen.dart | 9 ++++++++ lib/widgets/navigation_overlay_widget.dart | 24 +++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 25b3493..0f7990c 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -12,6 +12,7 @@ import 'package:bluebus/services/map_image_service.dart'; import 'package:bluebus/services/map_layers/base_routes_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/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'; @@ -19,6 +20,7 @@ import 'package:bluebus/widgets/dialog.dart'; import 'package:bluebus/widgets/directions_sheet.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'; @@ -84,6 +86,8 @@ class _MaizeBusCoreState extends State { ScreenRadius? screenRadius; bool screenRadiusLoaded = false; + NavigationManager navigationManager = NavigationManager(); + Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( Loadpoint("Initializing...", 0), @@ -1686,6 +1690,11 @@ class _MaizeBusCoreState extends State { ), ), + + NavigationOverlay(navigationManager: navigationManager), + + + // reminder widget SizedBox(height: 30.0), _journeyOverlayActive || _isOffline diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 03ec437..636bf1d 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -2,11 +2,11 @@ import 'package:bluebus/services/navigation/navigation_manager.dart'; import 'package:flutter/material.dart'; -class NavigationOverlayWidget extends StatefulWidget { +class NavigationOverlay extends StatefulWidget { final NavigationManager navigationManager; - const NavigationOverlayWidget({ + const NavigationOverlay({ super.key, required this.navigationManager }); @@ -14,25 +14,25 @@ class NavigationOverlayWidget extends StatefulWidget { // 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() => _NavigationOverlayWidgetState(); + State createState() => _NavigationOverlayState(); } -class _NavigationOverlayWidgetState extends State { +class _NavigationOverlayState extends State { @override Widget build(BuildContext context) { - switch (widget.navigationManager.getCurrentStage()) { - case NavOnBus(): - // Do stuff + // switch (widget.navigationManager.getCurrentStage()) { + // case NavOnBus(): + // // Do stuff - case NavWalking(): - // TODO: Handle this case. - throw UnimplementedError(); - } - return Text(widget.navigationManager.getCurrentStage().title); + // case NavWalking(): + // // TODO: Handle this case. + // throw UnimplementedError(); + // } + return Text("Heyyyyy!!"); } } From 369e1e66fe6103204cc276547356d864209625f6 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 7 Jun 2026 14:21:38 -0700 Subject: [PATCH 038/121] create draft of NavOnBus --- .../navigation/navigation_manager.dart | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index c360452..6dd7e08 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,4 +1,7 @@ +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -11,7 +14,42 @@ class NavWalking extends NavigationStage { } class NavOnBus extends NavigationStage { - // ... + @override + String get title => "On Bus"; + + String rt; + String departureStop; + String arrivalStop; + + Trip trip; + BusRouteLine? busPath; + + NavOnBus({ + required this.rt, + required this.departureStop, + required this.arrivalStop, + required this.trip, + required this.busPath, + }); + + factory NavOnBus.init(Leg leg, Map routesCache) { + final maybeRt = leg.rt; + final maybeTrip = leg.trip; + if (maybeRt == null || + maybeTrip == null || + leg.stopTimes == null || + leg.originID == '' || + leg.destinationID == '') { + throw Exception("leg was malformed or not a bus leg"); + } + return NavOnBus( + rt: maybeRt, + departureStop: leg.originID, + arrivalStop: leg.destinationID, + trip: maybeTrip, + busPath: routesCache[maybeRt], + ); + } } class ChooseBus extends NavigationStage{ From 21ad9da74ecdea39997fc936551e41ad20ac71a7 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:16:32 +0200 Subject: [PATCH 039/121] Added some base class variables to NavigationManager --- lib/screens/map_screen.dart | 13 +-- .../navigation/navigation_manager.dart | 13 ++- lib/widgets/navigation_overlay_widget.dart | 101 +++++++++++++++++- lib/widgets/navigation_widget.dart | 2 +- 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 54cf4d4..845f026 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -336,20 +336,13 @@ class _MaizeBusCoreState extends State { 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: Text("Error loading route $route. We are aware of the issue, and it will be fixed shortly."), - content: Text(error) + 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(), diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index c360452..109e01b 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -3,7 +3,18 @@ import 'package:bluebus/services/map_layers/navigation_layer.dart'; sealed class NavigationStage { - String title = "..."; + // 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) + } class NavWalking extends NavigationStage { diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 636bf1d..c3fecb2 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -1,5 +1,7 @@ +import 'package:bluebus/constants.dart'; import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; class NavigationOverlay extends StatefulWidget { @@ -32,9 +34,104 @@ class _NavigationOverlayState extends State { // // TODO: Handle this case. // throw UnimplementedError(); // } - return Text("Heyyyyy!!"); + return Column( + + children: [ + + Container( + width: double.infinity, + + 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: 48, + ), + 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)), + "Go for a swim" + ), + Text( + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + "I don't know, dude, figure it out" + ), + ] + ) + ) + ) + ]) + ), + + + Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter + + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(8), + + 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: [ + RouteIcon.small("BB"), + Padding( + padding: EdgeInsetsGeometry.only(left: 8), + child: Text( + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + "I'm told your bus is coming" + ), + ) + + ], + ) + ), + ] + ); } } -// QUESTION: Should navigation_manager \ No newline at end of file +// 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 index b0ec4ba..c2529be 100644 --- a/lib/widgets/navigation_widget.dart +++ b/lib/widgets/navigation_widget.dart @@ -25,6 +25,6 @@ class NavigationWidgetState extends State @override Widget build(BuildContext context) { - return null; // TODO: Return some widget stuff + return Text("Hello"); // TODO: Return some widget stuff } } From 0d506c3bf44b7e0a7294b73137ec07bfd59a19d8 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 14 Jun 2026 16:42:53 -0400 Subject: [PATCH 040/121] Update navigation_manager.dart --- .../navigation/navigation_manager.dart | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index c360452..179cb37 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,4 +1,7 @@ import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/models/bus_stop.dart'; @@ -6,22 +9,28 @@ sealed class NavigationStage { String title = "..."; } -class NavWalking extends NavigationStage { - // ... -} +// class NavWalking extends NavigationStage { +// // ... +// } -class NavOnBus extends NavigationStage { - // ... -} +// class NavOnBus extends NavigationStage { +// // ... +// } class ChooseBus extends NavigationStage{ - title = "Choose a Bus"; + @override 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; + List potentialBuses = []; + List potentialStops = []; + + //TODO function that returns what it should say in the bubble (title and subtitle) // 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 +// String getTitle() returns the title displayed in the big blue box +// String getSubtitle() returns the subtitle displayed in the big blue box +// double length is the length (in minutes) of your segment +// double percent_complete } //I believe this is just NavWalking but I'm doing it here to be sure. From 9cb9c8eff7f2fb105748d27bed0b7cc3a452d6e5 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:06:58 +0200 Subject: [PATCH 041/121] Added extra notes --- .../navigation/navigation_manager.dart | 28 +++++++- lib/widgets/navigation_overlay_widget.dart | 66 ++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 67e92ba..9b74c18 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -84,12 +84,27 @@ class Walking extends NavigationStage{ } +class DemoStage extends NavigationStage { + + String getTitle() { + return "This is a demo!"; + } + + String getSubtitle() { + return "Look, here's a subtitle too"; + } + + double length = 15.0; + double percent_complete = 11.0; + +} + class NavigationManager { // TODO: Implement ChangeNotifier and learn how that works int currentStage = 0; // Stores the current navigation state index List stageList = - []; // Stores all the states for users to page back and forth + [DemoStage()]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; // Some way for the navigation widget to @@ -98,9 +113,20 @@ class NavigationManager { // Init as necessary } + // 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 + NavigationStage getCurrentStage() { return stageList[currentStage]; } // 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 } diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index c3fecb2..cce77de 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -75,11 +75,12 @@ class _NavigationOverlayState extends State { children: [ Text( style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: getColor(context, ColorType.mapButtonIcon)), - "Go for a swim" + widget.navigationManager.getCurrentStage().getTitle() ), Text( style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), - "I don't know, dude, figure it out" + // "I don't know, dude, figure it out" + widget.navigationManager.getCurrentStage().getSubtitle() ), ] ) @@ -123,6 +124,67 @@ class _NavigationOverlayState extends State { ], ) ), + + // Expanded(child: SizedBox.expand()), + // SizedBox.expand(), + + Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter + + 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: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + + child: Row( + children: [ // Navigation sections + 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), + ), + ], + ) + ) + + // Padding( + // padding: EdgeInsetsGeometry.only(left: 8), + // child: Text( + // // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.primary)), + // "I'm told your bus is coming" + // ), + // ) + + ], + ) + ), ] ); } From a69d75a6a7469485fcc4ee5209c78b2ee49d8d15 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 21 Jun 2026 15:18:24 -0400 Subject: [PATCH 042/121] modified: lib/services/navigation/navigation_manager.dart --- android/app/build.gradle.kts | 2 +- lib/services/navigation/navigation_manager.dart | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 53fbbac..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 diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 9b74c18..988b1eb 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,4 +1,6 @@ +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:google_maps_flutter/google_maps_flutter.dart'; @@ -25,8 +27,7 @@ class NavWalking extends NavigationStage { } class NavOnBus extends NavigationStage { - @override - String get title => "On Bus"; + String title = "On Bus"; String rt; String departureStop; @@ -64,10 +65,10 @@ class NavOnBus extends NavigationStage { } class ChooseBus extends NavigationStage{ - title = "Choose a Bus"; + 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; + 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 @@ -76,9 +77,9 @@ class ChooseBus extends NavigationStage{ //I believe this is just NavWalking but I'm doing it here to be sure. class Walking extends NavigationStage{ //Points in order, you can check if you are near a point to remove it from the route or start another leg - List points; + List points = []; //This could be refreshed in intervals - LatLng currWalkingPos; + LatLng? currWalkingPos; } From 147e3c39bb01f69471105e07f7c3e018e182d39c Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:31:13 +0200 Subject: [PATCH 043/121] Added example classes --- .../navigation/navigation_manager.dart | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 9b74c18..0fae3a7 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,9 +1,34 @@ +import 'dart:ui'; + import 'package:bluebus/models/bus_route_line.dart'; import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; +enum LineType { Dotted, Dashed} + +class NavigationStageStep { + String getTitle() { + return ""; + } + + String? getSubtitle() { + return null; // Return null if no subtitle + } + + String getTime() { + return "0:00"; // Get the time + } + + Color? getColor() { + return null; // Return null for neutral gray + } + + LineType getLineType() { + return LineType.Dashed; + } +} sealed class NavigationStage { // String title = "..."; //Don't use this anymore--implement getTitle() instead @@ -18,6 +43,15 @@ sealed class NavigationStage { 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 []; + } } class NavWalking extends NavigationStage { From 6fcfe24ec3d8915af799b487d7fe68d8a753148b Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:02:55 +0200 Subject: [PATCH 044/121] Got base stage switching working! --- .../navigation/navigation_manager.dart | 110 ++++++++++++- lib/widgets/navigation_overlay_widget.dart | 150 +++++++++++++++--- 2 files changed, 233 insertions(+), 27 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 84990b6..e118b19 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui'; import 'package:bluebus/models/bus.dart'; @@ -5,6 +6,7 @@ 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:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; enum LineType { Dotted, Dashed} @@ -54,6 +56,10 @@ sealed class NavigationStage { List getPolylines() { return []; } + + Color getColor() { // Return a random color + return Color(Random().nextInt(0xFFFFFFFF)).withAlpha(255); + } } class NavWalking extends NavigationStage { @@ -121,16 +127,50 @@ class Walking extends NavigationStage{ class DemoStage extends NavigationStage { + int favoriteNumber; + String getTitle() { - return "This is a demo!"; + return "This is a demo! #${favoriteNumber}"; } String getSubtitle() { - return "Look, here's a subtitle too"; + return "Look, here's a subtitle too #${favoriteNumber}"; } double length = 15.0; - double percent_complete = 11.0; + double percent_complete = 0.110; + + DemoStage({ + required this.favoriteNumber, + required this.length, + required this.percent_complete + }); + + Color getColor() { // Return a random color + return Color(Random().nextInt(0xFFFFFFFF)).withAlpha(255); + } + +} + +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 ?? []; } @@ -139,9 +179,60 @@ class NavigationManager { int currentStage = 0; // Stores the current navigation state index List stageList = - [DemoStage()]; // Stores all the states for users to page back and forth + [ + DemoStage( + favoriteNumber: 1, length: 15, percent_complete: 0.80, + ), + DemoStage( + favoriteNumber: 2, length: 33, percent_complete: 0.23, + ), + DemoStage( + favoriteNumber: 3, length: 4, percent_complete: 0.0, + ), + + ]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; + 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() { @@ -158,6 +249,17 @@ class NavigationManager { return stageList[currentStage]; } + void nextStage() { + debugPrint("Stage index: $currentStage + 1 % ${stageList.length}"); + currentStage = (currentStage + 1) % stageList.length; + debugPrint("Stage index is now $currentStage"); + } + void previousStage() { + debugPrint("Stage index: $currentStage - 1 % ${stageList.length}"); + currentStage = (currentStage - 1) % stageList.length; + debugPrint("Stage index is now $currentStage"); + } + // TODO: Add start()/stop() methods diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index cce77de..8a60ded 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -22,7 +22,20 @@ class NavigationOverlay extends StatefulWidget { class _NavigationOverlayState extends State { + TimelineInfo timelineInfo = TimelineInfo(); + void updateTimeline() { // Call this after all the stages are loaded (or stages change) + // debugPrint("***** Updating timeline!"); + timelineInfo = widget.navigationManager.getTimeline(); + // debugPrint("***** Timeline now has ${timelineSteps.length} things!"); + } + + @override + void initState() { + // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); + super.initState(); + updateTimeline(); + } @override Widget build(BuildContext context) { @@ -79,7 +92,6 @@ class _NavigationOverlayState extends State { ), Text( style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), - // "I don't know, dude, figure it out" widget.navigationManager.getCurrentStage().getSubtitle() ), ] @@ -119,8 +131,30 @@ class _NavigationOverlayState extends State { style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), "I'm told your bus is coming" ), + ), + 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) ) + + ], ) ), @@ -150,29 +184,99 @@ class _NavigationOverlayState extends State { ), child: Column( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - - child: Row( - children: [ // Navigation sections - 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), - ), - ], - ) + // TODO: Add the user's position in all of this + // ClipRRect( + // borderRadius: BorderRadius.circular(12), + + // child: + LayoutBuilder( + builder: (context, constraints) { + + const double dotSize = 24.0; + final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); + + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Padding( + padding: EdgeInsets.only(top: dotSize, bottom: dotSize), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Row( + + children: this.timelineInfo.timelineSteps.map((item) { + return Flexible( + flex: item.estimated_time.floor(), // Proportionally sizes to each item's time + child: Container( + height: 10, + 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), + // ), + ), + ), + ), + + + // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), + + // Container( + // width: dotSize, + // height: dotSize, + // decoration: const BoxDecoration( + // color: Colors.red, + // shape: BoxShape.circle + // ), + // ), + + Positioned( // TODO: Make this thing animate smoooooothly! + left: dotLeft, + // top: -dotSize / 4, + // top: -dotSize, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Color(0xFF4286F5), + border: Border.all( + color: Colors.white, + // color: Color(0x666896DD), + width: 2.0 + ), + boxShadow: [ + BoxShadow(color: Color(0x666896DD), spreadRadius: 16) + ], + shape: BoxShape.circle + ), + ), + ) + ], + ); + } ) + // ) // Padding( // padding: EdgeInsetsGeometry.only(left: 8), From b7757c09be3d6574b964f5a8e8aaeae604260e07 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:07:15 +0200 Subject: [PATCH 045/121] Added default gray color --- lib/services/navigation/navigation_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index e118b19..2443e82 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -58,7 +58,7 @@ sealed class NavigationStage { } Color getColor() { // Return a random color - return Color(Random().nextInt(0xFFFFFFFF)).withAlpha(255); + return Color(0xFFDBE4ED); } } From e4642402c673709f5d984aad36796638e698ce4b Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:11:07 +0200 Subject: [PATCH 046/121] Removed extra debug logs --- lib/services/navigation/navigation_manager.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 2443e82..1c921ae 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -250,14 +250,10 @@ class NavigationManager { } void nextStage() { - debugPrint("Stage index: $currentStage + 1 % ${stageList.length}"); currentStage = (currentStage + 1) % stageList.length; - debugPrint("Stage index is now $currentStage"); } void previousStage() { - debugPrint("Stage index: $currentStage - 1 % ${stageList.length}"); currentStage = (currentStage - 1) % stageList.length; - debugPrint("Stage index is now $currentStage"); } // TODO: Add start()/stop() methods From fc45e4fdfde9f1c88ec04157cb8e85335ea6e52c Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Tue, 23 Jun 2026 11:20:10 -0700 Subject: [PATCH 047/121] feat, refactor: consolidate geometric helpers into a separate file, add continuous point to poly-line projection, use radian/degree constants from vector_math The functionality moved to lib/utils/geometry.dart - haversine distance - nearest point on polyline to point - pointRotation (looks like a bearing function) Added functionality - a continuous version of nearest point on polyline to point (still mostly* untested) - the intermediate steps for this continuous calculation are also available for use (also mostly* untested) *a previous iteration of this was tested a bit and can be found in the navigation-prototype branch, but in porting it over I reworked it quite a bit Other changes - use the constants from vector_math instead of pi / 180 and 180 / pi --- lib/bluebus_api.dart | 25 +--- lib/screens/map_screen.dart | 29 +---- lib/services/map_layers/journey_layer.dart | 56 +-------- lib/theride_api.dart | 22 +--- lib/utils/geometry.dart | 135 +++++++++++++++++++++ pubspec.yaml | 1 + 6 files changed, 146 insertions(+), 122 deletions(-) create mode 100644 lib/utils/geometry.dart diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index c81d5e1..1f593e3 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -1,6 +1,4 @@ import 'dart:convert'; -import 'dart:math' as Math; -import 'package:flutter/cupertino.dart'; import 'package:http/http.dart' as http; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'constants.dart'; @@ -8,28 +6,7 @@ import 'models/bus_stop.dart'; import 'models/bus.dart'; import 'models/bus_route_line.dart'; import 'services/route_color_service.dart'; -import 'package:bluebus/widgets/dialog.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; -} +import 'utils/geometry.dart'; class BlueBusApi { static const String baseUrl = BACKEND_URL; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 845f026..ce4bf46 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,9 +1,7 @@ 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 'package:bluebus/globals.dart'; import 'package:bluebus/models/bus_stop.dart'; import 'package:bluebus/providers/theme_provider.dart'; @@ -34,12 +32,11 @@ 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 '../widgets/map_widget.dart'; +import 'package:vector_math/vector_math_64.dart' as vec_math; import '../widgets/route_selector_modal.dart'; import '../widgets/favorites_sheet.dart'; import '../models/bus.dart'; import '../models/bus_route_line.dart'; -//import '../models/bus_stop.dart'; import '../models/journey.dart'; import '../providers/bus_provider.dart'; import '../services/route_color_service.dart'; @@ -47,32 +44,10 @@ import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; import 'package:screen_corner_radius/screen_corner_radius.dart'; -//import 'dart:convert'; 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; -} - class MaizeBusCore extends StatefulWidget { const MaizeBusCore({super.key}); @@ -1769,7 +1744,7 @@ class _MaizeBusCoreState extends State { ? (-_currentCameraPos! .bearing - 45) * - (math.pi / 180) + vec_math.degrees2Radians : 0, child: Icon( FontAwesomeIcons.compass, diff --git a/lib/services/map_layers/journey_layer.dart b/lib/services/map_layers/journey_layer.dart index e45c546..a32b8d1 100644 --- a/lib/services/map_layers/journey_layer.dart +++ b/lib/services/map_layers/journey_layer.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:bluebus/constants.dart'; import 'package:bluebus/globals.dart'; import 'package:bluebus/models/bus.dart'; @@ -8,11 +6,12 @@ import 'package:bluebus/models/journey.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:bluebus/widgets/route_icon.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; @@ -110,42 +109,6 @@ class JourneyLayer extends CompositeMapLayer { } } - // 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; - } - - // 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; - } - } - 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( @@ -153,20 +116,13 @@ class JourneyLayer extends CompositeMapLayer { LatLng start, LatLng end, ) { - // debugPrint("extractRouteSegment call!!!"); - final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); - final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); - // debugPrint("*** sRes = ${sRes}, eRes = ${eRes}"); - final si = sRes[0] as int; - final ei = eRes[0] as int; - final sDist = sRes[1] as double; - final eDist = eRes[1] as double; + 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) + if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) { return null; - - // debugPrint("We have valid coords!"); + } if (si == ei) return null; diff --git a/lib/theride_api.dart b/lib/theride_api.dart index 545446d..4f716dd 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -1,5 +1,6 @@ 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 +9,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; diff --git a/lib/utils/geometry.dart b/lib/utils/geometry.dart new file mode 100644 index 0000000..e2c1a1b --- /dev/null +++ b/lib/utils/geometry.dart @@ -0,0 +1,135 @@ +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; +} + +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/pubspec.yaml b/pubspec.yaml index 64ddfa1..329297e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: 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: From 222935d244fe75d7cad5869bed55894cef1cad50 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Tue, 23 Jun 2026 16:12:56 -0700 Subject: [PATCH 048/121] feat: get mock journey from mock backend --- lib/models/journey.dart | 13 +++++++++++++ lib/services/navigation/navigation_manager.dart | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/models/journey.dart b/lib/models/journey.dart index 1742bb1..0ba9676 100644 --- a/lib/models/journey.dart +++ b/lib/models/journey.dart @@ -16,6 +16,7 @@ class Journey { } } +// I really want to turn this into a sum type... (sealed class + two subclasses) class Leg { final String origin; final String destination; @@ -28,6 +29,7 @@ class Leg { final String originID; final String destinationID; final List? pathCoords; + final Map? directions; Leg({ required this.origin, @@ -41,6 +43,7 @@ class Leg { required this.originID, required this.destinationID, this.pathCoords, + this.directions, }); factory Leg.fromJson(Map json) { @@ -66,10 +69,20 @@ 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 ); } } +typedef Turn = ({double degree, String landmark}); + class StopTime { final String stop; final int arrivalTime; diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 1c921ae..e6ea787 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -5,6 +5,7 @@ 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/journey_repository.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -263,3 +264,14 @@ class NavigationManager { // - 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 } + +Future getMockJourney() async { + // using the same start / end as the backend test + // make sure BACKEND_URL is set to the mock backend + final journeys = await JourneyRepository.planJourney( + originLat: 42.264356, originLon: -83.744353999999, + destLat: 42.268067999999, destLon: -83.747307000001 + ); + return journeys[0]; +} + From 2a67de0d41279ab25cbc6571310f0344d15dc204 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Tue, 23 Jun 2026 19:53:48 -0400 Subject: [PATCH 049/121] oops class from prior --- lib/services/notification_service.dart | 1 + 1 file changed, 1 insertion(+) 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; From fce3cf92bf6a438976ee9082ad4b0bcfd8f0f143 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Tue, 23 Jun 2026 19:54:45 -0400 Subject: [PATCH 050/121] updates for oops class --- .../navigation/navigation_manager.dart | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 67e92ba..91c2d74 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,6 +1,7 @@ import 'package:bluebus/models/bus_route_line.dart'; import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:flutter/semantics.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -83,6 +84,48 @@ class Walking extends NavigationStage{ } +// oops stage +// TODOs: +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, + }); + + // Core functionality + TODOs for Allen + // Main objectives for the "oops" stage: + // - Acknowledge to user that they have missed expected bus + // - Based on logic: immediately ask user to get off on next stop + // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally + + // Data Structure Implementation + // What we need: + // - hangon... + +} + class NavigationManager { // TODO: Implement ChangeNotifier and learn how that works From c4a142373bb25e01921ba9e8a914033c4e52c628 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Tue, 23 Jun 2026 20:02:44 -0400 Subject: [PATCH 051/121] staged changes... see prior commit message --- .../navigation/navigation_manager.dart | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 9c359c6..c02b899 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -167,49 +167,6 @@ class MissedBus extends NavigationStage { } -// oops stage -// TODOs: -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, - }); - - // Core functionality + TODOs for Allen - // Main objectives for the "oops" stage: - // - Acknowledge to user that they have missed expected bus - // - Based on logic: immediately ask user to get off on next stop - // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally - - // Data Structure Implementation - // What we need: - // - hangon... - -} - - class DemoStage extends NavigationStage { int favoriteNumber; From 4561e84d09a53d95bf47f2c5bcf38067172ff7db Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:53:25 +0200 Subject: [PATCH 052/121] Added prelimiary marker/polyline support Added prelimiary marker/polyline support for the navigation view --- lib/screens/map_screen.dart | 5 ++++ lib/services/map_layers/navigation_layer.dart | 23 +++++++++++-------- .../navigation/navigation_manager.dart | 20 ++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 845f026..14c1805 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -12,6 +12,7 @@ import 'package:bluebus/services/map_image_service.dart'; import 'package:bluebus/services/map_layers/base_routes_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'; @@ -162,6 +163,7 @@ class _MaizeBusCoreState extends State { final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); final JourneyLayer journeyLayer = JourneyLayer(); + final NavigationLayer navigationLayer = NavigationLayer(); // GoogleMaps styles String _darkMapStyle = "{}"; @@ -187,6 +189,9 @@ class _MaizeBusCoreState extends State { context, ); + navigationManager.setMapLayer(navigationLayer); + navigationLayer.init(); + hideJourney(); // Hide the journey layer until we're ready to use it WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index 2d5385c..5426ef7 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -21,27 +21,32 @@ class NavigationLayer extends CompositeMapLayer { }; void init( - Set favoriteStops_in, - Set selectedRoutes_in, - Function(BusStop) onStopClicked_in, ) { //... } void reload() { - reloadMarkers(); - reloadPolylines(); + // reloadMarkers(); + // reloadPolylines(); if (isVisible) onUpdate(); } - void reloadMarkers() { - //... + void setMarkers(Set markers_in) { + this.markers = markers_in; } - void reloadPolylines() { - //... + 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 index 1c921ae..4dc7c18 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -193,6 +193,10 @@ class NavigationManager { ]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; + void setMapLayer(NavigationLayer mapLayer_in) { + this.mapLayer = mapLayer_in; + } + TimelineInfo getTimeline() { // TODO: Also return the user's position in the whole journey @@ -245,6 +249,22 @@ class NavigationManager { // 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() { + 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]; } From 7187f1d767e2898ca468826deb619e3e25258a30 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:51:49 +0200 Subject: [PATCH 053/121] Finished demo stage! --- lib/screens/map_screen.dart | 10 +++- lib/services/map_layers/navigation_layer.dart | 1 + .../navigation/navigation_manager.dart | 59 +++++++++++++++++-- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4142ba0..9dfe071 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -156,6 +156,9 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); + // debugPrint("MAP SCREEN INITSTATE==================="); + navigationManager.init(); + baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); journeyLayer.init( _showBusSheet, @@ -1409,9 +1412,10 @@ class _MaizeBusCoreState extends State { child: CompositeMapWidget( initialCenter: startLatLng, mapLayers: [ - baseRoutesLayer, - liveBusesLayer, - journeyLayer, + // baseRoutesLayer, + // liveBusesLayer, + // journeyLayer, + navigationLayer ], onMapCreated: _onMapCreated, ), diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index 5426ef7..c216d5d 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -28,6 +28,7 @@ class NavigationLayer extends CompositeMapLayer { void reload() { // reloadMarkers(); // reloadPolylines(); + debugPrint("**** RELOADING NAVIGATIONLAYER, we have ${markers.length} markers and ${polylines} polylines"); if (isVisible) onUpdate(); } diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 436bdaa..04117f7 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -183,14 +183,47 @@ class DemoStage extends NavigationStage { double length = 15.0; double percent_complete = 0.110; + LatLng startPoint; + LatLng endPoint; + DemoStage({ required this.favoriteNumber, required this.length, - required this.percent_complete + required this.percent_complete, + required this.startPoint, + required this.endPoint }); Color getColor() { // Return a random color - return Color(Random().nextInt(0xFFFFFFFF)).withAlpha(255); + // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber + const double golden = 0.618033988749895; + final double hue = ((this.favoriteNumber.hashCode * golden) % 1.0).abs() * 360; + return HSLColor.fromAHSL(1.0, hue, 0.65, 0.55).toColor(); + } + + 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 + ) + ]; + } + List getPolylines() { + return [ + Polyline( + polylineId: PolylineId("${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}"), + points: [ + this.startPoint, + this.endPoint + ], + color: this.getColor() + ) + ]; } } @@ -224,13 +257,25 @@ class NavigationManager { List stageList = [ DemoStage( - favoriteNumber: 1, length: 15, percent_complete: 0.80, + favoriteNumber: 1, + length: 15, + percent_complete: 0.80, + startPoint: LatLng(42.281973, -83.765719), + endPoint: LatLng(42.281291, -83.743918) ), DemoStage( - favoriteNumber: 2, length: 33, percent_complete: 0.23, + favoriteNumber: 2, + length: 33, + percent_complete: 0.23, + startPoint: LatLng(42.281291, -83.743918), + endPoint: LatLng(42.287031, -83.743532), ), DemoStage( - favoriteNumber: 3, length: 4, percent_complete: 0.0, + favoriteNumber: 3, + length: 4, + percent_complete: 0.0, + startPoint: LatLng(42.287031, -83.743532), + endPoint: LatLng(42.289689, -83.738435) ), ]; // Stores all the states for users to page back and forth @@ -238,6 +283,7 @@ class NavigationManager { void setMapLayer(NavigationLayer mapLayer_in) { this.mapLayer = mapLayer_in; + rebuildMarkersAndPolylines(); } TimelineInfo getTimeline() { @@ -284,6 +330,7 @@ class NavigationManager { 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) @@ -292,7 +339,7 @@ class NavigationManager { // 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() { + 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; From 041279f0adaab3176677ccaa9f6bdff9c244721f Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 28 Jun 2026 15:33:28 -0400 Subject: [PATCH 054/121] modified: lib/bluebus_api.dart modified: lib/screens/map_screen.dart --- lib/bluebus_api.dart | 11 +- lib/screens/map_screen.dart | 1372 ++++++++++++++++++++++++++--------- 2 files changed, 1047 insertions(+), 336 deletions(-) diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index c81d5e1..e30e6c7 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -14,7 +14,7 @@ import 'package:bluebus/widgets/dialog.dart'; // (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 / π + const double radToDeg = 57.29577951308232; // 180 / π double dLat = lat2 - lat1; double dLon = lon2 - lon1; @@ -166,9 +166,7 @@ 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 = []; @@ -193,11 +191,10 @@ 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/screens/map_screen.dart b/lib/screens/map_screen.dart index 845f026..41fa536 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,22 +5,14 @@ import 'dart:math' as Math; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:bluebus/globals.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/journey_layer.dart'; -import 'package:bluebus/services/map_layers/live_buses_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/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'; @@ -46,7 +38,6 @@ import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; -import 'package:screen_corner_radius/screen_corner_radius.dart'; //import 'dart:convert'; final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-16 00:00:00Z"); @@ -73,6 +64,21 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { 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}); @@ -81,12 +87,8 @@ class MaizeBusCore extends StatefulWidget { } class _MaizeBusCoreState extends State { - late bool canVibrate = false; + late bool canVibrate; late Journey currDisplayed; - ScreenRadius? screenRadius; - bool screenRadiusLoaded = false; - - NavigationManager navigationManager = NavigationManager(); Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( @@ -95,12 +97,10 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; CameraPosition? _currentCameraPos; bool? _userLocVisible; - static const _defaultCenter = LatLng(42.276463, -83.7374598); - static LatLng startLatLng = _defaultCenter; + static const LatLng _defaultCenter = LatLng(42.276463, -83.7374598); Set _displayedPolylines = {}; - Map _displayedStopMarkers = {}; // maps from stopID to marker - Map _displayedFavoriteStopMarkers = {}; + Set _displayedStopMarkers = {}; Set _displayedBusMarkers = {}; // Journey overlays for search results Set _displayedJourneyPolylines = {}; @@ -113,16 +113,12 @@ 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; @@ -130,17 +126,16 @@ 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 = - {}; // maps from route to a map of stopID to marker + final Map> _routeStopMarkers = {}; // Whether a journey search overlay is currently active (shows only journey path) bool _journeyOverlayActive = 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 @@ -159,10 +154,6 @@ class _MaizeBusCoreState extends State { // store persistent bottom sheet controller PersistentBottomSheetController? _bottomSheetController; - final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); - final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); - final JourneyLayer journeyLayer = JourneyLayer(); - // GoogleMaps styles String _darkMapStyle = "{}"; String _lightMapStyle = "{}"; @@ -179,36 +170,16 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); - baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); - journeyLayer.init( - _showBusSheet, - _activeJourneyBusIds, - _activeJourneyRoutes, - context, - ); - - 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) { @@ -220,23 +191,6 @@ class _MaizeBusCoreState extends State { }); } - void onStopClicked(BusStop stop) { - try { - Haptics.vibrate(HapticsType.light); - } catch (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(); @@ -283,21 +237,7 @@ class _MaizeBusCoreState extends State { Future _loadAllData() async { ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); - await theme.loadTheme(); - - screenRadius = await ScreenCornerRadius.get(); // load screen radius - screenRadiusLoaded = true; - - //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); - } - } + await theme.loadTheme(); // load user theme data canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -328,21 +268,21 @@ class _MaizeBusCoreState extends State { if (startupData.persistantMessageTitle != '') { showMaizebusOKDialog( contextIn: context, - title: startupData.persistantMessageTitle, - content: startupData.persistantMessage, + title: Text(startupData.persistantMessageTitle), + content: Text(startupData.persistantMessage), ); } 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 + title: Text("Error loading route $route. We are aware of the issue, and it will be fixed shortly."), + content: Text(error) ); // loading all this data in parallel await Future.wait([ - // _loadCustomMarkers(), + _loadCustomMarkers(), busProvider.loadRoutes(onBusError), _loadSelectedRoutes(), _loadFavoriteStops(), @@ -350,14 +290,10 @@ class _MaizeBusCoreState extends State { // actions that depend on the data loaded earlier _loadingMessageNotifier.value = Loadpoint('Loading bus images...', 2); - await MapImageService.loadData(); - // await _loadRouteSpecificBusIcons(); + 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(); @@ -400,7 +336,7 @@ class _MaizeBusCoreState extends State { final stopList = jsonDecode(response.body) as List; return stopList.map((stop) { - final name = normalizeStopName(stop['name'] as String); + final name = stop['name'] as String; final aliases = [ name.split(' ').map((w) => w.isNotEmpty ? w[0] : '').join(), ]; @@ -463,6 +399,126 @@ 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 { @@ -493,6 +549,107 @@ 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(); @@ -545,8 +702,6 @@ 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)) { @@ -583,7 +738,13 @@ class _MaizeBusCoreState extends State { final name = RouteColorService.getRouteName(r.routeId); routeIdToName[r.routeId] = name; - MapImageService.ensureRouteIconIsLoaded(r.routeId); + // 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); + } + } } } setState(() { @@ -610,59 +771,51 @@ class _MaizeBusCoreState extends State { ); } if (!_routeStopMarkers.containsKey(routeKey)) { - _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; - } + _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(); } } } @@ -676,8 +829,6 @@ 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 {} @@ -692,8 +843,6 @@ 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); } @@ -702,81 +851,55 @@ 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) { - // 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 + 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; }); - // 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) 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; - } - }); + if (stops != null) selectedStopMarkers.addAll(stops); } } + _displayedStopMarkers = selectedStopMarkers; + _updateAllDisplayedMarkers(); }); } void _updateDisplayedRoutes() { final selectedPolylines = {}; - final selectedStopMarkers = {}; + final selectedStopMarkers = {}; for (final routeId in _selectedRoutes) { // Find all variants of this route @@ -788,27 +911,115 @@ class _MaizeBusCoreState extends State { final polyline = _routePolylines[routeKey]; if (polyline != null) selectedPolylines.add(polyline); final stops = _routeStopMarkers[routeKey]; - if (stops == null) continue; - - stops.forEach((key, value) { - if (!selectedStopMarkers.containsKey(key)) { - selectedStopMarkers[key] = value; - } - }); + if (stops != null) { + selectedStopMarkers.addAll(stops); + } } } - baseRoutesLayer.reload(); - liveBusesLayer.reload(); - + setState(() { + _displayedPolylines = selectedPolylines; + _displayedStopMarkers = selectedStopMarkers; + _updateAllDisplayedMarkers(); + }); _updateDisplayedBuses( Provider.of(context, listen: false).buses, ); } void _updateDisplayedBuses(List allBuses) { - journeyLayer.refreshLiveBusMarkers(allBuses); - liveBusesLayer.reload(); + // 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; } // Show a red pin marker at search location @@ -828,6 +1039,28 @@ 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(); @@ -846,14 +1079,53 @@ 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, @@ -869,9 +1141,8 @@ class _MaizeBusCoreState extends State { setState(() { _selectedRoutes.clear(); _selectedRoutes.addAll(newSelection); - baseRoutesLayer.reload(); }); - // _updateDisplayedRoutes(); + _updateDisplayedRoutes(); // Save the new selection await _saveSelectedRoutes(); @@ -956,9 +1227,6 @@ class _MaizeBusCoreState extends State { ); }, ); - _bottomSheetController?.closed.then((_) { - hideJourney(); - }); } void _showDirectionsSheet( @@ -1033,19 +1301,10 @@ class _MaizeBusCoreState extends State { } }, onSelectJourney: (journey) { - currDisplayed = journey; - showJourney(); - journeyLayer.setJourney( + _displayJourneyOnMap( 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 @@ -1058,9 +1317,6 @@ class _MaizeBusCoreState extends State { ); }, ); - _bottomSheetController?.closed.then((_) { - hideJourney(); - }); } _showJourneySheetOnReopen() { @@ -1099,42 +1355,442 @@ class _MaizeBusCoreState extends State { }, ); }, - ).whenComplete(() { - hideJourney(); - }); + ); } - void showJourney() { - journeyLayer.isVisible = true; - baseRoutesLayer.isVisible = false; - liveBusesLayer.isVisible = false; - } + // 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'); + } + } - void hideJourney() { - journeyLayer.isVisible = false; - baseRoutesLayer.isVisible = true; - liveBusesLayer.isVisible = true; + 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(); + }); + + // 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 _onMapCreated(GoogleMapController controller) { - _mapController = controller; + // 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 _onCameraMove(CameraPosition position) async { - _currentCameraPos = position; + // 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 _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), - ); + // 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; } } + 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 _showBusSheet(String busID) { @@ -1161,8 +1817,8 @@ class _MaizeBusCoreState extends State { } else { showMaizebusOKDialog( contextIn: context, - title: "Error", - content: "Couldn't load stop.", + title: const Text("Error"), + content: const Text("Couldn't load stop."), ); } }, @@ -1187,8 +1843,8 @@ class _MaizeBusCoreState extends State { } else { showMaizebusOKDialog( contextIn: context, - title: 'Error', - content: 'Couldn\'t load stop.', + title: const Text('Error'), + content: const Text('Couldn\'t load stop.'), ); } }, @@ -1216,11 +1872,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); }, @@ -1239,9 +1895,7 @@ class _MaizeBusCoreState extends State { }, ); }, - ).then((_) { - hideJourney(); - }); // Hide any displayed journey when the sheet is closed + ).then((_) {}); } // lighter function for when we need to get location @@ -1273,9 +1927,6 @@ class _MaizeBusCoreState extends State { ), ); return null; - } else { - //Center map once right after user grants location permissions - _centerOnLocation(true); } } @@ -1358,43 +2009,56 @@ 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; - - // 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; + // 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 { - // perfect padding is perfect! it keeps the buttons - // out of the safe area so we'll just use them - globalBottomPadding = perfectPadding; - globalLeftRightPadding = perfectPadding; + // 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; + } } - // only set this to true if we've loaded the screen radius - globallPaddingHasBeenSet = screenRadiusLoaded; + globallPaddingHasBeenSet = true; } return FutureBuilder( @@ -1413,7 +2077,10 @@ class _MaizeBusCoreState extends State { // lets us prevent back button on map page canPop: false, onPopInvokedWithResult: (didPop, result) { - hideJourney(); // Hide the journey if it's showing right now + // when journey is showing and pop was attempted, clear journey + if (_journeyOverlayActive) { + _clearJourneyOverlays(); + } // If showing a persistent bottom sheet, close it. // Fix android back button for buildings sheet and journey sheet (doesn't work without this) @@ -1425,17 +2092,68 @@ class _MaizeBusCoreState extends State { }, child: Stack( children: [ - RepaintBoundary( - child: CompositeMapWidget( - initialCenter: startLatLng, - mapLayers: [ - baseRoutesLayer, - liveBusesLayer, - journeyLayer, - ], - onMapCreated: _onMapCreated, - ), - ), + // 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, + ), + Padding( padding: EdgeInsets.only( top: globalTopPadding, @@ -1604,6 +2322,9 @@ 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( @@ -1697,11 +2418,6 @@ class _MaizeBusCoreState extends State { ), ), - - NavigationOverlay(navigationManager: navigationManager), - - - // reminder widget SizedBox(height: 30.0), _journeyOverlayActive || _isOffline @@ -1716,6 +2432,7 @@ class _MaizeBusCoreState extends State { Spacer(), + // temp row (might add settings button to it later) (!_journeyOverlayActive) ? Padding( padding: const EdgeInsets.only(bottom: 20), @@ -1915,10 +2632,7 @@ class _MaizeBusCoreState extends State { ), ), child: ElevatedButton.icon( - onPressed: () { - hideJourney(); - // _clearJourneyOverlays - }, + onPressed: _clearJourneyOverlays, style: ElevatedButton.styleFrom( backgroundColor: getColor( context, @@ -1992,7 +2706,7 @@ class _MaizeBusCoreState extends State { ); } _showBusRoutesModal( - _busProviderRef!.routes, + busProvider.routes, ); }, heroTag: 'routes_fab', From 0be13403f45a99e8303200f019cb981863c6e56b Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 28 Jun 2026 16:04:15 -0400 Subject: [PATCH 055/121] Revert "Merge branch 'navigation-hub' of https://github.com/mbusdev/bluebus-flutter into navigation-hub" This reverts commit 5bf08b691313ff9bb6adbf295aaf9cea8e6ccf16, reversing changes made to 041279f0adaab3176677ccaa9f6bdff9c244721f. --- lib/bluebus_api.dart | 25 +- lib/models/journey.dart | 13 - lib/screens/map_screen.dart | 146 +++++++--- lib/services/map_layers/journey_layer.dart | 56 +++- lib/services/map_layers/navigation_layer.dart | 24 +- .../navigation/navigation_manager.dart | 261 +----------------- lib/services/notification_service.dart | 1 - lib/theride_api.dart | 22 +- lib/utils/geometry.dart | 135 --------- lib/widgets/navigation_overlay_widget.dart | 150 ++-------- pubspec.yaml | 1 - 11 files changed, 235 insertions(+), 599 deletions(-) delete mode 100644 lib/utils/geometry.dart diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index b2a4513..e30e6c7 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:math' as Math; +import 'package:flutter/cupertino.dart'; import 'package:http/http.dart' as http; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'constants.dart'; @@ -6,7 +8,28 @@ import 'models/bus_stop.dart'; import 'models/bus.dart'; import 'models/bus_route_line.dart'; import 'services/route_color_service.dart'; -import 'utils/geometry.dart'; +import 'package:bluebus/widgets/dialog.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 BlueBusApi { static const String baseUrl = BACKEND_URL; diff --git a/lib/models/journey.dart b/lib/models/journey.dart index 0ba9676..1742bb1 100644 --- a/lib/models/journey.dart +++ b/lib/models/journey.dart @@ -16,7 +16,6 @@ class Journey { } } -// I really want to turn this into a sum type... (sealed class + two subclasses) class Leg { final String origin; final String destination; @@ -29,7 +28,6 @@ class Leg { final String originID; final String destinationID; final List? pathCoords; - final Map? directions; Leg({ required this.origin, @@ -43,7 +41,6 @@ class Leg { required this.originID, required this.destinationID, this.pathCoords, - this.directions, }); factory Leg.fromJson(Map json) { @@ -69,20 +66,10 @@ 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 ); } } -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 6de0666..41fa536 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,16 +1,12 @@ 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 'package:bluebus/globals.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/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/dialog.dart'; @@ -30,22 +26,59 @@ 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'; import '../models/bus.dart'; import '../models/bus_route_line.dart'; +//import '../models/bus_stop.dart'; import '../models/journey.dart'; import '../providers/bus_provider.dart'; import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; -import 'package:screen_corner_radius/screen_corner_radius.dart'; +//import 'dart:convert'; 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}); @@ -121,11 +154,6 @@ 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(); - // GoogleMaps styles String _darkMapStyle = "{}"; String _lightMapStyle = "{}"; @@ -142,22 +170,6 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); - // debugPrint("MAP SCREEN INITSTATE==================="); - navigationManager.init(); - - baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); - journeyLayer.init( - _showBusSheet, - _activeJourneyBusIds, - _activeJourneyRoutes, - context, - ); - - navigationManager.setMapLayer(navigationLayer); - navigationLayer.init(); - - hideJourney(); // Hide the journey layer until we're ready to use it - WidgetsBinding.instance.addPostFrameCallback((_) { try { _busProviderRef = Provider.of(context, listen: false); @@ -2080,18 +2092,68 @@ class _MaizeBusCoreState extends State { }, child: Stack( children: [ - RepaintBoundary( - child: CompositeMapWidget( - initialCenter: startLatLng, - mapLayers: [ - // baseRoutesLayer, - // liveBusesLayer, - // journeyLayer, - navigationLayer - ], - onMapCreated: _onMapCreated, - ), - ), + // 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, + ), + Padding( padding: EdgeInsets.only( top: globalTopPadding, @@ -2424,7 +2486,7 @@ class _MaizeBusCoreState extends State { ? (-_currentCameraPos! .bearing - 45) * - vec_math.degrees2Radians + (math.pi / 180) : 0, child: Icon( FontAwesomeIcons.compass, diff --git a/lib/services/map_layers/journey_layer.dart b/lib/services/map_layers/journey_layer.dart index a32b8d1..e45c546 100644 --- a/lib/services/map_layers/journey_layer.dart +++ b/lib/services/map_layers/journey_layer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:bluebus/constants.dart'; import 'package:bluebus/globals.dart'; import 'package:bluebus/models/bus.dart'; @@ -6,12 +8,11 @@ import 'package:bluebus/models/journey.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:bluebus/widgets/route_icon.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; @@ -109,6 +110,42 @@ class JourneyLayer extends CompositeMapLayer { } } + // 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; + } + + // 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; + } + } + 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( @@ -116,13 +153,20 @@ class JourneyLayer extends CompositeMapLayer { LatLng start, LatLng end, ) { - final (si, sDist) = start.nearestPolylineIndexAndDistanceDiscrete(poly); - final (ei, eDist) = end.nearestPolylineIndexAndDistanceDiscrete(poly); + // debugPrint("extractRouteSegment call!!!"); + final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); + final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); + // debugPrint("*** sRes = ${sRes}, eRes = ${eRes}"); + 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) { + if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) return null; - } + + // debugPrint("We have valid coords!"); if (si == ei) return null; diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index c216d5d..2d5385c 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -21,33 +21,27 @@ class NavigationLayer extends CompositeMapLayer { }; void init( + Set favoriteStops_in, + Set selectedRoutes_in, + Function(BusStop) onStopClicked_in, ) { //... } void reload() { - // reloadMarkers(); - // reloadPolylines(); - debugPrint("**** RELOADING NAVIGATIONLAYER, we have ${markers.length} markers and ${polylines} polylines"); + reloadMarkers(); + reloadPolylines(); if (isVisible) onUpdate(); } - void setMarkers(Set markers_in) { - this.markers = markers_in; + void reloadMarkers() { + //... } - void setPolylines(Set polylines_in) { - this.polylines = polylines_in; + void reloadPolylines() { + //... } - // 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 index 04117f7..988b1eb 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,40 +1,11 @@ -import 'dart:math'; -import 'dart:ui'; - 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/journey_repository.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; -import 'package:flutter/semantics.dart'; -import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; -enum LineType { Dotted, Dashed} - -class NavigationStageStep { - String getTitle() { - return ""; - } - - String? getSubtitle() { - return null; // Return null if no subtitle - } - - String getTime() { - return "0:00"; // Get the time - } - - Color? getColor() { - return null; // Return null for neutral gray - } - LineType getLineType() { - return LineType.Dashed; - } - -} sealed class NavigationStage { // String title = "..."; //Don't use this anymore--implement getTitle() instead @@ -49,19 +20,6 @@ sealed class NavigationStage { 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 a random color - return Color(0xFFDBE4ED); - } } class NavWalking extends NavigationStage { @@ -126,127 +84,19 @@ class Walking extends NavigationStage{ } -// oops stage -// TODOs: -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, - }); - - // Core functionality + TODOs for Allen - // Main objectives for the "oops" stage: - // - Acknowledge to user that they have missed expected bus - // - Based on logic: immediately ask user to get off on next stop - // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally - - // Data Structure Implementation - // What we need: - // - hangon... - -} class DemoStage extends NavigationStage { - int favoriteNumber; - String getTitle() { - return "This is a demo! #${favoriteNumber}"; + return "This is a demo!"; } String getSubtitle() { - return "Look, here's a subtitle too #${favoriteNumber}"; + return "Look, here's a subtitle too"; } double length = 15.0; - double percent_complete = 0.110; - - LatLng startPoint; - LatLng endPoint; - - DemoStage({ - required this.favoriteNumber, - required this.length, - required this.percent_complete, - required this.startPoint, - required this.endPoint - }); - - Color getColor() { // Return a random color - // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber - const double golden = 0.618033988749895; - final double hue = ((this.favoriteNumber.hashCode * golden) % 1.0).abs() * 360; - return HSLColor.fromAHSL(1.0, hue, 0.65, 0.55).toColor(); - } - - 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 - ) - ]; - } - List getPolylines() { - return [ - Polyline( - polylineId: PolylineId("${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}"), - points: [ - this.startPoint, - this.endPoint - ], - color: this.getColor() - ) - ]; - } - -} - -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 ?? []; + double percent_complete = 11.0; } @@ -255,82 +105,13 @@ class NavigationManager { 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) - ), - DemoStage( - favoriteNumber: 2, - length: 33, - percent_complete: 0.23, - startPoint: LatLng(42.281291, -83.743918), - endPoint: LatLng(42.287031, -83.743532), - ), - DemoStage( - favoriteNumber: 3, - length: 4, - percent_complete: 0.0, - startPoint: LatLng(42.287031, -83.743532), - endPoint: LatLng(42.289689, -83.738435) - ), - - ]; // Stores all the states for users to page back and forth + [DemoStage()]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; - 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) @@ -339,33 +120,10 @@ class NavigationManager { // 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; - } - void previousStage() { - currentStage = (currentStage - 1) % stageList.length; - } - // TODO: Add start()/stop() methods @@ -373,14 +131,3 @@ class NavigationManager { // - 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 } - -Future getMockJourney() async { - // using the same start / end as the backend test - // make sure BACKEND_URL is set to the mock backend - final journeys = await JourneyRepository.planJourney( - originLat: 42.264356, originLon: -83.744353999999, - destLat: 42.268067999999, destLon: -83.747307000001 - ); - return journeys[0]; -} - diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 4f97a96..14b0277 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -10,7 +10,6 @@ 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 4f716dd..545446d 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -1,6 +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'; @@ -9,6 +8,27 @@ 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; diff --git a/lib/utils/geometry.dart b/lib/utils/geometry.dart deleted file mode 100644 index e2c1a1b..0000000 --- a/lib/utils/geometry.dart +++ /dev/null @@ -1,135 +0,0 @@ -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; -} - -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/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 8a60ded..cce77de 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -22,20 +22,7 @@ class NavigationOverlay extends StatefulWidget { class _NavigationOverlayState extends State { - TimelineInfo timelineInfo = TimelineInfo(); - void updateTimeline() { // Call this after all the stages are loaded (or stages change) - // debugPrint("***** Updating timeline!"); - timelineInfo = widget.navigationManager.getTimeline(); - // debugPrint("***** Timeline now has ${timelineSteps.length} things!"); - } - - @override - void initState() { - // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); - super.initState(); - updateTimeline(); - } @override Widget build(BuildContext context) { @@ -92,6 +79,7 @@ class _NavigationOverlayState extends State { ), Text( style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + // "I don't know, dude, figure it out" widget.navigationManager.getCurrentStage().getSubtitle() ), ] @@ -131,30 +119,8 @@ class _NavigationOverlayState extends State { style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), "I'm told your bus is coming" ), - ), - 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) ) - - ], ) ), @@ -184,99 +150,29 @@ class _NavigationOverlayState extends State { ), child: Column( children: [ - // TODO: Add the user's position in all of this - // ClipRRect( - // borderRadius: BorderRadius.circular(12), - - // child: - LayoutBuilder( - builder: (context, constraints) { - - const double dotSize = 24.0; - final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); - - - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Padding( - padding: EdgeInsets.only(top: dotSize, bottom: dotSize), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Row( - - children: this.timelineInfo.timelineSteps.map((item) { - return Flexible( - flex: item.estimated_time.floor(), // Proportionally sizes to each item's time - child: Container( - height: 10, - 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), - // ), - ), - ), - ), - - - // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), - - // Container( - // width: dotSize, - // height: dotSize, - // decoration: const BoxDecoration( - // color: Colors.red, - // shape: BoxShape.circle - // ), - // ), - - Positioned( // TODO: Make this thing animate smoooooothly! - left: dotLeft, - // top: -dotSize / 4, - // top: -dotSize, - child: Container( - width: dotSize, - height: dotSize, - decoration: BoxDecoration( - color: Color(0xFF4286F5), - border: Border.all( - color: Colors.white, - // color: Color(0x666896DD), - width: 2.0 - ), - boxShadow: [ - BoxShadow(color: Color(0x666896DD), spreadRadius: 16) - ], - shape: BoxShape.circle - ), - ), - ) - ], - ); - } + ClipRRect( + borderRadius: BorderRadius.circular(12), + + child: Row( + children: [ // Navigation sections + 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), + ), + ], + ) ) - // ) // Padding( // padding: EdgeInsetsGeometry.only(left: 8), diff --git a/pubspec.yaml b/pubspec.yaml index 329297e..64ddfa1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,6 @@ dependencies: 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: From 410873d735a873f8b68d78b175e445960b4205cb Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 28 Jun 2026 16:04:27 -0400 Subject: [PATCH 056/121] Revert " modified: lib/bluebus_api.dart" This reverts commit 041279f0adaab3176677ccaa9f6bdff9c244721f. --- lib/bluebus_api.dart | 11 +- lib/screens/map_screen.dart | 1372 +++++++++-------------------------- 2 files changed, 336 insertions(+), 1047 deletions(-) diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index e30e6c7..c81d5e1 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -14,7 +14,7 @@ import 'package:bluebus/widgets/dialog.dart'; // (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 / π + const double radToDeg = 57.29577951308232; // 180 / π double dLat = lat2 - lat1; double dLon = lon2 - lon1; @@ -166,7 +166,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 +193,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/screens/map_screen.dart b/lib/screens/map_screen.dart index 41fa536..845f026 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,14 +5,22 @@ import 'dart:math' as Math; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:bluebus/globals.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/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_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/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'; @@ -38,6 +46,7 @@ import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; +import 'package:screen_corner_radius/screen_corner_radius.dart'; //import 'dart:convert'; final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-16 00:00:00Z"); @@ -64,21 +73,6 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { 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,8 +81,12 @@ class MaizeBusCore extends StatefulWidget { } class _MaizeBusCoreState extends State { - late bool canVibrate; + late bool canVibrate = false; late Journey currDisplayed; + ScreenRadius? screenRadius; + bool screenRadiusLoaded = false; + + NavigationManager navigationManager = NavigationManager(); Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( @@ -97,10 +95,12 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; CameraPosition? _currentCameraPos; 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 +113,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 +130,17 @@ 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; // 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 +159,10 @@ class _MaizeBusCoreState extends State { // store persistent bottom sheet controller PersistentBottomSheetController? _bottomSheetController; + final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); + final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); + final JourneyLayer journeyLayer = JourneyLayer(); + // GoogleMaps styles String _darkMapStyle = "{}"; String _lightMapStyle = "{}"; @@ -170,16 +179,36 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); + baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); + journeyLayer.init( + _showBusSheet, + _activeJourneyBusIds, + _activeJourneyRoutes, + context, + ); + + 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 +220,23 @@ class _MaizeBusCoreState extends State { }); } + void onStopClicked(BusStop stop) { + try { + Haptics.vibrate(HapticsType.light); + } catch (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 +283,21 @@ 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(); + + screenRadius = await ScreenCornerRadius.get(); // load screen radius + screenRadiusLoaded = true; + + //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); + } + } canVibrate = await Haptics.canVibrate(); final busProvider = Provider.of(context, listen: false); @@ -268,21 +328,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) + 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 +350,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(); @@ -336,7 +400,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 +463,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 +493,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(); @@ -702,6 +545,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 +583,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 +610,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 +676,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 +692,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 +702,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 +788,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 @@ -1039,28 +828,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 +846,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 +869,9 @@ class _MaizeBusCoreState extends State { setState(() { _selectedRoutes.clear(); _selectedRoutes.addAll(newSelection); + baseRoutesLayer.reload(); }); - _updateDisplayedRoutes(); + // _updateDisplayedRoutes(); // Save the new selection await _saveSelectedRoutes(); @@ -1227,6 +956,9 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } void _showDirectionsSheet( @@ -1301,10 +1033,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 @@ -1317,6 +1058,9 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } _showJourneySheetOnReopen() { @@ -1355,441 +1099,41 @@ 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), - ), - ); - } - } - } } - // 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 showJourney() { + journeyLayer.isVisible = true; + baseRoutesLayer.isVisible = false; + liveBusesLayer.isVisible = false; } - // 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 hideJourney() { + journeyLayer.isVisible = false; + baseRoutesLayer.isVisible = true; + liveBusesLayer.isVisible = true; } - // 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; - } - } - return [bestIdx, bestDist]; + void _onMapCreated(GoogleMapController controller) { + _mapController = controller; } - // 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; + void _onCameraMove(CameraPosition position) async { + _currentCameraPos = position; + } - // 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 { + // 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), + ); + } } } @@ -1817,8 +1161,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 +1187,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 +1216,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 +1239,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 +1273,9 @@ class _MaizeBusCoreState extends State { ), ); return null; + } else { + //Center map once right after user grants location permissions + _centerOnLocation(true); } } @@ -2009,56 +1358,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,10 +1413,7 @@ 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 showing a persistent bottom sheet, close it. // Fix android back button for buildings sheet and journey sheet (doesn't work without this) @@ -2092,68 +1425,17 @@ class _MaizeBusCoreState extends State { }, 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: [ + baseRoutesLayer, + liveBusesLayer, + journeyLayer, + ], + onMapCreated: _onMapCreated, + ), + ), Padding( padding: EdgeInsets.only( top: globalTopPadding, @@ -2322,9 +1604,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( @@ -2418,6 +1697,11 @@ class _MaizeBusCoreState extends State { ), ), + + NavigationOverlay(navigationManager: navigationManager), + + + // reminder widget SizedBox(height: 30.0), _journeyOverlayActive || _isOffline @@ -2432,7 +1716,6 @@ class _MaizeBusCoreState extends State { Spacer(), - // temp row (might add settings button to it later) (!_journeyOverlayActive) ? Padding( padding: const EdgeInsets.only(bottom: 20), @@ -2632,7 +1915,10 @@ class _MaizeBusCoreState extends State { ), ), child: ElevatedButton.icon( - onPressed: _clearJourneyOverlays, + onPressed: () { + hideJourney(); + // _clearJourneyOverlays + }, style: ElevatedButton.styleFrom( backgroundColor: getColor( context, @@ -2706,7 +1992,7 @@ class _MaizeBusCoreState extends State { ); } _showBusRoutesModal( - busProvider.routes, + _busProviderRef!.routes, ); }, heroTag: 'routes_fab', From 750c6b2bc02982464629dfd7decbbe097919156c Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 28 Jun 2026 12:57:07 -0400 Subject: [PATCH 057/121] modified: lib/screens/map_screen.dart modified: lib/widgets/composite_map_widget.dart --- lib/screens/map_screen.dart | 112 +++++++++++++++++++++++--- lib/widgets/composite_map_widget.dart | 6 ++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 845f026..4058e65 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -85,7 +85,13 @@ class _MaizeBusCoreState extends State { late Journey currDisplayed; ScreenRadius? screenRadius; bool screenRadiusLoaded = false; + StreamSubscription? _posSub; + // TODO: Follow-mode state. When true, the map recenters on location updates. + Position? _lastCenteredPos; + // TODO: Tune this threshold (meters) to your liking. + static const double _followDistanceThresholdMeters = 8.0; + bool _followUser = true; NavigationManager navigationManager = NavigationManager(); Future? _dataLoadingFuture; @@ -376,9 +382,80 @@ 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: 5, + ); + + _posSub = Geolocator.getPositionStream(locationSettings: settings).listen( + (Position p) async { + // Keep this lightweight; do a minimal amount of work here and defer heavy updates. + if (!mounted || _mapController == null) return; + + // If follow mode is disabled, don't recenter automatically. + if (!_followUser) return; + + // Only move camera if user has moved more than threshold to avoid jitter. + final shouldMove = _lastCenteredPos == null || + Geolocator.distanceBetween( + _lastCenteredPos!.latitude, + _lastCenteredPos!.longitude, + p.latitude, + p.longitude, + ) > + _followDistanceThresholdMeters; + + if (!shouldMove) 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?.zoom, + bearing: _currentCameraPos?.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; + }); + } + // need this to make sure that the stop names exist in the cache Future _loadStopsForLaunch() async { // LOADS BOTH STOP TYPES @@ -901,8 +978,8 @@ class _MaizeBusCoreState extends State { if (isBusStop) { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showStopSheet( stopID, @@ -913,8 +990,8 @@ class _MaizeBusCoreState extends State { } else { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showBuildingSheet(location); } @@ -1120,8 +1197,11 @@ class _MaizeBusCoreState extends State { _mapController = controller; } - void _onCameraMove(CameraPosition position) async { - _currentCameraPos = position; + void _onCameraMove(CameraPosition position) { + if (!mounted) return; + setState(() { + _currentCameraPos = position; + }); } void _onCameraIdle() async { @@ -1130,9 +1210,12 @@ class _MaizeBusCoreState extends State { if (viewportBounds != null) { Position? pos = await _getLastKnownLocation(); if (pos != null) { - _userLocVisible = !viewportBounds.contains( - LatLng(pos.latitude, pos.longitude), - ); + if (!mounted) return; + setState(() { + _userLocVisible = !viewportBounds.contains( + LatLng(pos.latitude, pos.longitude), + ); + }); } } } @@ -1306,10 +1389,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, @@ -1335,7 +1420,8 @@ class _MaizeBusCoreState extends State { CameraUpdate.newCameraPosition( CameraPosition( target: LatLng(position.latitude, position.longitude), - zoom: userLocation ? 15.0 : 17.0, + zoom: zoom ?? (userLocation ? 15.0 : 17.0), + bearing: bearing ?? 0.0, ), ), ); @@ -1434,6 +1520,8 @@ class _MaizeBusCoreState extends State { journeyLayer, ], onMapCreated: _onMapCreated, + onCameraMove: _onCameraMove, + onCameraIdle: _onCameraIdle, ), ), Padding( diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 99d2302..7b9cf5f 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -38,11 +38,15 @@ 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 @@ -133,6 +137,8 @@ class CompositeMapWidgetState extends State }); widget.onMapCreated(controller); }, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, ), ); } From 906a27c3bb5b2ba1c0adaf8a940c5a9283f37e4f Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 28 Jun 2026 13:23:37 -0400 Subject: [PATCH 058/121] talking to overlay widget --- lib/services/navigation/navigation_manager.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 436bdaa..0201e7f 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -280,6 +280,7 @@ class NavigationManager { } + // Some way for the navigation widget to void init() { @@ -327,6 +328,11 @@ class NavigationManager { // The stage (e.g. "On bus") should call the "Oops" stage when it needs to } +abstract class NavigationOverlayHost { + void displayOopsDialogue(MissedBus state); // just for the Oops state for now... + void onNavigationUpdated(); // call navigation overlay widget to refresh +} + Future getMockJourney() async { // using the same start / end as the backend test // make sure BACKEND_URL is set to the mock backend From 1325f6ef0ea87e6941fb1d664ac7e8fad040c235 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 28 Jun 2026 13:44:04 -0400 Subject: [PATCH 059/121] additions to navigation manager class to communicate with the overlay widget --- lib/services/navigation/navigation_manager.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 0201e7f..cdb1539 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -236,6 +236,23 @@ class NavigationManager { ]; // 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; + } + } + + // Call to update if state changes require an update + void notifyOverlay() { + _overlay?.onNavigationUpdated(); + } + void setMapLayer(NavigationLayer mapLayer_in) { this.mapLayer = mapLayer_in; } From 37e07cb477211fd641d7382c771d9f1312ba4b76 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 28 Jun 2026 11:19:25 -0700 Subject: [PATCH 060/121] fix issue with multi-subroute routes being represented poorly in routesCache, add index information for the bus stops, progress in NavOnBus NavOnBus required richer information than was currently existing in routesCache, added something to BusRouteLine and did the associated refactor. While looking through it I also noticed that for multi-sub-route routes only the last route was included and attempted a fix of that. (likely incomplete, see FIXME comment) --- lib/bluebus_api.dart | 26 +++--- lib/models/bus_route_line.dart | 3 +- lib/screens/map_screen.dart | 2 +- .../map_layers/base_routes_layer.dart | 2 +- lib/services/map_layers/journey_layer.dart | 11 ++- .../navigation/navigation_manager.dart | 79 +++++++++++++++++-- lib/theride_api.dart | 26 +++--- lib/widgets/favorites_sheet.dart | 4 +- 8 files changed, 112 insertions(+), 41 deletions(-) diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index 1f593e3..d4ce399 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -25,7 +25,7 @@ class BlueBusApi { 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; @@ -41,27 +41,25 @@ class BlueBusApi { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + double stopRotation; + if (isLast) { // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( + 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( + 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)); } - + stops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } @@ -82,7 +80,7 @@ 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; @@ -99,26 +97,26 @@ class BlueBusApi { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + double stopRotation; + if (isLast) { // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( + 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( + 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)); } + detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } diff --git a/lib/models/bus_route_line.dart b/lib/models/bus_route_line.dart index 0bdee50..93a0e89 100644 --- a/lib/models/bus_route_line.dart +++ b/lib/models/bus_route_line.dart @@ -5,7 +5,8 @@ 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 + final List<(int, BusStop)> stops; final Color? color; final String? imageUrl; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 9dfe071..948f150 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -594,7 +594,7 @@ class _MaizeBusCoreState extends State { } if (!_routeStopMarkers.containsKey(routeKey)) { _routeStopMarkers[routeKey] = {}; - for (final stop in r.stops) { + for (final (_, stop) in r.stops) { // iterate through all stops in this route final isFavorite = _favoriteStops.contains(stop.id); diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 2c6064a..63ae296 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -83,7 +83,7 @@ class BaseRoutesLayer extends CompositeMapLayer { if (!markersCache.containsKey(routeKey)) { // Prevent duplicate copies of the same stop on top of each other markersCache[routeKey] = {}; - for (final stop in r.stops) { + for (final (_, stop) in r.stops) { // iterate through all stops in this route // TODO: Implement favorite stops // final isFavorite = _favoriteStops.contains(stop.id); diff --git a/lib/services/map_layers/journey_layer.dart b/lib/services/map_layers/journey_layer.dart index a32b8d1..d3aac35 100644 --- a/lib/services/map_layers/journey_layer.dart +++ b/lib/services/map_layers/journey_layer.dart @@ -4,6 +4,7 @@ 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'; @@ -38,7 +39,7 @@ class JourneyLayer extends CompositeMapLayer { Set activeJourneyRoutes = {}; Set liveBusMarkers = {}; - Map routesCache = {}; + Map> routesCache = {}; BuildContext? context; GoogleMapController? _mapController; @@ -104,8 +105,9 @@ class JourneyLayer extends CompositeMapLayer { } void setRoutesCache(List routes) { + routesCache.clear(); for (BusRouteLine l in routes) { - routesCache[l.routeId] = l; + routesCache.putIfAbsent(l.routeId, () => []).add(l); } } @@ -146,7 +148,10 @@ class JourneyLayer extends CompositeMapLayer { if (leg.rt != null) activeJourneyRoutes.add(leg.rt!); if (leg.trip != null) activeJourneyBusIds.add(leg.trip!.vid); - BusRouteLine? line = routesCache[leg.rt]; + final rt = leg.rt; + final line = rt != null + ? NavOnBus.determineRouteOfBusLeg(routesCache, rt, leg.originID, leg.destinationID) + : null; // debugPrint("Tracing path from ${leg.originID} to ${leg.destinationID}"); diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 04117f7..01b0615 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -7,6 +7,7 @@ import 'package:bluebus/models/bus_stop.dart' show BusStop; import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/journey_repository.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:bluebus/services/route_color_service.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -69,14 +70,13 @@ class NavWalking extends NavigationStage { } class NavOnBus extends NavigationStage { - String title = "On Bus"; - String rt; String departureStop; String arrivalStop; Trip trip; - BusRouteLine? busPath; + List<(LatLng, (int, BusStop)?)> busPath; + // BusRouteLine busPath; NavOnBus({ required this.rt, @@ -86,7 +86,7 @@ class NavOnBus extends NavigationStage { required this.busPath, }); - factory NavOnBus.init(Leg leg, Map routesCache) { + factory NavOnBus.init(Leg leg, Map> routesCache) { final maybeRt = leg.rt; final maybeTrip = leg.trip; if (maybeRt == null || @@ -96,14 +96,83 @@ class NavOnBus extends NavigationStage { leg.destinationID == '') { throw Exception("leg was malformed or not a bus leg"); } + final busLine = determineRouteOfBusLeg(routesCache, maybeRt, leg.originID, leg.destinationID); + if (busLine == null) throw Exception("bus line not found"); + + final stopsIter = busLine.stops.skipWhile((s) => s.$2.id != leg.originID); + final startIdx = stopsIter.firstOrNull?.$1; + final endIdx = stopsIter.where((s) => s.$2.id == leg.destinationID).firstOrNull?.$1; + if (startIdx == null || endIdx == null) throw Exception("valid bus line not found"); + + final busPath = <(LatLng, (int, BusStop)?)>[]; + for (int i = startIdx; i <= endIdx; i++) { + busPath.add((busLine.points[i], busLine.stops.where((s) => s.$1 == i).firstOrNull)); + } + return NavOnBus( rt: maybeRt, departureStop: leg.originID, arrivalStop: leg.destinationID, trip: maybeTrip, - busPath: routesCache[maybeRt], + busPath: busPath, ); } + + @override + String getTitle() { + // TODO: implement getTitle + return "($rt) Ride ${-1} more stops"; + } + + @override + String getSubtitle() { + // TODO: implement getSubtitle + return "${-1} min"; + } + + @override + // TODO: implement length + double get length => super.length; + + @override + // TODO: implement percent_complete + double get percent_complete => super.percent_complete; + + @override + List getSteps() { + // TODO: implement getSteps + return super.getSteps(); + } + + @override + List getMarkers() { + // TODO: implement getMarkers + return super.getMarkers(); + } + + @override + List getPolylines() { + // TODO: implement getPolylines + return super.getPolylines(); + } + + @override + Color getColor() { + return RouteColorService.getRouteColor(rt); + } + + // FIXME: it is assumed that all resonable trips are represented by only one subroute, confirm this or make it able to handle the multi-subroute case + static BusRouteLine? determineRouteOfBusLeg( + Map> routesCache, String rt, String originID, String destinationID + ) { + List candidates = routesCache[rt] ?? []; + return candidates + .where((line) { + final stpids = line.stops.map((s) => s.$2.id); + return stpids.skipWhile((stpid) => stpid != originID).contains(destinationID); + }) + .firstOrNull; + } } class ChooseBus extends NavigationStage{ diff --git a/lib/theride_api.dart b/lib/theride_api.dart index 4f716dd..b027ed0 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -26,7 +26,7 @@ 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; @@ -42,26 +42,25 @@ class RideAPI { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + double stopRotation; + if (isLast) { // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( + 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( + 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)); } + stops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } } @@ -83,7 +82,7 @@ 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; @@ -100,26 +99,25 @@ class RideAPI { ); if (point['typ'] == 'S') { // get rotation of stop - if (isLast){ + double stopRotation; + if (isLast) { // use the previous 2 points to calculate rotation - double stopRotation = pointRotation( + 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( + 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)); } + detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } } diff --git a/lib/widgets/favorites_sheet.dart b/lib/widgets/favorites_sheet.dart index ffc2313..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; } } From 1dad0224ebf59c7f87988b993349f2108bdaaf98 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 28 Jun 2026 14:57:44 -0400 Subject: [PATCH 061/121] overlay widget modifications to talk to nav hub --- lib/widgets/navigation_overlay_widget.dart | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 8a60ded..f43ad11 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -20,7 +20,8 @@ class NavigationOverlay extends StatefulWidget { } -class _NavigationOverlayState extends State { +class _NavigationOverlayState extends State + implements NavigationOverlayHost { TimelineInfo timelineInfo = TimelineInfo(); @@ -35,6 +36,35 @@ class _NavigationOverlayState extends State { // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); 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(); + }); + } + + // this is the actual Oops code portion + // not sure if this is how we should have it set up but it is here for now, going to leave a marker + // !! TEMP !! + void displayOopsDialog(MissedBus stage) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(stage.getTitle()), + content: Text(stage.getSubtitle()), + ), + ); } @override From 569cf85338a2d2ef32db5d599a0d79fe43ffed48 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 28 Jun 2026 14:59:06 -0400 Subject: [PATCH 062/121] nav manager changes pt 2. --- lib/services/navigation/navigation_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index cdb1539..d1d520a 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -346,7 +346,7 @@ class NavigationManager { } abstract class NavigationOverlayHost { - void displayOopsDialogue(MissedBus state); // just for the Oops state for now... + void displayOopsDialog(MissedBus state); // just for the Oops state for now... void onNavigationUpdated(); // call navigation overlay widget to refresh } From 7aa6b960f105fcb43b904e0b4ad81309af6e1b86 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 28 Jun 2026 14:59:29 -0400 Subject: [PATCH 063/121] resolving some pull issues with this --- lib/services/notification_service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 4f97a96..14b0277 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -10,7 +10,6 @@ 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; From 544ca1705a8c9b171da034834c9c4ab43311901c Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:47:53 +0200 Subject: [PATCH 064/121] Finished demo stage! + navigation layer wiring Combines 7187f1d (demo stage work) with navigation layer setup: adds NavigationLayer to MapScreen, restores getColor/getMarkers/getPolylines on NavigationStage base class, and wires NavigationManager to the map layer. --- lib/screens/map_screen.dart | 15 +- lib/services/map_layers/navigation_layer.dart | 8 +- .../navigation/navigation_manager.dart | 176 +++++++++++++++++- 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4058e65..bce82c5 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -12,6 +12,7 @@ import 'package:bluebus/services/map_image_service.dart'; import 'package:bluebus/services/map_layers/base_routes_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'; @@ -168,6 +169,7 @@ class _MaizeBusCoreState extends State { final BaseRoutesLayer baseRoutesLayer = BaseRoutesLayer(); final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); final JourneyLayer journeyLayer = JourneyLayer(); + final NavigationLayer navigationLayer = NavigationLayer(); // GoogleMaps styles String _darkMapStyle = "{}"; @@ -185,6 +187,9 @@ class _MaizeBusCoreState extends State { super.initState(); _setupConnectivityMonitoring(); + // debugPrint("MAP SCREEN INITSTATE==================="); + navigationManager.init(); + baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); journeyLayer.init( _showBusSheet, @@ -193,6 +198,9 @@ class _MaizeBusCoreState extends State { context, ); + navigationManager.setMapLayer(navigationLayer); + navigationLayer.init(); + hideJourney(); // Hide the journey layer until we're ready to use it WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1515,9 +1523,10 @@ class _MaizeBusCoreState extends State { child: CompositeMapWidget( initialCenter: startLatLng, mapLayers: [ - baseRoutesLayer, - liveBusesLayer, - journeyLayer, + // baseRoutesLayer, + // liveBusesLayer, + // journeyLayer, + navigationLayer ], onMapCreated: _onMapCreated, onCameraMove: _onCameraMove, diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index 2d5385c..264a89c 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -21,16 +21,14 @@ class NavigationLayer extends CompositeMapLayer { }; void init( - Set favoriteStops_in, - Set selectedRoutes_in, - Function(BusStop) onStopClicked_in, ) { //... } void reload() { - reloadMarkers(); - reloadPolylines(); + // reloadMarkers(); + // reloadPolylines(); + debugPrint("**** RELOADING NAVIGATIONLAYER, we have ${markers.length} markers and ${polylines} polylines"); if (isVisible) onUpdate(); } diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 988b1eb..58e7b37 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,8 +1,11 @@ +import 'dart:ui'; + 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:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -19,7 +22,19 @@ sealed class NavigationStage { 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 getMarkers() { + return []; + } + + List getPolylines() { + return []; + } + + Color getColor() { + return Color(0xFFDBE4ED); + } + } class NavWalking extends NavigationStage { @@ -96,7 +111,77 @@ class DemoStage extends NavigationStage { } double length = 15.0; - double percent_complete = 11.0; + double percent_complete = 0.110; + + LatLng startPoint; + LatLng endPoint; + + double favoriteNumber; + + DemoStage({ + required this.favoriteNumber, + required this.length, + required this.percent_complete, + required this.startPoint, + required this.endPoint + }); + + @override + Color getColor() { // Return a random color + // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber + const double golden = 0.618033988749895; + final double hue = ((this.favoriteNumber.hashCode * golden) % 1.0).abs() * 360; + return HSLColor.fromAHSL(1.0, hue, 0.65, 0.55).toColor(); + } + + @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() + ) + ]; + } + +} + +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 ?? []; } @@ -105,13 +190,82 @@ class NavigationManager { int currentStage = 0; // Stores the current navigation state index List stageList = - [DemoStage()]; // Stores all the states for users to page back and forth + [ + DemoStage( + favoriteNumber: 1, + length: 15, + percent_complete: 0.80, + startPoint: LatLng(42.281973, -83.765719), + endPoint: LatLng(42.281291, -83.743918) + ), + DemoStage( + favoriteNumber: 2, + length: 33, + percent_complete: 0.23, + startPoint: LatLng(42.281291, -83.743918), + endPoint: LatLng(42.287031, -83.743532), + ), + DemoStage( + favoriteNumber: 3, + length: 4, + percent_complete: 0.0, + startPoint: LatLng(42.287031, -83.743532), + endPoint: LatLng(42.289689, -83.738435) + ), + + ]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; + 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) @@ -120,6 +274,22 @@ class NavigationManager { // 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]; } From 29a05d1876461c4626e5a5caeb9ef8eb0966a8c6 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:55:26 +0200 Subject: [PATCH 065/121] Fixed merge mess --- lib/services/map_layers/navigation_layer.dart | 8 + .../navigation/navigation_manager.dart | 12 +- lib/widgets/navigation_overlay_widget.dart | 152 +++++++++++++++--- 3 files changed, 146 insertions(+), 26 deletions(-) diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index 264a89c..1e68938 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -32,6 +32,14 @@ class NavigationLayer extends CompositeMapLayer { if (isVisible) onUpdate(); } + void setMarkers(Set markers_in) { + this.markers = markers_in; + } + + void setPolylines(Set polylines_in) { + this.polylines = polylines_in; + } + void reloadMarkers() { //... } diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 58e7b37..f1108ed 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -103,11 +103,11 @@ class Walking extends NavigationStage{ class DemoStage extends NavigationStage { String getTitle() { - return "This is a demo!"; + return "This is a demo! #$favoriteNumber"; } String getSubtitle() { - return "Look, here's a subtitle too"; + return "Look, here's a subtitle too #$favoriteNumber"; } double length = 15.0; @@ -294,6 +294,14 @@ class NavigationManager { return stageList[currentStage]; } + void nextStage() { + currentStage = (currentStage + 1) % stageList.length; + } + + void previousStage() { + currentStage = (currentStage - 1) % stageList.length; + } + // TODO: Add start()/stop() methods diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index cce77de..4673784 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -22,7 +22,20 @@ class NavigationOverlay extends StatefulWidget { class _NavigationOverlayState extends State { + TimelineInfo timelineInfo = TimelineInfo(); + void updateTimeline() { // Call this after all the stages are loaded (or stages change) + // debugPrint("***** Updating timeline!"); + timelineInfo = widget.navigationManager.getTimeline(); + // debugPrint("***** Timeline now has ${timelineSteps.length} things!"); + } + + @override + void initState() { + // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); + super.initState(); + updateTimeline(); + } @override Widget build(BuildContext context) { @@ -79,7 +92,6 @@ class _NavigationOverlayState extends State { ), Text( style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), - // "I don't know, dude, figure it out" widget.navigationManager.getCurrentStage().getSubtitle() ), ] @@ -117,10 +129,32 @@ class _NavigationOverlayState extends State { padding: EdgeInsetsGeometry.only(left: 8), child: Text( style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), - "I'm told your bus is coming" + "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) ) + + ], ) ), @@ -150,29 +184,99 @@ class _NavigationOverlayState extends State { ), child: Column( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - - child: Row( - children: [ // Navigation sections - 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), - ), - ], - ) + // TODO: Add the user's position in all of this + // ClipRRect( + // borderRadius: BorderRadius.circular(12), + + // child: + LayoutBuilder( + builder: (context, constraints) { + + const double dotSize = 24.0; + final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); + + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Padding( + padding: EdgeInsets.only(top: dotSize, bottom: dotSize), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Row( + + children: this.timelineInfo.timelineSteps.map((item) { + return Flexible( + flex: item.estimated_time.floor(), // Proportionally sizes to each item's time + child: Container( + height: 10, + 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), + // ), + ), + ), + ), + + + // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), + + // Container( + // width: dotSize, + // height: dotSize, + // decoration: const BoxDecoration( + // color: Colors.red, + // shape: BoxShape.circle + // ), + // ), + + Positioned( // TODO: Make this thing animate smoooooothly! + left: dotLeft, + // top: -dotSize / 4, + // top: -dotSize, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Color(0xFF4286F5), + border: Border.all( + color: Colors.white, + // color: Color(0x666896DD), + width: 2.0 + ), + boxShadow: [ + BoxShadow(color: Color(0x666896DD), spreadRadius: 16) + ], + shape: BoxShape.circle + ), + ), + ) + ], + ); + } ) + // ) // Padding( // padding: EdgeInsetsGeometry.only(left: 8), From 95a3722ad0f86509e812911f8ee5130269685336 Mon Sep 17 00:00:00 2001 From: Swati Date: Mon, 29 Jun 2026 19:43:42 -0400 Subject: [PATCH 066/121] feat: adding walking-stage (still work in progress) --- .../navigation/navigation_manager.dart | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index f1108ed..6ea50bf 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -7,6 +7,7 @@ import 'package:bluebus/models/journey.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'dart:math' as math; @@ -90,13 +91,90 @@ class ChooseBus extends NavigationStage{ } //I believe this is just NavWalking but I'm doing it here to be sure. -class Walking extends NavigationStage{ - //Points in order, you can check if you are near a point to remove it from the route or start another leg - List points = []; - //This could be refreshed in intervals - LatLng? currWalkingPos; +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), + ]; + + 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; + } + + //call this whenever a new gps fix arrives, returns true if a waypoint was just cleared (so the ui can refresh) + bool updatePosition(LatLng newPos) { + currWalkingPos = newPos; + if (_nextIndex < points.length && + _distMeters(newPos, points[_nextIndex]) <= _reachThresholdMeters) { + _nextIndex++; + return true; + } + return false; + } + @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"; + } + @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"; + } } From 891407f58ed80ec5d706f5fcfc027776d41e0801 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:28:17 +0200 Subject: [PATCH 067/121] Started implementation of stage events stream --- .../navigation/navigation_manager.dart | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 6ea50bf..25b0e5e 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:bluebus/models/bus.dart'; @@ -36,6 +37,27 @@ sealed class NavigationStage { return Color(0xFFDBE4ED); } + final _eventController = StreamController(); + + Stream get events => _eventController.stream; + + void dispose() { + _eventController.close(); + } + +} + +enum RerouteReason { + wrongBus, + walkPathChanged + // Feel free to add additional reasons as necessary +} + +sealed class StageEvent {} +class StageComplete extends StageEvent {} +class StageReroute extends StageEvent { + final RerouteReason reason; // e.g. wrong bus, missed stop + StageReroute(this.reason); } class NavWalking extends NavigationStage { @@ -239,6 +261,19 @@ class DemoStage extends NavigationStage { ]; } + final _eventController = StreamController(); + + Stream get events => _eventController.stream; + + // To add stage events (i.e. if you miss the bus): + // _controller.add(StageReroute(RerouteReason.wrongBus)) + // _controller.add(StageReroute(RerouteReason.walkPathChanged)) + // _controller.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(); + } } class TimelineStep { @@ -266,6 +301,8 @@ class TimelineInfo { class NavigationManager { // TODO: Implement ChangeNotifier and learn how that works + StreamSubscription? _stageEventSub; + int currentStage = 0; // Stores the current navigation state index List stageList = [ @@ -294,6 +331,19 @@ class NavigationManager { ]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; + 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(); @@ -374,10 +424,12 @@ class NavigationManager { void nextStage() { currentStage = (currentStage + 1) % stageList.length; + _activateStageSub(stageList[currentStage]); } void previousStage() { currentStage = (currentStage - 1) % stageList.length; + _activateStageSub(stageList[currentStage]); } // TODO: Add start()/stop() methods @@ -387,3 +439,5 @@ class NavigationManager { // - 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 } + +// TODO: Call dispose() on stages as they are removed \ No newline at end of file From ce0413b67e4d1989d557abad7b4a0744931bde9a Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 5 Jul 2026 11:25:36 -0400 Subject: [PATCH 068/121] Add Settings for Centering Position --- lib/globals.dart | 3 + lib/screens/map_screen.dart | 14 +- lib/screens/settings.dart | 249 ++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 4 deletions(-) diff --git a/lib/globals.dart b/lib/globals.dart index 87e317a..a13019e 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; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index bce82c5..1e35648 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -89,8 +89,6 @@ class _MaizeBusCoreState extends State { StreamSubscription? _posSub; // TODO: Follow-mode state. When true, the map recenters on location updates. Position? _lastCenteredPos; - // TODO: Tune this threshold (meters) to your liking. - static const double _followDistanceThresholdMeters = 8.0; bool _followUser = true; NavigationManager navigationManager = NavigationManager(); @@ -299,6 +297,14 @@ class _MaizeBusCoreState extends State { theme.onSystemThemeUpdate(context); 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; @@ -413,7 +419,7 @@ class _MaizeBusCoreState extends State { final settings = LocationSettings( accuracy: LocationAccuracy.bestForNavigation, - distanceFilter: 5, + distanceFilter: globalGpsUpdateDistanceFilterMeters, ); _posSub = Geolocator.getPositionStream(locationSettings: settings).listen( @@ -432,7 +438,7 @@ class _MaizeBusCoreState extends State { p.latitude, p.longitude, ) > - _followDistanceThresholdMeters; + globalFollowDistanceThresholdMeters; if (!shouldMove) return; 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, From bda449e9ed712ee73da1f94028b253d81daacc12 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 5 Jul 2026 13:29:00 -0400 Subject: [PATCH 069/121] widget additions to support communication to the navhub --- lib/widgets/navigation_overlay_widget.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index f43ad11..627898f 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -57,7 +57,9 @@ class _NavigationOverlayState extends State // this is the actual Oops code portion // not sure if this is how we should have it set up but it is here for now, going to leave a marker // !! TEMP !! + @override void displayOopsDialog(MissedBus stage) { + // TODO FOR ALLEN: Make this one a MaizeBusDialogue (Next Updates) showDialog( context: context, builder: (_) => AlertDialog( From fe1dff8f35d3202263a768744b0d5952889710e5 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 5 Jul 2026 13:38:50 -0400 Subject: [PATCH 070/121] dunno what happened to my missed stage earlier, think it got erased somehow? --- .../navigation/navigation_manager.dart | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 88f2473..b639b5c 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -112,6 +112,47 @@ class ChooseBus extends NavigationStage{ // This could be simplified more, probably by picking up data from another function } +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, + }); + + // Core functionality + TODOs for Allen + // Main objectives for the "oops" stage: + // - Acknowledge to user that they have missed expected bus + // - Based on logic: immediately ask user to get off on next stop + // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally + + // Data Structure Implementation + // What we need: + // - hangon... + +} + + //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) From 6f7953cbb6c739234e2b295d3cc198be96d65988 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 5 Jul 2026 10:46:21 -0700 Subject: [PATCH 071/121] fix: resolve FIXME w/ graph traversal of routes --- lib/models/bus_route_line.dart | 8 +- lib/services/map_layers/journey_layer.dart | 2 +- .../navigation/navigation_manager.dart | 86 ++++++++++++++++--- 3 files changed, 81 insertions(+), 15 deletions(-) diff --git a/lib/models/bus_route_line.dart b/lib/models/bus_route_line.dart index 93a0e89..ee3266c 100644 --- a/lib/models/bus_route_line.dart +++ b/lib/models/bus_route_line.dart @@ -5,16 +5,18 @@ import 'bus_stop.dart'; class BusRouteLine { final String routeId; final List points; + /// 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/services/map_layers/journey_layer.dart b/lib/services/map_layers/journey_layer.dart index d3aac35..104108b 100644 --- a/lib/services/map_layers/journey_layer.dart +++ b/lib/services/map_layers/journey_layer.dart @@ -150,7 +150,7 @@ class JourneyLayer extends CompositeMapLayer { final rt = leg.rt; final line = rt != null - ? NavOnBus.determineRouteOfBusLeg(routesCache, rt, leg.originID, leg.destinationID) + ? determineRouteOfBusLeg(routesCache, rt, leg.originID, leg.destinationID) : null; // debugPrint("Tracing path from ${leg.originID} to ${leg.destinationID}"); diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 01b0615..623f970 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:math'; import 'dart:ui'; @@ -161,18 +162,81 @@ class NavOnBus extends NavigationStage { return RouteColorService.getRouteColor(rt); } - // FIXME: it is assumed that all resonable trips are represented by only one subroute, confirm this or make it able to handle the multi-subroute case - static BusRouteLine? determineRouteOfBusLeg( - Map> routesCache, String rt, String originID, String destinationID - ) { - List candidates = routesCache[rt] ?? []; - return candidates - .where((line) { - final stpids = line.stops.map((s) => s.$2.id); - return stpids.skipWhile((stpid) => stpid != originID).contains(destinationID); - }) - .firstOrNull; +} + +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{ From 8c5e22768aa479e14688d6eb2290103b36d7406d Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sun, 5 Jul 2026 11:20:16 -0700 Subject: [PATCH 072/121] fix: make it compile again --- lib/screens/map_screen.dart | 3 +-- lib/services/map_image_service.dart | 1 - lib/services/navigation/navigation_manager.dart | 15 ++------------- lib/theride_api.dart | 1 - 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 69c4239..55a9a98 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,9 +1,7 @@ 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 'package:bluebus/globals.dart'; import 'package:bluebus/models/bus_stop.dart'; import 'package:bluebus/providers/theme_provider.dart'; @@ -35,6 +33,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'; diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 45215a0..c5f6108 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui' as ui; -import 'dart:ui'; import 'package:bluebus/constants.dart'; import 'package:bluebus/models/bus.dart'; diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index a54240a..612a703 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -1,15 +1,14 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:math'; -import 'dart:ui'; +import 'dart:math' as math; 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/journey_repository.dart'; import 'package:bluebus/services/map_layers/navigation_layer.dart'; import 'package:bluebus/services/route_color_service.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -272,16 +271,6 @@ class ChooseBus extends NavigationStage{ // This could be simplified more, probably by picking up data from another function } -//I believe this is just NavWalking but I'm doing it here to be sure. -class Walking extends NavigationStage{ - //Points in order, you can check if you are near a point to remove it from the route or start another leg - List points = []; - //This could be refreshed in intervals - LatLng? currWalkingPos; - - -} - // oops stage // TODOs: class MissedBus extends NavigationStage { diff --git a/lib/theride_api.dart b/lib/theride_api.dart index b027ed0..eac07a8 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -1,5 +1,4 @@ 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'; From 9fc5cf8d2852d17afd571fa30395e90049c8ac42 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Sun, 12 Jul 2026 16:43:42 -0400 Subject: [PATCH 073/121] modified: lib/screens/map_screen.dart --- lib/screens/map_screen.dart | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1e35648..ef19ad9 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -89,6 +89,8 @@ class _MaizeBusCoreState extends State { StreamSubscription? _posSub; // TODO: Follow-mode state. When true, the map recenters on location updates. Position? _lastCenteredPos; + bool _userHasInteractedWithMap = false; + bool _isProgrammaticCameraMove = false; bool _followUser = true; NavigationManager navigationManager = NavigationManager(); @@ -429,6 +431,7 @@ class _MaizeBusCoreState extends State { // If follow mode is disabled, don't recenter automatically. if (!_followUser) return; + if (_userHasInteractedWithMap) return; // Only move camera if user has moved more than threshold to avoid jitter. final shouldMove = _lastCenteredPos == null || @@ -467,6 +470,7 @@ class _MaizeBusCoreState extends State { if (!enabled) return; // When enabling follow mode, reset last-centered so next position recenters immediately. _lastCenteredPos = null; + _userHasInteractedWithMap = false; }); } @@ -1213,6 +1217,9 @@ class _MaizeBusCoreState extends State { void _onCameraMove(CameraPosition position) { if (!mounted) return; + if (!_isProgrammaticCameraMove) { + _userHasInteractedWithMap = true; + } setState(() { _currentCameraPos = position; }); @@ -1430,15 +1437,20 @@ 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: zoom ?? (userLocation ? 15.0 : 17.0), - bearing: bearing ?? 0.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 = false; + } } } @@ -1923,6 +1935,7 @@ class _MaizeBusCoreState extends State { ), child: FloatingActionButton.small( onPressed: () { + _setFollowMode(true); _centerOnLocation( true, ); From 75ea2d38595c6f02e5c8e1f2c17d10629637fd31 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:09:12 +0200 Subject: [PATCH 074/121] Added initWithLeg code --- .../navigation/navigation_manager.dart | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index b639b5c..bc4c233 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -45,6 +45,16 @@ sealed class NavigationStage { _eventController.close(); } + void initWithLeg(Leg leg) { + // Do cool stuff to set up your Stage with an e.g. walking or bus leg + } + + void receiveLocationUpdate(LatLng newLocation) { + // 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.: + // _controller.add(StageComplete()) // If the user has reached the end! + } + } enum RerouteReason { @@ -304,17 +314,28 @@ class DemoStage extends NavigationStage { final _eventController = StreamController(); - Stream get events => _eventController.stream; + 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): - // _controller.add(StageReroute(RerouteReason.wrongBus)) - // _controller.add(StageReroute(RerouteReason.walkPathChanged)) - // _controller.add(StageComplete()) // If your stage is complete! + // _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(LatLng newLocation) { + // 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 { From 2c8278f77ee70efc64e71542f52e0c09d9f40f98 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:53:17 +0200 Subject: [PATCH 075/121] Added preliminary support for stage steps --- lib/screens/map_screen.dart | 10 +- .../navigation/navigation_manager.dart | 50 +- lib/widgets/navigation_overlay_widget.dart | 550 +++++++++++------- 3 files changed, 379 insertions(+), 231 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index e9d06c9..91d5d98 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1789,8 +1789,9 @@ class _MaizeBusCoreState extends State { ), ), - - NavigationOverlay(navigationManager: navigationManager), + // Expanded( + // child: NavigationOverlay(navigationManager: navigationManager), + // ), @@ -2218,6 +2219,11 @@ class _MaizeBusCoreState extends State { ], ), ), + Positioned.fill( + child: RepaintBoundary( + child: NavigationOverlay(navigationManager: navigationManager) + ) + ), ], ), ) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 5b549a5..5287c1f 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -15,24 +15,38 @@ 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 ""; + return title; } String? getSubtitle() { - return null; // Return null if no subtitle + return subtitle; // Return null if no subtitle } String getTime() { - return "0:00"; // Get the time + return time; // Get the time } Color? getColor() { - return null; // Return null for neutral gray + return color; // Return null for neutral gray } LineType getLineType() { - return LineType.Dashed; + return lineType; } } @@ -473,6 +487,32 @@ class DemoStage extends NavigationStage { ]; } + 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, + ), + NavigationStageStep( + title: "Step 2", + subtitle: "Step 2 subtitle", + time: '4:56 AM', + color: getColor(), // Use the stage's color in our demo + lineType: LineType.Dashed, + ), + 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, + ) + ]; // Get navigation stage steps + } + final _eventController = StreamController(); Stream get events => _eventController.stream; // This is so the NavigationController can do yourStage.events and access your event controller diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index c1432a2..f436d16 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -79,247 +79,349 @@ class _NavigationOverlayState extends State // // TODO: Handle this case. // throw UnimplementedError(); // } - return Column( - + return Stack( children: [ - - Container( - width: double.infinity, - - 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, + Padding( + padding: EdgeInsetsGeometry.only(left: 10, right: 10, top: 70), + child: Column( + children: [ + + Container( + width: double.infinity, + + 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), ), - blurRadius: 10, - offset: Offset(0, 6), + child: Row(children: [ + Icon( + Icons.pool, + color: getColor(context, ColorType.mapButtonIcon), + size: 48, + ), + 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() + ), + ] + ) + ) + ) + ]) ), - ], - borderRadius: - BorderRadius.circular(25), - ), - child: Row(children: [ - Icon( - Icons.pool, - color: getColor(context, ColorType.mapButtonIcon), - size: 48, - ), - Expanded( - - child: - Padding( - padding: EdgeInsets.only(left: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + + Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter + + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(8), + + 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: [ - Text( - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: getColor(context, ColorType.mapButtonIcon)), - widget.navigationManager.getCurrentStage().getTitle() + RouteIcon.small("BB"), + Padding( + padding: EdgeInsetsGeometry.only(left: 8), + child: Text( + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + "Bus arriving in 218 mins" + ), ), - Text( - style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), - widget.navigationManager.getCurrentStage().getSubtitle() + 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) + ) + + + + ], ) - ) - ) - ]) - ), - - - Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter - - margin: EdgeInsets.fromLTRB(0, 10, 0, 0), - padding: EdgeInsets.all(8), - - 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: [ - RouteIcon.small("BB"), - 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) - ) - - + + // 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" + // ), + // ) + + // ], + // ) + // ), + ] + ), ), - // Expanded(child: SizedBox.expand()), - // SizedBox.expand(), - - Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter - - 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), + // TODO: Add a scrim that fades in when you drag up on the progress bar so that the background is darkened behind the DraggableScrollableSheet + + + DraggableScrollableSheet( + initialChildSize: 0.12, // TODO: Compute the height of the progress bar dynamically instead of using 12% of screen height as a hardcoded number + minChildSize: 0.12, + maxChildSize: 0.85, + snap: true, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: getColor(context, ColorType.infoCardColor), + borderRadius: const BorderRadius.vertical(top: Radius.circular(25)), + boxShadow: [ /* TODO: Add a nice box shadow */ ] ), - ], - borderRadius: - BorderRadius.circular(25), - ), - child: Column( - children: [ - // TODO: Add the user's position in all of this - // ClipRRect( - // borderRadius: BorderRadius.circular(12), - - // child: - LayoutBuilder( - builder: (context, constraints) { - - const double dotSize = 24.0; - final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); - - - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Padding( - padding: EdgeInsets.only(top: dotSize, bottom: dotSize), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Row( - - children: this.timelineInfo.timelineSteps.map((item) { - return Flexible( - flex: item.estimated_time.floor(), // Proportionally sizes to each item's time - child: Container( - height: 10, - 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), - // ), - ), - ), - ), - - - // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), - - // Container( - // width: dotSize, - // height: dotSize, - // decoration: const BoxDecoration( - // color: Colors.red, - // shape: BoxShape.circle - // ), - // ), - - Positioned( // TODO: Make this thing animate smoooooothly! - left: dotLeft, - // top: -dotSize / 4, - // top: -dotSize, - child: Container( - width: dotSize, - height: dotSize, + child: 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: 50, + height: 4, + child: DecoratedBox( decoration: BoxDecoration( - color: Color(0xFF4286F5), - border: Border.all( - color: Colors.white, - // color: Color(0x666896DD), - width: 2.0 - ), - boxShadow: [ - BoxShadow(color: Color(0x666896DD), spreadRadius: 16) - ], - shape: BoxShape.circle + 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 = 24.0; + final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); + + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Padding( + padding: EdgeInsets.only(top: dotSize, bottom: dotSize), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Row( + + children: this.timelineInfo.timelineSteps.map((item) { + return Flexible( + flex: item.estimated_time.floor(), // Proportionally sizes to each item's time + child: Container( + height: 10, + 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), + // ), + ), + ), + ), + + + // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), + + // Container( + // width: dotSize, + // height: dotSize, + // decoration: const BoxDecoration( + // color: Colors.red, + // shape: BoxShape.circle + // ), + // ), + + Positioned( // TODO: Make this thing animate smoooooothly! + left: dotLeft, + // top: -dotSize / 4, + // top: -dotSize, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Color(0xFF4286F5), + border: Border.all( + color: Colors.white, + // color: Color(0x666896DD), + width: 2.0 + ), + boxShadow: [ + BoxShadow(color: Color(0x666896DD), spreadRadius: 16) + ], + shape: BoxShape.circle + ), + ), + ) + ], + ); + } + ), + Padding( + padding: EdgeInsets.only(left: 10, right: 10, bottom: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Arrive in 10 mins"), + Text("ETA 9:35PM") + ], + ) + ) + ] + // ) + ), + + Column( + children: widget.navigationManager.stageList.map((NavigationStage stage) { + return Column( + children: stage.getSteps().map((NavigationStageStep step) { + return Row( + children: [ + Padding(padding: EdgeInsets.only(left: 20)), + Container( + decoration: BoxDecoration( + color: step.getColor() + ), + width: 30, + height: 40, + child: Container( + + ) + ), + Padding(padding: EdgeInsets.only(left: 20)), + Text(step.getTitle()), + // Spacer(), + Container(width: 40), + Text(step.getTime()) + ] + ); + }).toList(), + ); + // return Text(stage.getTitle()); + }).toList(), + ) + + ] ) - // ) - - // Padding( - // padding: EdgeInsetsGeometry.only(left: 8), - // child: Text( - // // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.primary)), - // "I'm told your bus is coming" - // ), - // ) - - ], - ) + ); + } ), ] ); From f9574bc97481474ea6aa759b82b7b8a2f6f105d9 Mon Sep 17 00:00:00 2001 From: gusrod-coder Date: Thu, 23 Jul 2026 01:55:39 -0400 Subject: [PATCH 076/121] modified: lib/widgets/map_widget.dart --- lib/widgets/map_widget.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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), From e7b7f36945aaac86ad6bb67d0e8b7e0c08085fa2 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:42:19 +0200 Subject: [PATCH 077/121] Spruced up steps UI --- lib/constants.dart | 3 + .../navigation/navigation_manager.dart | 38 ++++-- lib/widgets/navigation_overlay_widget.dart | 127 +++++++++++++++--- 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 335d017..79dbd80 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -116,6 +116,7 @@ enum ColorType { secondaryButtonText, mapWalkingLine, // Color for the walking line on the map + navigationStepsGray } const Map lightColors = { @@ -156,6 +157,7 @@ const Map lightColors = { ColorType.secondaryButtonText: maizeBusBlue, ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97), + ColorType.navigationStepsGray: Color.fromARGB(255, 217, 217, 217) }; const Map darkColors = { @@ -196,6 +198,7 @@ const Map darkColors = { ColorType.secondaryButtonText: Color.fromARGB(255, 49, 129, 199), ColorType.mapWalkingLine: Color.fromARGB(255, 178, 219, 255), + ColorType.navigationStepsGray: Color.fromARGB(255, 93, 93, 93) }; // returns true if the current theme is dark mode diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 5287c1f..601104c 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'dart:math'; import 'dart:math' as math; +import 'package:bluebus/constants.dart'; import 'package:bluebus/models/bus.dart'; import 'package:bluebus/models/bus_route_line.dart'; import 'package:bluebus/models/bus_stop.dart' show BusStop; @@ -79,6 +80,10 @@ sealed class NavigationStage { return Color(0xFFDBE4ED); } + bool hasRoundedCorners() { + return false; + } + final _eventController = StreamController(); Stream get events => _eventController.stream; @@ -443,21 +448,23 @@ class DemoStage extends NavigationStage { 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.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 - const double golden = 0.618033988749895; - final double hue = ((this.favoriteNumber.hashCode * golden) % 1.0).abs() * 360; - return HSLColor.fromAHSL(1.0, hue, 0.65, 0.55).toColor(); + return color; } @override @@ -494,25 +501,32 @@ class DemoStage extends NavigationStage { subtitle: "Step 1 subtitle", time: '1:23 AM', color: getColor(), // Use the stage's color in our demo - lineType: LineType.Dashed, + // lineType: LineType.Dashed, + lineType: this.lineType ), NavigationStageStep( title: "Step 2", subtitle: "Step 2 subtitle", time: '4:56 AM', color: getColor(), // Use the stage's color in our demo - lineType: LineType.Dashed, + // 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: 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 @@ -574,7 +588,9 @@ class NavigationManager { length: 15, percent_complete: 0.80, startPoint: LatLng(42.281973, -83.765719), - endPoint: LatLng(42.281291, -83.743918) + 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, @@ -582,13 +598,17 @@ class NavigationManager { 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) + endPoint: LatLng(42.289689, -83.738435), + color: darkColors[ColorType.navigationStepsGray]!, + lineType: LineType.Dashed ), ]; // Stores all the states for users to page back and forth diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index f436d16..6fb20fd 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -388,36 +388,121 @@ class _NavigationOverlayState extends State // ) ), + // TODO: Filter by user location!! Only show the future steps(?) + // TODO: Also show stage titles in this list + + Column( - children: widget.navigationManager.stageList.map((NavigationStage stage) { + + children: widget.navigationManager.stageList.asMap().entries.map((entry) { + + int index = entry.key; + NavigationStage stage = entry.value; + + bool shouldRoundTopCorners = (index == 0) || stage.hasRoundedCorners(); + return Column( - children: stage.getSteps().map((NavigationStageStep step) { - return Row( - children: [ - Padding(padding: EdgeInsets.only(left: 20)), - Container( - decoration: BoxDecoration( - color: step.getColor() + children: [ + + Row( + children: [ + Padding(padding: EdgeInsets.only(left: 20)), + Container( // Gray background behind colorful line segment + width: 30, + height: 40, + 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), + ) + ), + ), ), - width: 30, - height: 40, - child: Container( + + Padding(padding: EdgeInsets.only(left: 20)), + Text( + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold + ), + stage.getTitle() + ), + // Spacer(), + Container(width: 40), + // Text(stage.g()) + ] + ), - ) - ), - Padding(padding: EdgeInsets.only(left: 20)), - Text(step.getTitle()), - // Spacer(), - Container(width: 40), - Text(step.getTime()) - ] - ); - }).toList(), + ...stage.getSteps().asMap().entries.map((entry) { + int sub_index = entry.key; + NavigationStageStep step = entry.value; + // NEXT STEPS TODO: Get the border radius working on only the first and last items + + + // NEXT STEPS TODO: get live location showing on the step list, as well as properly rounded corners (see the Figma) and bigger dots on the first/last segments, etc. Also get subtitles working + + 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 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)), + Text(step.getTitle()), + // Spacer(), + Container(width: 40), + Text(step.getTime()) + ] + ); + }).toList() + ], ); // return Text(stage.getTitle()); }).toList(), ) + // ) ] ) ); From 6e67be97500e7b092fffe58fc5515d9d305a1570 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Fri, 24 Jul 2026 15:07:58 -0400 Subject: [PATCH 078/121] staged minor changes --- lib/services/navigation/navigation_manager.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 612a703..cd0a87a 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -307,10 +307,6 @@ class MissedBus extends NavigationStage { // - Based on logic: immediately ask user to get off on next stop // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally - // Data Structure Implementation - // What we need: - // - hangon... - } From 18452af00ff31c824222e93bb9c7ee43858d4485 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:21:37 +0200 Subject: [PATCH 079/121] Freshened up UI, started init from /plan-journey --- .../navigation/navigation_manager.dart | 29 ++ lib/widgets/navigation_overlay_widget.dart | 286 +++++++++++------- 2 files changed, 214 insertions(+), 101 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 601104c..ef38432 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -732,6 +732,35 @@ class NavigationManager { _activateStageSub(stageList[currentStage]); } + void initFromJourney(Journey journey) { + + this.stageList.clear(); + + for (Leg leg in journey.legs) { + // if (leg.") + debugPrint("Adding ${leg.origin}->${leg.destination} leg"); + // TODO: Call initWithLeg(leg) constructor here if it's a Bus leg + + // TODO: Add a "mode" variable to the Leg (this is returned as JSON from the API--we just need to add a variable to capture it) + } + + 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 diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 6fb20fd..991e2ea 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -1,5 +1,6 @@ import 'package:bluebus/constants.dart'; +import 'package:bluebus/services/journey_repository.dart'; import 'package:bluebus/services/navigation/navigation_manager.dart'; import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; @@ -83,116 +84,197 @@ class _NavigationOverlayState extends State children: [ Padding( padding: EdgeInsetsGeometry.only(left: 10, right: 10, top: 70), - child: Column( + child: Column( // Core column for vertical layout children: [ - - Container( - width: double.infinity, - - 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: 48, - ), + MaterialButton( + color: Colors.blue.shade900, + child: Text("Init stages from /plan-journey"), + onPressed: () async { + final journeys = await JourneyRepository.planJourney( + originLat: 42.274014, + originLon: -83.753664, + destLat: 42.297493, + destLon: -83.710782, + ); + + // Use journeys[0] to get the first one + + widget.navigationManager.initFromJourney(journeys[0]); + + + } + ), + + + Row( // Top header row + children: [ Expanded( - - child: - Padding( - padding: EdgeInsets.only(left: 10), + child: + Container( + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + decoration: BoxDecoration( + // color: getColor(context, ColorType.mapButtonPrimary), + color: Colors.green, + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), 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 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, + ), + SizedBox.square(dimension: 10,), + Text( + "Then turn left", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18 + ) + ) + ], + ) + ) + ] ) ) - ) - ]) - ), - - - Container( // I have absolutely no idea how to shrink this to fit the content. Thanks Flutter - - margin: EdgeInsets.fromLTRB(0, 10, 0, 0), - padding: EdgeInsets.all(8), + + ), + SizedBox.square(dimension: 10.0,), + Container( - 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: [ - RouteIcon.small("BB"), - 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), + 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), ), - MaterialButton( - minWidth: 50, - onPressed: () { - setState(() { - widget.navigationManager.nextStage(); - updateTimeline(); - }); - }, - child: Icon(Icons.arrow_forward, color: Colors.white) + 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) + // ) + + + + ], ) - - - - ], - ) + ), + ], ), + // Expanded(child: SizedBox.expand()), // SizedBox.expand(), @@ -388,6 +470,8 @@ class _NavigationOverlayState extends State // ) ), + SizedBox.square(dimension: 20.0,), + // TODO: Filter by user location!! Only show the future steps(?) // TODO: Also show stage titles in this list @@ -409,7 +493,7 @@ class _NavigationOverlayState extends State Padding(padding: EdgeInsets.only(left: 20)), Container( // Gray background behind colorful line segment width: 30, - height: 40, + height: 50, decoration: (index != 0) ? BoxDecoration( color: getColor(context, ColorType.navigationStepsGray) ) : null, From 81e1ada4bafd959c95cb4e0bc5adf5d702711fd2 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sat, 25 Jul 2026 20:07:40 -0400 Subject: [PATCH 080/121] Adding UI for whatbusyouareon popup, includes button for missed case --- lib/widgets/navigation_overlay_widget.dart | 87 +++++++++++++++++++--- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 991e2ea..c40536c 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -1,7 +1,9 @@ import 'package:bluebus/constants.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/route_icon.dart'; import 'package:flutter/material.dart'; @@ -55,21 +57,84 @@ class _NavigationOverlayState extends State }); } - // this is the actual Oops code portion - // not sure if this is how we should have it set up but it is here for now, going to leave a marker - // !! TEMP !! - @override - void displayOopsDialog(MissedBus stage) { - // TODO FOR ALLEN: Make this one a MaizeBusDialogue (Next Updates) - showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text(stage.getTitle()), - content: Text(stage.getSubtitle()), + 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 uh.. + 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(BuildContext context) { + showUndismissableMaizebusDialog( + contextIn: context, + title: Text("Which bus are you on?"), + content: Container( + child: Column( + spacing: 1.0, + children: [ + + ], + ) + ) + ); + } + @override Widget build(BuildContext context) { // switch (widget.navigationManager.getCurrentStage()) { From 218f6fae4920d50f0b68a2752dc9ce45e0c9fa32 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 26 Jul 2026 15:46:34 -0400 Subject: [PATCH 081/121] progress on pop up prompt for which bus you are on + missed bus option --- lib/widgets/navigation_overlay_widget.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index c40536c..48de52b 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -57,6 +57,7 @@ class _NavigationOverlayState extends State }); } + // the regular busOptions button Widget busOptionButton( Bus bus, VoidCallback onTap @@ -100,7 +101,7 @@ class _NavigationOverlayState extends State ); } - // making this reusable, bit unecessary but uh.. + // 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, From 1dd4660992d87658ade1f06b04b9f4219af447be Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:52:11 +0200 Subject: [PATCH 082/121] Started floorplan overlay, enabled nav transparency --- lib/main.dart | 12 ++ lib/screens/map_screen.dart | 6 + lib/widgets/floorplan_overlay_widget.dart | 135 +++++++++++++++++++++ lib/widgets/navigation_overlay_widget.dart | 6 +- 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/floorplan_overlay_widget.dart 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/screens/map_screen.dart b/lib/screens/map_screen.dart index 91d5d98..7b95858 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -17,6 +17,7 @@ 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'; @@ -2219,6 +2220,11 @@ class _MaizeBusCoreState extends State { ], ), ), + // Positioned.fill( + // child: RepaintBoundary( + // child: FloorplanOverlay() + // ) + // ), Positioned.fill( child: RepaintBoundary( child: NavigationOverlay(navigationManager: navigationManager) diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart new file mode 100644 index 0000000..f9b9d81 --- /dev/null +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +class FloorplanOverlay extends StatefulWidget { + // const FloorplanOverlauy + + @override + State createState() => _FloorplanOverlayState(); +} + +class _FloorplanOverlayState extends State { + + + @override + Widget build(BuildContext context) { + // TODO: implement build + return Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + 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, + ), + ), + ), + + 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: () { + + }, + style: IconButton.styleFrom(backgroundColor: Colors.white), // TODO: Make this dynamic for light/dark mode + ), + SizedBox(width: 10,), + 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( + "Duderstadt Floor 400", + style: TextStyle(color: Colors.black,), + textAlign: TextAlign.center, + ), + ) + ) + + ) + ], + ), + ), + + Spacer(), + Padding( + padding: EdgeInsetsGeometry.all(15), + child: Row( + children: [ + 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/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 991e2ea..2926150 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -114,7 +114,7 @@ class _NavigationOverlayState extends State margin: EdgeInsets.fromLTRB(0, 10, 0, 0), decoration: BoxDecoration( // color: getColor(context, ColorType.mapButtonPrimary), - color: Colors.green, + color: Color.fromARGB(255, 187, 187, 187), boxShadow: [ BoxShadow( color: getColor( @@ -190,13 +190,15 @@ class _NavigationOverlayState extends State 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 + fontSize: 18, + color: getColor(context, ColorType.mapButtonIcon) ) ) ], From 1287553d47723f03367ed47fd2ef2a7f1d3f9a68 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 26 Jul 2026 16:54:13 -0400 Subject: [PATCH 083/121] mock data for UI display currently --- lib/widgets/navigation_overlay_widget.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 48de52b..1dc46ce 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -6,6 +6,7 @@ import 'package:bluebus/services/navigation/navigation_manager.dart'; import 'package:bluebus/widgets/dialog.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 { @@ -128,8 +129,22 @@ class _NavigationOverlayState extends State content: Container( child: Column( spacing: 1.0, - children: [ - + 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(0, 9, 9, 239)), + () {}), + busOptionButton(Bus(id: "5678", + position: LatLng(12.1, 12.1), + routeId: "BB", + heading: 12.0, + fullness: "67%", + routeColor: Color.fromARGB(0, 9, 9, 239)), + () {}), + missedBusButton(() {}, Text("I missed the bus")) ], ) ) From a0c19aa79212b8f90389623373c2a29a673ff5d5 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 26 Jul 2026 16:58:20 -0400 Subject: [PATCH 084/121] commented out missed stage and accomodated changes for UI display in widget file, and lastly a temp data structure --- .../navigation/navigation_manager.dart | 83 ++++++++++--------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index fc8dcbe..4b0c800 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -110,6 +110,22 @@ enum RerouteReason { // 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 { @@ -301,42 +317,35 @@ class ChooseBus extends NavigationStage{ } // oops stage -// TODOs: -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, - }); - - // Core functionality + TODOs for Allen - // Main objectives for the "oops" stage: - // - Acknowledge to user that they have missed expected bus - // - Based on logic: immediately ask user to get off on next stop - // - Goal: Recalculate or call to recalcualte new route and redirect user to a nother stage ideally - -} +// 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. @@ -766,7 +775,7 @@ class NavigationManager { } abstract class NavigationOverlayHost { - void displayOopsDialog(MissedBus state); // just for the Oops state for now... + void displayOopsDialog(BuildContext context); // just for the Oops state for now... void onNavigationUpdated(); // call navigation overlay widget to refresh } // TODO: Call dispose() on stages as they are removed \ No newline at end of file From 85f44c071bccd7c9f378e94b97c0fae6e87a9c8c Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 2 Aug 2026 16:28:57 -0400 Subject: [PATCH 085/121] Walking stage: initWithLeg() & getFixedTitle() --- .../navigation/navigation_manager.dart | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 4b0c800..1e2772a 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -133,10 +133,6 @@ class StageReroute extends StageEvent { StageReroute(this.reason); } -class NavWalking extends NavigationStage { - // ... -} - class NavOnBus extends NavigationStage { String rt; String departureStop; @@ -384,14 +380,23 @@ class Walking extends NavigationStage { } //call this whenever a new gps fix arrives, returns true if a waypoint was just cleared (so the ui can refresh) - bool updatePosition(LatLng newPos) { - currWalkingPos = newPos; - if (_nextIndex < points.length && - _distMeters(newPos, points[_nextIndex]) <= _reachThresholdMeters) { - _nextIndex++; - return true; + @override + bool receiveLocationUpdate(LatLng newLocation) { + currWalkingPos = newLocation; + + // check if it has not reached new waypoint + if (_nextIndex >= points.length || + _distMeters(newLocation, points[_nextIndex]) > _reachThresholdMeters) { + return false; } - 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 @@ -407,6 +412,27 @@ class Walking extends NavigationStage { 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) { @@ -433,6 +459,24 @@ class Walking extends NavigationStage { } return "U-turn"; } + + // Initializes the Walking stage given a Leg. + @override + void initWithLeg(Leg leg) { + final path = leg.pathCoords; + + if (path == null || path.isEmpty) { + throw ArgumentError( + 'Walking leg from ${leg.origin} to ${leg.destination} has no path.', + ); + } + + points = List.unmodifiable(path); + _nextIndex = 0; + + length = leg.duration; + percent_complete = 0.0; + } } From 3c8b25be2d480c6268ce633fe93d85c542f5ae41 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:43:08 +0200 Subject: [PATCH 086/121] Added base API call chain Clicking on the "Init from /plan-journey" button will now try to initialize walking and bus stages --- lib/models/journey.dart | 7 +++- .../navigation/navigation_manager.dart | 42 +++++++++++++------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/models/journey.dart b/lib/models/journey.dart index 0ba9676..3dba255 100644 --- a/lib/models/journey.dart +++ b/lib/models/journey.dart @@ -16,6 +16,8 @@ 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; @@ -30,6 +32,7 @@ class Leg { final String destinationID; final List? pathCoords; final Map? directions; + final LegMode mode; Leg({ required this.origin, @@ -44,6 +47,7 @@ class Leg { required this.destinationID, this.pathCoords, this.directions, + required this.mode }); factory Leg.fromJson(Map json) { @@ -76,7 +80,8 @@ class Leg { (degree: (x['turn']['degrees'] as num).toDouble(), landmark: x['turn']['landmark'] as String) } - : null + : null, + mode: (json['mode'] == "bus" ? LegMode.bus : LegMode.walk) ); } } diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 1e2772a..72864e0 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -490,7 +490,7 @@ class DemoStage extends NavigationStage { return "Look, here's a subtitle too #$favoriteNumber"; } - double length = 15.0; + double length = 15.0; // In minutes double percent_complete = 0.110; LatLng startPoint; @@ -659,6 +659,8 @@ class NavigationManager { color: darkColors[ColorType.navigationStepsGray]!, lineType: LineType.Dashed ), + + ]; // Stores all the states for users to page back and forth NavigationLayer? mapLayer; @@ -790,20 +792,36 @@ class NavigationManager { debugPrint("Adding ${leg.origin}->${leg.destination} leg"); // TODO: Call initWithLeg(leg) constructor here if it's a Bus leg + if (leg.mode == LegMode.walk) { + Walking walkingStage = Walking(); + walkingStage.initWithLeg(leg); + this.stageList.add(walkingStage); + } else if (leg.mode == LegMode.bus) { + NavOnBus onBusStage = NavOnBus( + rt: leg.rt ?? "", + departureStop: leg.destinationID, + arrivalStop: leg.originID, + trip: leg.trip!, + busPath: [] + ); + onBusStage.initWithLeg(leg); + this.stageList.add(onBusStage); + } + // TODO: Add a "mode" variable to the Leg (this is returned as JSON from the API--we just need to add a variable to capture it) } - 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 - ) - ); + // 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(); From ac2e0142f007b0c5bac38a5ded1457c72326e70e Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:46:31 +0200 Subject: [PATCH 087/121] Removed comment --- lib/services/navigation/navigation_manager.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 72864e0..aaf7f67 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -807,8 +807,6 @@ class NavigationManager { onBusStage.initWithLeg(leg); this.stageList.add(onBusStage); } - - // TODO: Add a "mode" variable to the Leg (this is returned as JSON from the API--we just need to add a variable to capture it) } // this.stageList.add( From 5094eb92373865b6884a7141004cb6ffc049fe52 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:23:53 +0200 Subject: [PATCH 088/121] Added floor selector to floor plans --- lib/widgets/floorplan_overlay_widget.dart | 193 +++++++++++++++++++--- 1 file changed, 170 insertions(+), 23 deletions(-) diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index f9b9d81..9f8b829 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -1,5 +1,144 @@ +import 'package:bluebus/constants.dart'; import 'package:flutter/material.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 { + // const FloorSelector + + @override + State createState() => _FloorSelectorState(); +} + +class _FloorSelectorState extends State { + List floors = ["1", "2", "3"]; + int selectedIndex = 1; + double yDragDistance = 0.0; + + void snapToIndex(int index) { + setState(() { + yDragDistance = 0; + selectedIndex = index; + }); + } + + + @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 + } + + }); + + }, + 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: () { + debugPrint("Clicked!"); + 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 FloorplanOverlay extends StatefulWidget { // const FloorplanOverlauy @@ -8,7 +147,7 @@ class FloorplanOverlay extends StatefulWidget { } class _FloorplanOverlayState extends State { - + List floors = ["1", "2", "3"]; // TODO: Change type as necessary @override Widget build(BuildContext context) { @@ -18,21 +157,22 @@ class _FloorplanOverlayState extends State { children: [ Container( decoration: BoxDecoration( - 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, - ), + color: maizeBusBlue, + // 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, + // ), ), ), @@ -78,15 +218,22 @@ class _FloorplanOverlayState extends State { Padding( padding: EdgeInsetsGeometry.all(15), child: Row( + // mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - IconButton.filled( - icon: Icon(Icons.layers), - iconSize: 30, - onPressed: () { - }, - style: IconButton.styleFrom(backgroundColor: Colors.white), // TODO: Make this dynamic for light/dark mode - ), + + FloorSelector(), + + + // 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( From 79870a17951a9ecfa165669f31f66b0d6b9bdfbf Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 25 Jul 2026 19:40:53 -0700 Subject: [PATCH 089/121] chore: update font_awesome_flutter to v11 --- android/gradle.properties | 4 ++++ lib/screens/map_screen.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..475a628 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7b95858..13a7220 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1865,7 +1865,7 @@ class _MaizeBusCoreState extends State { 45) * vec_math.degrees2Radians : 0, - child: Icon( + child: FaIcon( FontAwesomeIcons.compass, color: getColor( context, diff --git a/pubspec.yaml b/pubspec.yaml index 329297e..ccfba49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: 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 From bcb97f357e95b18a5ee039320400a200ce501130 Mon Sep 17 00:00:00 2001 From: Edward Zhang Date: Sat, 8 Aug 2026 10:34:25 -0700 Subject: [PATCH 090/121] feat(NavOnBus): complete bulk of the implementation The polyline returned is still a placeholder awaiting backend changes. Adjusted the navigation overlay widget to add a loading state and choose the journey to display from more options. --- .../navigation/navigation_manager.dart | 298 ++++++++++++++---- lib/utils/geometry.dart | 12 + lib/utils/time.dart | 16 + lib/widgets/journey_results_widget.dart | 14 +- lib/widgets/navigation_overlay_widget.dart | 45 ++- pubspec.yaml | 2 +- 6 files changed, 299 insertions(+), 88 deletions(-) create mode 100644 lib/utils/time.dart diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index aaf7f67..681808c 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -4,13 +4,18 @@ import 'dart:math'; import 'dart:math' as math; 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} @@ -133,98 +138,275 @@ class StageReroute extends StageEvent { 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 { - String rt; - String departureStop; - String arrivalStop; - - Trip trip; - List<(LatLng, (int, BusStop)?)> busPath; - // BusRouteLine busPath; - - NavOnBus({ - required this.rt, - required this.departureStop, - required this.arrivalStop, - required this.trip, - required this.busPath, - }); + late NavOnBusState state; + late BitmapDescriptor stopBitmap; + LatLng? lastPosition; - factory NavOnBus.init(Leg leg, Map> routesCache) { - final maybeRt = leg.rt; - final maybeTrip = leg.trip; - if (maybeRt == null || - maybeTrip == null || - leg.stopTimes == null || + 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 Exception("leg was malformed or not a bus leg"); + throw FormatException("leg was malformed"); } - final busLine = determineRouteOfBusLeg(routesCache, maybeRt, leg.originID, leg.destinationID); - if (busLine == null) throw Exception("bus line not found"); - - final stopsIter = busLine.stops.skipWhile((s) => s.$2.id != leg.originID); - final startIdx = stopsIter.firstOrNull?.$1; - final endIdx = stopsIter.where((s) => s.$2.id == leg.destinationID).firstOrNull?.$1; - if (startIdx == null || endIdx == null) throw Exception("valid bus line not found"); - - final busPath = <(LatLng, (int, BusStop)?)>[]; - for (int i = startIdx; i <= endIdx; i++) { - busPath.add((busLine.points[i], busLine.stops.where((s) => s.$1 == i).firstOrNull)); + 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), + ), + )); } - - return NavOnBus( - rt: maybeRt, + 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, - trip: maybeTrip, - busPath: busPath, + 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(LatLng newLocation) { + lastPosition = newLocation; + // TODO: determine if stage is over } @override String getTitle() { - // TODO: implement getTitle - return "($rt) Ride ${-1} more stops"; + // 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() { - // TODO: implement getSubtitle - return "${-1} min"; + return "Get off at ${getStopNameFromID(state.arrivalStop)}"; } @override - // TODO: implement length - double get length => super.length; + // 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 - // TODO: implement percent_complete - double get percent_complete => super.percent_complete; + // 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() { - // TODO: implement getSteps - return super.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() { - // TODO: implement getMarkers - return super.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() { - // TODO: implement getPolylines - return super.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(rt); + return RouteColorService.getRouteColor(state.rt); } - } typedef Edge = ({ BusStop from, BusStop to, List points }); @@ -797,13 +979,7 @@ class NavigationManager { walkingStage.initWithLeg(leg); this.stageList.add(walkingStage); } else if (leg.mode == LegMode.bus) { - NavOnBus onBusStage = NavOnBus( - rt: leg.rt ?? "", - departureStop: leg.destinationID, - arrivalStop: leg.originID, - trip: leg.trip!, - busPath: [] - ); + NavOnBus onBusStage = NavOnBus(); onBusStage.initWithLeg(leg); this.stageList.add(onBusStage); } diff --git a/lib/utils/geometry.dart b/lib/utils/geometry.dart index e2c1a1b..7d2bd9a 100644 --- a/lib/utils/geometry.dart +++ b/lib/utils/geometry.dart @@ -21,6 +21,18 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { return angle; } +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() { 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/journey_results_widget.dart b/lib/widgets/journey_results_widget.dart index 56e51e5..917698e 100644 --- a/lib/widgets/journey_results_widget.dart +++ b/lib/widgets/journey_results_widget.dart @@ -1,5 +1,6 @@ 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'; @@ -490,19 +491,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, diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 87944f6..b11796b 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -1,4 +1,6 @@ +import 'dart:math'; + import 'package:bluebus/constants.dart'; import 'package:bluebus/models/bus.dart'; import 'package:bluebus/services/journey_repository.dart'; @@ -27,6 +29,7 @@ class NavigationOverlay extends StatefulWidget { 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) @@ -169,20 +172,36 @@ class _NavigationOverlayState extends State children: [ MaterialButton( color: Colors.blue.shade900, - child: Text("Init stages from /plan-journey"), + child: Text(planJourneyInProgress ? "Loading..." : "Init stages from /plan-journey"), onPressed: () async { - final journeys = await JourneyRepository.planJourney( - originLat: 42.274014, - originLon: -83.753664, - destLat: 42.297493, - destLon: -83.710782, - ); - - // Use journeys[0] to get the first one - - widget.navigationManager.initFromJourney(journeys[0]); - - + 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; + }); + } } ), diff --git a/pubspec.yaml b/pubspec.yaml index ccfba49..a90b1f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,7 @@ 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 From 318233347fb8a8ddb5d393fbb0a3809a21cd41d9 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:25:06 +0200 Subject: [PATCH 091/121] Reformulating layout a little It looks a little broken for now--but trying to make the layout more responsive to text wrapping (as well as fixing the margins a little bit). --- .../navigation/navigation_manager.dart | 2 +- lib/widgets/navigation_overlay_widget.dart | 132 +++++++++++++----- 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 681808c..b5d14da 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -736,7 +736,7 @@ class DemoStage extends NavigationStage { lineType: this.lineType ), NavigationStageStep( - title: "Step 2", + 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 diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index b11796b..b3bc7a9 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -610,7 +610,7 @@ class _NavigationOverlayState extends State ), ), - Padding(padding: EdgeInsets.only(left: 20)), + Padding(padding: EdgeInsets.only(left: 15)), Text( style: TextStyle( fontSize: 16.0, @@ -642,45 +642,111 @@ class _NavigationOverlayState extends State shouldRoundBottomCorners = true; } - return Row( + return Stack( + children: [ - Padding(padding: EdgeInsets.only(left: 20)), - Container( // Gray background behind colorful line segment + Positioned( + left: 10, + top: 0, + bottom: 0, 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, + 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: Colors.white, - borderRadius: BorderRadius.all(Radius.circular(100)) + color: step.getColor(), + borderRadius: BorderRadius.only( + bottomLeft: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero, + bottomRight: (shouldRoundBottomCorners) ? Radius.circular(20) : Radius.zero + ) ), - // color: Colors.white - ) + + 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)), - Text(step.getTitle()), - // Spacer(), - Container(width: 40), - Text(step.getTime()) - ] + + 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() ], ); From f06ed1cb1a2512ddc2d25fbbfed5b694e5750681 Mon Sep 17 00:00:00 2001 From: Pronkle Date: Sun, 9 Aug 2026 00:57:35 -0500 Subject: [PATCH 092/121] tested: ?missed bus prompt --- .../navigation/navigation_manager.dart | 12 +++- lib/widgets/navigation_overlay_widget.dart | 57 ++++++++++++------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 4b0c800..56afc19 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -631,11 +631,17 @@ class NavigationManager { } } - // Call to update if state changes require an update + // 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 @@ -774,8 +780,8 @@ class NavigationManager { // The stage (e.g. "On bus") should call the "Oops" stage when it needs to } -abstract class NavigationOverlayHost { - void displayOopsDialog(BuildContext context); // just for the Oops state for now... +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 \ No newline at end of file diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 87944f6..84b96f6 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -122,29 +122,40 @@ class _NavigationOverlayState extends State // 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(BuildContext context) { + void displayOopsDialog() { showUndismissableMaizebusDialog( contextIn: context, - title: Text("Which bus are you on?"), - content: Container( - child: Column( - spacing: 1.0, + 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(0, 9, 9, 239)), - () {}), - busOptionButton(Bus(id: "5678", - position: LatLng(12.1, 12.1), - routeId: "BB", - heading: 12.0, - fullness: "67%", - routeColor: Color.fromARGB(0, 9, 9, 239)), - () {}), - missedBusButton(() {}, Text("I missed the bus")) + 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")) ], ) ) @@ -186,6 +197,12 @@ class _NavigationOverlayState extends State } ), + MaterialButton( + color: Colors.red.shade900, + child: Text("Show Oops dialog"), + onPressed: () => widget.navigationManager.showOopsDialog(), + ), + Row( // Top header row children: [ From 9f4f390f000a70c64cb8440243940c4974b5c0d8 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:35:47 -0700 Subject: [PATCH 093/121] Started implementing Fancy Stop Icons --- lib/screens/map_screen.dart | 5 -- lib/services/map_image_service.dart | 90 ++++++++++++++++++- .../map_layers/base_routes_layer.dart | 22 +++-- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 13a7220..eec9be5 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1790,11 +1790,6 @@ class _MaizeBusCoreState extends State { ), ), - // Expanded( - // child: NavigationOverlay(navigationManager: navigationManager), - // ), - - // reminder widget SizedBox(height: 30.0), diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index c5f6108..123cd9e 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -6,10 +7,14 @@ 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'; +const STOP_ICON_WIDTH = 65; +const STOP_ICON_HEIGHT = 65; + class MapImageService { // Route specific bus icons static Map _routeBusIcons = {}; @@ -17,6 +22,30 @@ class MapImageService { // 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 ui.Image? _stopIconImage; + static ui.Image? _rideStopIconImage; + static ui.Image? _favStopIconImage; + static ui.Image? _favRideStopIconImage; + + static ByteData? _stopIconBytes; + static ByteData? _rideStopIconBytes; + static ByteData? _favStopIconBytes; + static ByteData? _favRideStopIconBytes; + static Future getFrontEndImageVer() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -230,8 +259,7 @@ class MapImageService { _routeBusIcons.clear(); _loadRouteSpecificBusIcons(); } - - // FUTURE: Maybe wrap this into a map_image_service.dart file? +// 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; @@ -269,7 +297,65 @@ class MapImageService { } } + static Future _decode(ByteData data, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + data.buffer.asUint8List(), + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + static Future getFancyStopIcon() async { // TODO: Pass in a list of bus route codes here later + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + 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 { + canvas.drawImage(_stopIconImage!, Offset.zero, 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, (total_width - STOP_ICON_WIDTH).toDouble(), STOP_ICON_HEIGHT.toDouble()), paint); + + final picture = recorder.endRecording(); + final img = await picture.toImage(total_width, total_height); + final byteData = await img.toByteData(format: ui.ImageByteFormat.png); + + return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); + } + static Future loadData() async { await _loadRouteSpecificBusIcons(); + + 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'); + + _stopIconImage = await _decode(_stopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); + _rideStopIconImage = await _decode(_rideStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); + _favStopIconImage = await _decode(_favStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); + _favRideStopIconImage = await _decode(_favRideStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); + + // Load stop icons + stopIcon = await MapImageService.resizeImage(_stopIconBytes!); + rideStopIcon = await MapImageService.resizeImage(_rideStopIconBytes!); + favStopIcon = await MapImageService.resizeImage(_favStopIconBytes!,); + favRideStopIcon = await MapImageService.resizeImage(_favRideStopIconBytes!); + + } catch (e) { + // Fallback to default markers if custom loading fails + // These are now set as initial values + } } } diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 63ae296..82c5f3c 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -42,11 +42,11 @@ class BaseRoutesLayer extends CompositeMapLayer { {}; // TODO: Merge this with polylines variable? Map polylinesCache = {}; - void cacheRoutes(List routes) { + void cacheRoutes(List routes) async { // Called from inside _loadAllData() inside map_screen.dart routesCache = routes; - reloadMarkers(); + await reloadMarkers(); reloadPolylines(); if (isVisible) onUpdate(); @@ -63,13 +63,13 @@ class BaseRoutesLayer extends CompositeMapLayer { _loadCustomMarkers(); } - void reload() { - reloadMarkers(); + void reload() async { + await reloadMarkers(); reloadPolylines(); if (isVisible) onUpdate(); } - void reloadMarkers() { + Future reloadMarkers() async { markersCache.clear(); for (final r in routesCache) { @@ -96,9 +96,15 @@ class BaseRoutesLayer extends CompositeMapLayer { flat: true, // icon: BitmapDescriptor.defaultMarker, icon: - favoriteStops.contains(stop.id) // Used to be isFavorite - ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - : (stop.isRide ? _rideStopIcon : _stopIcon), + // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) + // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), + await MapImageService.getFancyStopIcon(), + + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + // : (stop.isRide ? _rideStopIcon : _stopIcon), consumeTapEvents: true, onTap: () { onStopClicked(stop); From 7a4c16b649090d87c1bbee1d9ac0508ce05a90ef Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:31:54 -0700 Subject: [PATCH 094/121] Design for marker route previews --- lib/services/map_image_service.dart | 151 +++++++++++++----- .../map_layers/base_routes_layer.dart | 123 +++++++------- lib/widgets/navigation_overlay_widget.dart | 11 +- 3 files changed, 183 insertions(+), 102 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 123cd9e..3aa8fe9 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -15,6 +15,18 @@ import 'package:shared_preferences/shared_preferences.dart'; 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 = {}; @@ -46,6 +58,8 @@ class MapImageService { static ByteData? _favStopIconBytes; static ByteData? _favRideStopIconBytes; + static bool _stopIconsInitialized = false; + static Future getFrontEndImageVer() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -222,6 +236,33 @@ class MapImageService { } } + 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'); + + _stopIconImage = await _decode(_stopIconBytes!); + _rideStopIconImage = await _decode(_rideStopIconBytes!); + _favStopIconImage = await _decode(_favStopIconBytes!); + _favRideStopIconImage = await _decode(_favRideStopIconBytes!); + + // Load stop icons + stopIcon = await MapImageService.resizeImage(_stopIconBytes!); + rideStopIcon = await MapImageService.resizeImage(_rideStopIconBytes!); + favStopIcon = await MapImageService.resizeImage(_favStopIconBytes!,); + favRideStopIcon = await MapImageService.resizeImage(_favRideStopIconBytes!); + + _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 { @@ -297,65 +338,103 @@ class MapImageService { } } - static Future _decode(ByteData data, int width, int height) { - final completer = Completer(); - ui.decodeImageFromPixels( - data.buffer.asUint8List(), - width, - height, - ui.PixelFormat.rgba8888, - completer.complete, - ); - return completer.future; + 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) { + 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, + ) + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr + )..layout(minWidth: 0, maxWidth: width.toDouble()); + + 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)); } + +// 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 static Future getFancyStopIcon() async { // TODO: Pass in a list of bus route codes here later final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - int total_width = STOP_ICON_WIDTH * 2 + STOP_ICON_WIDTH; - int total_height = STOP_ICON_HEIGHT; + List routesServed = ["BB", "CS", "CN", "CSX"]; + + // 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 { - canvas.drawImage(_stopIconImage!, Offset.zero, Paint()); // 1 pixel to 1 canvas unit. I'm treating canvas units as pixels here + if (!_stopIconsInitialized) { + debugPrint("Stop icons not initialized, loading..."); + await _loadStopIcons(); + } + + 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, (total_width - STOP_ICON_WIDTH).toDouble(), STOP_ICON_HEIGHT.toDouble()), paint); + // canvas.drawRect(Rect.fromLTWH(STOP_ICON_WIDTH.toDouble(), 0, (FANCY_STOP_ICON_WIDTH - STOP_ICON_WIDTH).toDouble(), STOP_ICON_HEIGHT.toDouble()), paint); + + int xDrawPos = STOP_ICON_WIDTH + FANCY_STOP_ICON_XHEADROOM + FANCY_STOP_ICON_MARGIN; + int yDrawPos = 0; + + 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; + } + + String routeId = routesServed[i]; + drawRouteIconOntoCanvas( + canvas, + xDrawPos, // x + yDrawPos, // y + ROW_ICON_SIZE.toInt(), // width + ROW_ICON_SIZE.toInt(), // height + routeId); + + xDrawPos += ROW_ICON_SIZE.toInt() + FANCY_STOP_ICON_SMALLMARGIN; + } final picture = recorder.endRecording(); - final img = await picture.toImage(total_width, total_height); + final img = await picture.toImage(FANCY_STOP_ICON_WIDTH.toInt(), FANCY_STOP_ICON_HEIGHT); final byteData = await img.toByteData(format: ui.ImageByteFormat.png); return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); } + static Offset getFancyStopIconOffset() { + double offsetX = (STOP_ICON_WIDTH.toDouble() / 2) / 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 Future loadData() async { await _loadRouteSpecificBusIcons(); - 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'); - - _stopIconImage = await _decode(_stopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); - _rideStopIconImage = await _decode(_rideStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); - _favStopIconImage = await _decode(_favStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); - _favRideStopIconImage = await _decode(_favRideStopIconBytes!, STOP_ICON_WIDTH, STOP_ICON_HEIGHT); - - // Load stop icons - stopIcon = await MapImageService.resizeImage(_stopIconBytes!); - rideStopIcon = await MapImageService.resizeImage(_rideStopIconBytes!); - favStopIcon = await MapImageService.resizeImage(_favStopIconBytes!,); - favRideStopIcon = await MapImageService.resizeImage(_favRideStopIconBytes!); - - } catch (e) { - // Fallback to default markers if custom loading fails - // These are now set as initial values - } + await _loadStopIcons(); } } diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 82c5f3c..7369a4d 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -69,66 +69,77 @@ class BaseRoutesLayer extends CompositeMapLayer { if (isVisible) onUpdate(); } - Future reloadMarkers() async { - markersCache.clear(); + // TODO: Add caching so this doesn't have to recompute markers for each stop each time - 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 (!markersCache.containsKey(routeKey)) { - // Prevent duplicate copies of the same stop on top of each other - markersCache[routeKey] = {}; - for (final (_, stop) in r.stops) { - // iterate through all stops in this route - // TODO: Implement favorite stops - // final isFavorite = _favoriteStops.contains(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_${stop.id}_${Object.hashAll(r.points)}'), - position: stop.location, - flat: true, - // icon: BitmapDescriptor.defaultMarker, - icon: - // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) - // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), - await MapImageService.getFancyStopIcon(), - - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - // : (stop.isRide ? _rideStopIcon : _stopIcon), - consumeTapEvents: true, - onTap: () { - onStopClicked(stop); - }, - rotation: stop.rotation, - anchor: Offset(0.5, 0.5), - ); - // _routeStopMarkers[routeKey]?[stop.id] = marker; - - markersCache[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; + Future reloadMarkers() async { + try { + markersCache.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 (!markersCache.containsKey(routeKey)) { + // Prevent duplicate copies of the same stop on top of each other + markersCache[routeKey] = {}; + for (final (idx, stop) in r.stops) { + // iterate through all stops in this route + // TODO: Implement favorite stops + // final isFavorite = _favoriteStops.contains(stop.id); + + + // TODO: ****** See why the duplicate cache isn't working in some cases! + + bool useFancyStopIcon = true; + + 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_${stop.id}_${Object.hashAll(r.points)}'), + position: stop.location, + flat: true, + // icon: BitmapDescriptor.defaultMarker, + icon: + // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) + // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), + (idx % 3 == 1) ? await MapImageService.getFancyStopIcon() : MapImageService.stopIcon, + + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + // : (stop.isRide ? _rideStopIcon : _stopIcon), + consumeTapEvents: true, + onTap: () { + onStopClicked(stop); + }, + rotation: useFancyStopIcon ? 0.0 : stop.rotation, + anchor: useFancyStopIcon ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + ); + // _routeStopMarkers[routeKey]?[stop.id] = marker; + + markersCache[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; + } } } - } - // markers = {}; - markers = markersCache.values.expand((Map m) { - return m.values; - }).toSet(); + // markers = {}; + markers = markersCache.values.expand((Map m) { + return m.values; + }).toSet(); + } catch (err) { + debugPrint("Error: $err"); + } } void reloadPolylines() { diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 765cf3d..666eff1 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -539,8 +539,6 @@ class _NavigationOverlayState extends State ), - // Text("HIIIIIII THIS IS A TEST ${dotLeft}, pos %: ${this.timelineInfo.activePositionPercentage}"), - // Container( // width: dotSize, // height: dotSize, @@ -590,10 +588,6 @@ class _NavigationOverlayState extends State ), SizedBox.square(dimension: 20.0,), - - // TODO: Filter by user location!! Only show the future steps(?) - // TODO: Also show stage titles in this list - Column( @@ -644,10 +638,7 @@ class _NavigationOverlayState extends State ...stage.getSteps().asMap().entries.map((entry) { int sub_index = entry.key; NavigationStageStep step = entry.value; - // NEXT STEPS TODO: Get the border radius working on only the first and last items - - - // NEXT STEPS TODO: get live location showing on the step list, as well as properly rounded corners (see the Figma) and bigger dots on the first/last segments, etc. Also get subtitles working + // NEXT STEPS TODO: get live location showing on the step list bool shouldRoundBottomCorners = false; From 0c3f2f5254f5ee19bca6ace6b7201edac6292a21 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:30:37 -0700 Subject: [PATCH 095/121] Got fancy stop icons to display real data --- lib/services/map_image_service.dart | 68 +++++++++++++++++-- .../map_layers/base_routes_layer.dart | 67 ++++++++++++++++-- lib/widgets/composite_map_widget.dart | 40 ++++++----- 3 files changed, 147 insertions(+), 28 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 3aa8fe9..65e99ab 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -11,6 +12,7 @@ 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; @@ -32,6 +34,8 @@ class MapImageService { 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" + // TODO: Maybe make this manage stop icons too? @@ -356,6 +360,7 @@ class MapImageService { fontSize: width / 2, fontWeight: FontWeight.w900, letterSpacing: -1, + fontFamily: 'Urbanist' ) ), textAlign: TextAlign.center, @@ -367,13 +372,45 @@ class MapImageService { // textPainter.paint(canvas, Offset(0,0)); } + 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 - static Future getFancyStopIcon() async { // TODO: Pass in a list of bus route codes here later +// +// *** 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, 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 = stopId; + + + 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); - List routesServed = ["BB", "CS", "CN", "CSX"]; + debugPrint("Generating icon for ${routesServed.join(",")}"); // int total_width = STOP_ICON_WIDTH * 2 + STOP_ICON_WIDTH; // int total_height = STOP_ICON_HEIGHT; @@ -390,14 +427,31 @@ class MapImageService { await _loadStopIcons(); } - 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 + drawRotatedImage( + canvas, + _stopIconImage!, + 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 @@ -421,13 +475,15 @@ class MapImageService { final img = await picture.toImage(FANCY_STOP_ICON_WIDTH.toInt(), FANCY_STOP_ICON_HEIGHT); final byteData = await img.toByteData(format: ui.ImageByteFormat.png); - return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); + BitmapDescriptor output = BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); + _fancyStopIconsCache[cacheKey] = output; + return output; } static Offset getFancyStopIconOffset() { - double offsetX = (STOP_ICON_WIDTH.toDouble() / 2) / FANCY_STOP_ICON_WIDTH.toDouble(); + 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"); + // debugPrint("Offset X: $offsetX, Y: $offsetY"); return Offset(offsetX, offsetY); // return Offset(0.5, 0.5); } diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 7369a4d..7417173 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; +const double FANCY_ICONS_ZOOM_THRESHOLD = 17.55; + class BaseRoutesLayer extends CompositeMapLayer { @override bool isVisible = true; @@ -20,7 +22,11 @@ class BaseRoutesLayer extends CompositeMapLayer { 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 = {}; @@ -46,6 +52,31 @@ class BaseRoutesLayer extends CompositeMapLayer { // 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 { + MapImageService.getFancyStopIcon(entry.key, stopIdToStop![entry.key]!.rotation, entry.value.toList()); // Pre-cache each icon so it's faster later! + } 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) + + // stopIdToRouteId.entries.forEach((e) => { + // debugPrint("Bus stop ${e.key} has service from ${e.value.join(", ")}") + // },); + await reloadMarkers(); reloadPolylines(); @@ -71,10 +102,29 @@ class BaseRoutesLayer extends CompositeMapLayer { // TODO: Add caching so this doesn't have to recompute markers for each stop each time + @override + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) async { + // debugPrint("Camera zoomed from ${oldPosition.zoom} to ${newPosition.zoom}"); + if (oldPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD) { + debugPrint("******* ENABLING FANCY ICONS"); + displayFancyIcons = true; + await reloadMarkers(); + if (isVisible) onUpdate(); + } else if (oldPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD) { + debugPrint("******* DISABLING FANCY ICONS"); + displayFancyIcons = false; + await reloadMarkers(); + if (isVisible) onUpdate(); + } + } + Future reloadMarkers() async { try { markersCache.clear(); + // FUTURE TODO: Create these icons asynchronously and CACHE THEM so they don't block when we're trying to load them all. Make sure they display all available routes even if only a few are selected (so the cache doesn't become invalid when the user selects different routes) + + for (final r in routesCache) { if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes @@ -94,7 +144,8 @@ class BaseRoutesLayer extends CompositeMapLayer { // TODO: ****** See why the duplicate cache isn't working in some cases! - bool useFancyStopIcon = true; + // List routesServed = ["BB", "CS", "CN", "CSX"]; + Set routesServed = stopIdToRouteIds[stop.id] ?? {}; final marker = Marker( zIndexInt: @@ -108,7 +159,15 @@ class BaseRoutesLayer extends CompositeMapLayer { // favoriteStops.contains(stop.id) // Used to be isFavorite // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), - (idx % 3 == 1) ? await MapImageService.getFancyStopIcon() : MapImageService.stopIcon, + (displayFancyIcons) ? await MapImageService.getFancyStopIcon(stop.id, stop.rotation, routesServed.toList()) : MapImageService.stopIcon, + + // NEXT STEPS TODO: + // * Stagger the marker updates across frames, instead of trying to load them in all at once. Process maybe 50-100 markers at a time before waiting + // * Add support for favorited stops (add the favorite/nonfavorite flag as part of the cache key to make sure our cache won't give us an old icon) + // * + + // TODO: Maybe only generate fancy stop icons if we can confirm the Marker is within view? + // favoriteStops.contains(stop.id) // Used to be isFavorite // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) @@ -117,8 +176,8 @@ class BaseRoutesLayer extends CompositeMapLayer { onTap: () { onStopClicked(stop); }, - rotation: useFancyStopIcon ? 0.0 : stop.rotation, - anchor: useFancyStopIcon ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + rotation: displayFancyIcons ? 0.0 : stop.rotation, + anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), ); // _routeStopMarkers[routeKey]?[stop.id] = marker; diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 7b9cf5f..70091b4 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -1,35 +1,22 @@ -import 'dart:math'; -import 'dart:math' as math; -import 'dart:typed_data'; -import 'dart:ui' as ui; - 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'; -import 'package:bluebus/models/journey.dart'; -import 'package:bluebus/services/map_image_service.dart'; import 'package:bluebus/services/map_layers/journey_layer.dart'; import 'package:bluebus/services/map_layers/live_buses_layer.dart'; -import 'package:bluebus/services/route_color_service.dart'; -import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:haptic_feedback/haptic_feedback.dart'; -import 'package:widget_to_marker/widget_to_marker.dart'; // Define the CompositeMapLayer abstract class CompositeMapLayer { - // Every CompositeMapLayer must have these four things + // Every CompositeMapLayer must have these five things bool get isVisible; Set get polylines; Set get markers; Function() get onUpdate; void setOnUpdate(Function() fn); void dispose() {} + + // Optional: If they need, CompositeMapLayers can include these things + void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) {} } // TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff @@ -60,6 +47,7 @@ class CompositeMapWidgetState extends State GoogleMapController? _mapController; Set allMarkers = {}; Set allPolylines = {}; + CameraPosition? oldCameraPosition; void reloadMap() { setState(() {}); // Rebuild with updated markers @@ -137,7 +125,23 @@ class CompositeMapWidgetState extends State }); widget.onMapCreated(controller); }, - onCameraMove: widget.onCameraMove, + 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, ), ); From 6f85c0023a4dc3fb677e8a470b2a32e70d3d8a2f Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:51:29 -0700 Subject: [PATCH 096/121] Optimizations for fancy marker loading --- lib/services/map_image_service.dart | 12 +- .../map_layers/base_routes_layer.dart | 274 +++++++++++++----- 2 files changed, 202 insertions(+), 84 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 65e99ab..70cae50 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -394,11 +394,11 @@ class MapImageService { // 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, double rotation, List routesServed) async { // TODO: Pass in a list of bus route codes here later + 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 = stopId; + String cacheKey = "${stopId}_$isFavorite"; if (_fancyStopIconsCache.containsKey(cacheKey)) { @@ -410,7 +410,7 @@ class MapImageService { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - debugPrint("Generating icon for ${routesServed.join(",")}"); + // debugPrint("Generating icon for ${routesServed.join(",")}"); // int total_width = STOP_ICON_WIDTH * 2 + STOP_ICON_WIDTH; // int total_height = STOP_ICON_HEIGHT; @@ -427,9 +427,13 @@ class MapImageService { await _loadStopIcons(); } + ui.Image? targetImage = isFavorite ? + (isRide ? _favRideStopIconImage : _favStopIconImage) : + (isRide ? _rideStopIconImage : _stopIconImage); + drawRotatedImage( canvas, - _stopIconImage!, + targetImage!, Offset( FANCY_STOP_ICON_XHEADROOM.toDouble() + (STOP_ICON_WIDTH.toDouble() / 2), FANCY_STOP_ICON_YHEADROOM.toDouble() + (STOP_ICON_HEIGHT.toDouble() / 2)), diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 7417173..1a1c035 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -1,9 +1,12 @@ +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'; @@ -44,8 +47,12 @@ class BaseRoutesLayer extends CompositeMapLayer { BitmapDescriptor.hueAzure, ); - Map> markersCache = - {}; // TODO: Merge this with polylines variable? + // 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 { @@ -67,7 +74,13 @@ class BaseRoutesLayer extends CompositeMapLayer { debugPrint("Pre-generating fancy stop icons"); for (MapEntry entry in stopIdToRouteIds.entries) { try { - MapImageService.getFancyStopIcon(entry.key, stopIdToStop![entry.key]!.rotation, entry.value.toList()); // Pre-cache each icon so it's faster later! + 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! } catch (err) {} } @@ -77,7 +90,7 @@ class BaseRoutesLayer extends CompositeMapLayer { // debugPrint("Bus stop ${e.key} has service from ${e.value.join(", ")}") // },); - await reloadMarkers(); + await reloadAllMarkers(); reloadPolylines(); if (isVisible) onUpdate(); @@ -95,7 +108,7 @@ class BaseRoutesLayer extends CompositeMapLayer { } void reload() async { - await reloadMarkers(); + await reloadAllMarkers(); reloadPolylines(); if (isVisible) onUpdate(); } @@ -108,99 +121,200 @@ class BaseRoutesLayer extends CompositeMapLayer { if (oldPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD) { debugPrint("******* ENABLING FANCY ICONS"); displayFancyIcons = true; - await reloadMarkers(); - if (isVisible) onUpdate(); + // TODO: Add a _markerGeneration++ statement here + // await reloadAllMarkers(); + // if (isVisible) onUpdate(); + reloadAllMarkersStaggered(); } else if (oldPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD) { debugPrint("******* DISABLING FANCY ICONS"); displayFancyIcons = false; - await reloadMarkers(); - if (isVisible) onUpdate(); + reloadAllMarkersStaggered(); + // await reloadAllMarkers(); + // if (isVisible) onUpdate(); } } - Future reloadMarkers() async { - try { - markersCache.clear(); + Future reloadMarkersForRoutes(List routes) async { + // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother + for (final r in routes) { // used to be routesCache + if (!selectedRoutes.contains(r.routeId)) continue; // Skip deselected routes - // FUTURE TODO: Create these icons asynchronously and CACHE THEM so they don't block when we're trying to load them all. Make sure they display all available routes even if only a few are selected (so the cache doesn't become invalid when the user selects different 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); - 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 (!markersCache.containsKey(routeKey)) { - // Prevent duplicate copies of the same stop on top of each other - markersCache[routeKey] = {}; - for (final (idx, stop) in r.stops) { - // iterate through all stops in this route - // TODO: Implement favorite stops - // final isFavorite = _favoriteStops.contains(stop.id); - - - // TODO: ****** See why the duplicate cache isn't working in some cases! - - // List routesServed = ["BB", "CS", "CN", "CSX"]; - Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), - position: stop.location, - flat: true, - // icon: BitmapDescriptor.defaultMarker, - icon: - // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) - // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), - (displayFancyIcons) ? await MapImageService.getFancyStopIcon(stop.id, stop.rotation, routesServed.toList()) : MapImageService.stopIcon, - - // NEXT STEPS TODO: - // * Stagger the marker updates across frames, instead of trying to load them in all at once. Process maybe 50-100 markers at a time before waiting - // * Add support for favorited stops (add the favorite/nonfavorite flag as part of the cache key to make sure our cache won't give us an old icon) - // * - - // TODO: Maybe only generate fancy stop icons if we can confirm the Marker is within view? - - - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - // : (stop.isRide ? _rideStopIcon : _stopIcon), - consumeTapEvents: true, - onTap: () { - onStopClicked(stop); - }, - rotation: displayFancyIcons ? 0.0 : stop.rotation, - anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), - ); - // _routeStopMarkers[routeKey]?[stop.id] = marker; - - markersCache[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; - } + // if (!markersCache.containsKey(routeKey)) { + // // Prevent duplicate copies of the same stop on top of each other + // markersCache[routeKey] = {}; + for (final (idx, stop) in r.stops) { + // iterate through all stops in this route + // TODO: Implement favorite stops + // final isFavorite = _favoriteStops.contains(stop.id); + + if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { + continue; // This is a duplicate marker that has already been updated. Skip it } + _markerBuiltAtGeneration[stop.id] = _markerGeneration; + + + // List routesServed = ["BB", "CS", "CN", "CSX"]; + Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), + position: stop.location, + flat: true, + icon: + // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + (displayFancyIcons) + ? ( + await MapImageService.getFancyStopIcon( + stop.id, + favoriteStops.contains(stop.id), + stop.isRide, + stop.rotation, + routesServed.toList() + ) + ) : ( + favoriteStops.contains(stop.id) // Used to be isFavorite + ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + : (stop.isRide ? _rideStopIcon : _stopIcon) + ), + consumeTapEvents: true, + onTap: () { + onStopClicked(stop); + }, + rotation: displayFancyIcons ? 0.0 : stop.rotation, + anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + ); + + markersCache[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; } + } + + markers = markersCache.values.toSet(); // Update global markers list + + } + + Future reloadAllMarkers() async { + try { + markersCache.clear(); + _markerGeneration++; + + // FUTURE TODO: Create these icons asynchronously and CACHE THEM so they don't block when we're trying to load them all. Make sure they display all available routes even if only a few are selected (so the cache doesn't become invalid when the user selects different routes) + + await reloadMarkersForRoutes(routesCache); // Reload all the markers all at once + + // 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 (!markersCache.containsKey(routeKey)) { + // // Prevent duplicate copies of the same stop on top of each other + // markersCache[routeKey] = {}; + // for (final (idx, stop) in r.stops) { + // // iterate through all stops in this route + // // TODO: Implement favorite stops + // // final isFavorite = _favoriteStops.contains(stop.id); + + + // // TODO: ****** See why the duplicate cache isn't working in some cases! + + // // List routesServed = ["BB", "CS", "CN", "CSX"]; + // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), + // position: stop.location, + // flat: true, + // // icon: BitmapDescriptor.defaultMarker, + // icon: + // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + // // favoriteStops.contains(stop.id) // Used to be isFavorite + // // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) + // // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), + // (displayFancyIcons) ? await MapImageService.getFancyStopIcon(stop.id, stop.rotation, routesServed.toList()) : MapImageService.stopIcon, + + // // NEXT STEPS TODO: + // // * Stagger the marker updates across frames, instead of trying to load them in all at once. Process maybe 50-100 markers at a time before waiting + // // * Add support for favorited stops (add the favorite/nonfavorite flag as part of the cache key to make sure our cache won't give us an old icon by mistake') + // // * See if I can fix the rotation bug? Some stops have strange rotation--see if there is an existing function to "smooth out" the rotation so that it follows the polyline + // // * Make the TheRide stop numbers ovals instead of circles + // // * Also sort the list of stops every time! + // // * And figure out why I'm getting so many ErrorSummary errors + // // We can also think about "snapping"/"binning" the rotation to e.g. 20-degree increments to make the cache a little smaller + + // // TODO: Maybe only generate fancy stop icons if we can confirm the Marker is within view? + + + // // favoriteStops.contains(stop.id) // Used to be isFavorite + // // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + // // : (stop.isRide ? _rideStopIcon : _stopIcon), + // consumeTapEvents: true, + // onTap: () { + // onStopClicked(stop); + // }, + // rotation: displayFancyIcons ? 0.0 : stop.rotation, + // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + // ); + // // _routeStopMarkers[routeKey]?[stop.id] = marker; + + // markersCache[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; + // } + // } + // } // markers = {}; - markers = markersCache.values.expand((Map m) { - return m.values; - }).toSet(); + // markers = markersCache.values.expand((Map m) { + // return m.values; + // }).toSet(); } 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++; + + + for (int i = 0; i < (routesCache.length / 3); i++) { + // debugPrint("Reloading markers (staggered) for route ${routesCache[i].routeId}"); + // await reloadMarkersForRoutes([routesCache[i]]); + await reloadMarkersForRoutes(routesCache.sublist(i * 3, math.min((i + 1) * 3, routesCache.length))); + // if (isVisible) onUpdate(); + if (isVisible) onUpdate(); + await SchedulerBinding.instance.endOfFrame; + await Future.delayed(Duration(milliseconds: 200)); + + } + + if (isVisible) onUpdate(); + + } + void reloadPolylines() { polylinesCache.clear(); From ca86002124f78616c829e9d92debb39db5f07814 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:54:06 -0700 Subject: [PATCH 097/121] Added "ripple on tap" effect --- .claude/settings.json | 7 + lib/screens/map_screen.dart | 8 +- lib/services/map_image_service.dart | 19 +- .../map_layers/base_routes_layer.dart | 345 +++++++++++++++--- lib/services/map_layers/live_buses_layer.dart | 19 +- lib/utils/rebuild_watchdog.dart | 38 ++ lib/widgets/composite_map_widget.dart | 232 +++++++++--- 7 files changed, 546 insertions(+), 122 deletions(-) create mode 100644 .claude/settings.json create mode 100644 lib/utils/rebuild_watchdog.dart 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/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index eec9be5..e6f4dac 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1198,9 +1198,11 @@ class _MaizeBusCoreState extends State { if (!_isProgrammaticCameraMove) { _userHasInteractedWithMap = true; } - setState(() { - _currentCameraPos = position; - }); + + // Note: Please avoid calling setState() inside _onCameraMove since Flutter has to rebuild the map each time and it causes stuttering. Thanks! + // setState(() { + // _currentCameraPos = position; + // }); } void _onCameraIdle() async { diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 70cae50..0524e36 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -348,7 +348,7 @@ class MapImageService { return frame.image; } - static void drawRouteIconOntoCanvas(Canvas canvas, int x, int y, int width, int height, String routeId) { + 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; @@ -366,8 +366,18 @@ class MapImageService { textAlign: TextAlign.center, textDirection: TextDirection.ltr )..layout(minWidth: 0, maxWidth: width.toDouble()); - - canvas.drawCircle(Offset(x + width / 2, y + height / 2), width / 2, paint); + + 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)); } @@ -470,7 +480,8 @@ class MapImageService { yDrawPos, // y ROW_ICON_SIZE.toInt(), // width ROW_ICON_SIZE.toInt(), // height - routeId); + routeId, + isRide); xDrawPos += ROW_ICON_SIZE.toInt() + FANCY_STOP_ICON_SMALLMARGIN; } diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 1a1c035..c3b7f66 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -11,6 +11,15 @@ 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 @@ -21,6 +30,10 @@ class BaseRoutesLayer extends CompositeMapLayer { 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"); }; @@ -47,6 +60,8 @@ class BaseRoutesLayer extends CompositeMapLayer { 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 @@ -117,16 +132,19 @@ class BaseRoutesLayer extends CompositeMapLayer { @override void onCameraMove(CameraPosition oldPosition, CameraPosition newPosition) async { + + + viewportLocation = newPosition.target; // debugPrint("Camera zoomed from ${oldPosition.zoom} to ${newPosition.zoom}"); if (oldPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD) { - debugPrint("******* ENABLING FANCY ICONS"); + // debugPrint("******* ENABLING FANCY ICONS"); displayFancyIcons = true; // TODO: Add a _markerGeneration++ statement here // await reloadAllMarkers(); // if (isVisible) onUpdate(); reloadAllMarkersStaggered(); } else if (oldPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD) { - debugPrint("******* DISABLING FANCY ICONS"); + // debugPrint("******* DISABLING FANCY ICONS"); displayFancyIcons = false; reloadAllMarkersStaggered(); // await reloadAllMarkers(); @@ -134,78 +152,254 @@ class BaseRoutesLayer extends CompositeMapLayer { } } - Future reloadMarkersForRoutes(List routes) async { - // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother + 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); - // if (!markersCache.containsKey(routeKey)) { - // // Prevent duplicate copies of the same stop on top of each other - // markersCache[routeKey] = {}; - for (final (idx, stop) in r.stops) { + for (final (_, stop) in r.stops) { // iterate through all stops in this route - // TODO: Implement favorite stops - // final isFavorite = _favoriteStops.contains(stop.id); 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)); + } + } - // List routesServed = ["BB", "CS", "CN", "CSX"]; - Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), - position: stop.location, - flat: true, - icon: - // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - (displayFancyIcons) - ? ( - await MapImageService.getFancyStopIcon( - stop.id, - favoriteStops.contains(stop.id), - stop.isRide, - stop.rotation, - routesServed.toList() - ) - ) : ( - favoriteStops.contains(stop.id) // Used to be isFavorite - ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - : (stop.isRide ? _rideStopIcon : _stopIcon) - ), - consumeTapEvents: true, - onTap: () { - onStopClicked(stop); - }, - rotation: displayFancyIcons ? 0.0 : stop.rotation, - anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), - ); + stopsToReload.sort((a, b) => getSquaredDistanceBetween(a.stop.location, viewportLocation).compareTo(getSquaredDistanceBetween(b.stop.location, viewportLocation))); // Sort by distance to the viewport - markersCache[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; - } - } + // Future reloadMarkersForRoutes(List routes) async { + // // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother + // for (final r in routes) { // used to be 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 (!markersCache.containsKey(routeKey)) { + // // // Prevent duplicate copies of the same stop on top of each other + // // markersCache[routeKey] = {}; + // for (final (_, stop) in r.stops) { + // // iterate through all stops in this route + // // TODO: Implement favorite stops + // // final isFavorite = _favoriteStops.contains(stop.id); + + // if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { + // continue; // This is a duplicate marker that has already been updated. Skip it + // } + // _markerBuiltAtGeneration[stop.id] = _markerGeneration; + + + // // List routesServed = ["BB", "CS", "CN", "CSX"]; + // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), + // position: stop.location, + // flat: true, + // icon: + // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + // (displayFancyIcons) + // ? ( + // await MapImageService.getFancyStopIcon( + // stop.id, + // favoriteStops.contains(stop.id), + // stop.isRide, + // stop.rotation, + // routesServed.toList() + // ) + // ) : ( + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + // : (stop.isRide ? _rideStopIcon : _stopIcon) + // ), + // consumeTapEvents: true, + // onTap: () { + // onStopClicked(stop); + // }, + // rotation: displayFancyIcons ? 0.0 : stop.rotation, + // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + // ); + + // markersCache[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; + // } + // } + + // markers = markersCache.values.toSet(); // Update global markers list + + // } + + Future reloadPreprocessedMarkersSegment(int markersToReload) async { + + int stopIndex = stopsToReloadCursor + markersToReload; + debugPrint("Reloading markers #${stopsToReloadCursor} to #${stopsToReloadCursor + markersToReload} out of ${stopsToReload.length}"); + + // if (!markersCache.containsKey(routeKey)) { + // // Prevent duplicate copies of the same stop on top of each other + // markersCache[routeKey] = {}; + 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() + ) + ) : ( + 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: displayFancyIcons ? 0.0 : entry.stop.rotation, + anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + ); - markers = markersCache.values.toSet(); // Update global markers list + markersCache[entry.stop.id] = marker; + } + markers = markersCache.values.toSet(); // Update global markers list } + + + // } + + + // Future reloadMarkersForRoutes(List routes) async { + // // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother + // for (final r in routes) { // used to be 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 (!markersCache.containsKey(routeKey)) { + // // // Prevent duplicate copies of the same stop on top of each other + // // markersCache[routeKey] = {}; + // for (final (_, stop) in r.stops) { + // // iterate through all stops in this route + // // TODO: Implement favorite stops + // // final isFavorite = _favoriteStops.contains(stop.id); + + // if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { + // continue; // This is a duplicate marker that has already been updated. Skip it + // } + // _markerBuiltAtGeneration[stop.id] = _markerGeneration; + + + // // List routesServed = ["BB", "CS", "CN", "CSX"]; + // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), + // position: stop.location, + // flat: true, + // icon: + // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic + // (displayFancyIcons) + // ? ( + // await MapImageService.getFancyStopIcon( + // stop.id, + // favoriteStops.contains(stop.id), + // stop.isRide, + // stop.rotation, + // routesServed.toList() + // ) + // ) : ( + // favoriteStops.contains(stop.id) // Used to be isFavorite + // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) + // : (stop.isRide ? _rideStopIcon : _stopIcon) + // ), + // consumeTapEvents: true, + // onTap: () { + // onStopClicked(stop); + // }, + // rotation: displayFancyIcons ? 0.0 : stop.rotation, + // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), + // ); + + // markersCache[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; + // } + // } + + // markers = markersCache.values.toSet(); // Update global markers list + + // } + Future reloadAllMarkers() async { try { markersCache.clear(); @@ -213,7 +407,20 @@ class BaseRoutesLayer extends CompositeMapLayer { // FUTURE TODO: Create these icons asynchronously and CACHE THEM so they don't block when we're trying to load them all. Make sure they display all available routes even if only a few are selected (so the cache doesn't become invalid when the user selects different routes) - await reloadMarkersForRoutes(routesCache); // Reload all the markers all at once + + preprocessStopsToReload(routesCache); + await reloadPreprocessedMarkersSegment(stopsToReload.length); // Reload ALL the markers at once. This also updates the markers variable + + // markers.clear(); + + // No need to do this anymore since reloadProcessedMarkersSegment already updates the markers set + // markers = markersCache.values.expand((Map m) { + // return m.values; + // }).toSet(); + + + // await reloadMarkersForRoutes(routesCache); // Reload all the markers all at once + // TODO: Call reloadAllMarkersStaggered here? // for (final r in routesCache) { // if (!selectedRoutes.contains(r.routeId)) @@ -298,18 +505,32 @@ class BaseRoutesLayer extends CompositeMapLayer { 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++; - - for (int i = 0; i < (routesCache.length / 3); i++) { - // debugPrint("Reloading markers (staggered) for route ${routesCache[i].routeId}"); - // await reloadMarkersForRoutes([routesCache[i]]); - await reloadMarkersForRoutes(routesCache.sublist(i * 3, math.min((i + 1) * 3, routesCache.length))); - // if (isVisible) onUpdate(); + 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)); - } + + + // for (int i = 0; i < (routesCache.length / 3); i++) { + // // debugPrint("Reloading markers (staggered) for route ${routesCache[i].routeId}"); + // // await reloadMarkersForRoutes([routesCache[i]]); + // await reloadMarkersForRoutes(routesCache.sublist(i * 3, math.min((i + 1) * 3, routesCache.length))); + // // if (isVisible) onUpdate(); + // if (isVisible) onUpdate(); + // await SchedulerBinding.instance.endOfFrame; + // await Future.delayed(Duration(milliseconds: 200)); + + // } if (isVisible) onUpdate(); @@ -343,10 +564,16 @@ class BaseRoutesLayer extends CompositeMapLayer { 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 diff --git a/lib/services/map_layers/live_buses_layer.dart b/lib/services/map_layers/live_buses_layer.dart index 76b82fa..bf4617e 100644 --- a/lib/services/map_layers/live_buses_layer.dart +++ b/lib/services/map_layers/live_buses_layer.dart @@ -45,6 +45,11 @@ class LiveBusesLayer extends CompositeMapLayer { 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 = {}; @@ -52,7 +57,7 @@ class LiveBusesLayer extends CompositeMapLayer { late Animation animation; int nextAnimationFrameTime = 0; int animationStartedTime = 0; - static const int FRAME_DURATION = 100; // Frame duration in ms for animations + static const int FRAME_DURATION = 200; // Frame duration in ms for animations static const int ANIMATION_DURATION = 11000; //4000; // Animation duration in ms @@ -73,6 +78,11 @@ class LiveBusesLayer extends CompositeMapLayer { onUpdate = callback; } + @override + void setShowRipple(Function(LatLng) callback) { + showRipple = callback; + } + void initWithTickerProvider(TickerProvider tickerProviderIn) { tickerProvider = tickerProviderIn; controller = AnimationController( @@ -103,7 +113,11 @@ class LiveBusesLayer extends CompositeMapLayer { icon: icon, rotation: bus.heading, anchor: const Offset(0.5, 0.5), - onTap: () => onBusClicked(bus), + onTap: () { + debugPrint("****** ON TAP"); + showRipple(bus.position); + onBusClicked(bus); + }, ); } @@ -185,6 +199,7 @@ class LiveBusesLayer extends CompositeMapLayer { 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) {} 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/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 70091b4..3db7be4 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -1,6 +1,7 @@ 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'; @@ -13,6 +14,9 @@ abstract class CompositeMapLayer { 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 @@ -42,16 +46,110 @@ class CompositeMapWidget extends StatefulWidget { } } +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 = {}; 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 = "{}"; @@ -71,6 +169,7 @@ class CompositeMapWidgetState extends State _loadMapStyles(); widget.mapLayers.forEach((CompositeMapLayer layer) { layer.setOnUpdate(reloadMap); + layer.setShowRipple(showRipple); if (layer is LiveBusesLayer) { layer.initWithTickerProvider(this); } @@ -79,6 +178,7 @@ class CompositeMapWidgetState extends State @override Widget build(BuildContext context) { + _rebuildWatchdog.tick(); allMarkers = widget.mapLayers.expand((CompositeMapLayer layer) { if (!layer.isVisible) return {}; return layer.markers; @@ -88,63 +188,87 @@ class CompositeMapWidgetState extends State return layer.polylines; }).toSet(); - return RepaintBoundary( - child: GoogleMap( - compassEnabled: false, - myLocationEnabled: true, - mapToolbarEnabled: false, - zoomControlsEnabled: false, - myLocationButtonEnabled: false, - markers: allMarkers, - polylines: allPolylines, - cameraTargetBounds: CameraTargetBounds( - LatLngBounds( - southwest: LatLng( - 42.217530, - -83.84367266, - ), // Southern and Westernmost point - northeast: LatLng( - 42.328602, - -83.53892646, - ), // Northern and Easternmost point + return Stack( + children: [ + RepaintBoundary( + child: GoogleMap( + compassEnabled: false, + myLocationEnabled: true, + mapToolbarEnabled: false, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + markers: allMarkers, + polylines: allPolylines, + 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: 15.0, + ), + 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, ), ), - minMaxZoomPreference: const MinMaxZoomPreference(10, 21), - // markers: curMarkers.union(widget.staticMarkers), - initialCameraPosition: CameraPosition( - target: widget.initialCenter, - zoom: 15.0, - ), - 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 From 301c8a118a24d304576a8cd07a439ba1b7f9d4da Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:19:21 -0700 Subject: [PATCH 098/121] Removed extra debugPrint()s and comments --- lib/services/map_image_service.dart | 2 +- .../map_layers/base_routes_layer.dart | 273 +----------------- lib/services/map_layers/live_buses_layer.dart | 2 - lib/services/map_layers/navigation_layer.dart | 1 - .../navigation/navigation_manager.dart | 2 - lib/widgets/navigation_overlay_widget.dart | 3 - 6 files changed, 3 insertions(+), 280 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 0524e36..9bda233 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -433,7 +433,7 @@ class MapImageService { try { if (!_stopIconsInitialized) { - debugPrint("Stop icons not initialized, loading..."); + // debugPrint("Stop icons not initialized, loading..."); await _loadStopIcons(); } diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index c3b7f66..827b8fe 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -86,7 +86,7 @@ class BaseRoutesLayer extends CompositeMapLayer { } } - debugPrint("Pre-generating fancy stop icons"); + // debugPrint("Pre-generating fancy stop icons"); for (MapEntry entry in stopIdToRouteIds.entries) { try { await MapImageService.getFancyStopIcon( @@ -101,10 +101,6 @@ class BaseRoutesLayer extends CompositeMapLayer { // 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) - // stopIdToRouteId.entries.forEach((e) => { - // debugPrint("Bus stop ${e.key} has service from ${e.value.join(", ")}") - // },); - await reloadAllMarkers(); reloadPolylines(); @@ -135,20 +131,13 @@ class BaseRoutesLayer extends CompositeMapLayer { viewportLocation = newPosition.target; - // debugPrint("Camera zoomed from ${oldPosition.zoom} to ${newPosition.zoom}"); + if (oldPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD) { - // debugPrint("******* ENABLING FANCY ICONS"); displayFancyIcons = true; - // TODO: Add a _markerGeneration++ statement here - // await reloadAllMarkers(); - // if (isVisible) onUpdate(); reloadAllMarkersStaggered(); } else if (oldPosition.zoom >= FANCY_ICONS_ZOOM_THRESHOLD && newPosition.zoom < FANCY_ICONS_ZOOM_THRESHOLD) { - // debugPrint("******* DISABLING FANCY ICONS"); displayFancyIcons = false; reloadAllMarkersStaggered(); - // await reloadAllMarkers(); - // if (isVisible) onUpdate(); } } @@ -197,86 +186,10 @@ class BaseRoutesLayer extends CompositeMapLayer { } - // Future reloadMarkersForRoutes(List routes) async { - // // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother - // for (final r in routes) { // used to be 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 (!markersCache.containsKey(routeKey)) { - // // // Prevent duplicate copies of the same stop on top of each other - // // markersCache[routeKey] = {}; - // for (final (_, stop) in r.stops) { - // // iterate through all stops in this route - // // TODO: Implement favorite stops - // // final isFavorite = _favoriteStops.contains(stop.id); - - // if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { - // continue; // This is a duplicate marker that has already been updated. Skip it - // } - // _markerBuiltAtGeneration[stop.id] = _markerGeneration; - - - // // List routesServed = ["BB", "CS", "CN", "CSX"]; - // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), - // position: stop.location, - // flat: true, - // icon: - // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - // (displayFancyIcons) - // ? ( - // await MapImageService.getFancyStopIcon( - // stop.id, - // favoriteStops.contains(stop.id), - // stop.isRide, - // stop.rotation, - // routesServed.toList() - // ) - // ) : ( - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - // : (stop.isRide ? _rideStopIcon : _stopIcon) - // ), - // consumeTapEvents: true, - // onTap: () { - // onStopClicked(stop); - // }, - // rotation: displayFancyIcons ? 0.0 : stop.rotation, - // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), - // ); - - // markersCache[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; - // } - // } - - // markers = markersCache.values.toSet(); // Update global markers list - - // } - Future reloadPreprocessedMarkersSegment(int markersToReload) async { int stopIndex = stopsToReloadCursor + markersToReload; - debugPrint("Reloading markers #${stopsToReloadCursor} to #${stopsToReloadCursor + markersToReload} out of ${stopsToReload.length}"); - // if (!markersCache.containsKey(routeKey)) { - // // Prevent duplicate copies of the same stop on top of each other - // markersCache[routeKey] = {}; for (; stopsToReloadCursor < math.min(stopsToReload.length, stopIndex); stopsToReloadCursor++) { @@ -324,179 +237,14 @@ class BaseRoutesLayer extends CompositeMapLayer { } - - // } - - - // Future reloadMarkersForRoutes(List routes) async { - // // Used to only reload markers for a given list of routes. Useful because the fancy icons take some time for Google Maps to process (replacing 100+ markers with custom icons all at once causes a lot of stuttering), so we only process 1-2 routes per frame to keep things smoother - // for (final r in routes) { // used to be 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 (!markersCache.containsKey(routeKey)) { - // // // Prevent duplicate copies of the same stop on top of each other - // // markersCache[routeKey] = {}; - // for (final (_, stop) in r.stops) { - // // iterate through all stops in this route - // // TODO: Implement favorite stops - // // final isFavorite = _favoriteStops.contains(stop.id); - - // if (_markerBuiltAtGeneration[stop.id] == _markerGeneration) { - // continue; // This is a duplicate marker that has already been updated. Skip it - // } - // _markerBuiltAtGeneration[stop.id] = _markerGeneration; - - - // // List routesServed = ["BB", "CS", "CN", "CSX"]; - // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), - // position: stop.location, - // flat: true, - // icon: - // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - // (displayFancyIcons) - // ? ( - // await MapImageService.getFancyStopIcon( - // stop.id, - // favoriteStops.contains(stop.id), - // stop.isRide, - // stop.rotation, - // routesServed.toList() - // ) - // ) : ( - // favoriteStops.contains(stop.id) // Used to be isFavorite - // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - // : (stop.isRide ? _rideStopIcon : _stopIcon) - // ), - // consumeTapEvents: true, - // onTap: () { - // onStopClicked(stop); - // }, - // rotation: displayFancyIcons ? 0.0 : stop.rotation, - // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), - // ); - - // markersCache[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; - // } - // } - - // markers = markersCache.values.toSet(); // Update global markers list - - // } - Future reloadAllMarkers() async { try { markersCache.clear(); _markerGeneration++; - // FUTURE TODO: Create these icons asynchronously and CACHE THEM so they don't block when we're trying to load them all. Make sure they display all available routes even if only a few are selected (so the cache doesn't become invalid when the user selects different routes) - - preprocessStopsToReload(routesCache); await reloadPreprocessedMarkersSegment(stopsToReload.length); // Reload ALL the markers at once. This also updates the markers variable - // markers.clear(); - - // No need to do this anymore since reloadProcessedMarkersSegment already updates the markers set - // markers = markersCache.values.expand((Map m) { - // return m.values; - // }).toSet(); - - - // await reloadMarkersForRoutes(routesCache); // Reload all the markers all at once - // TODO: Call reloadAllMarkersStaggered here? - - // 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 (!markersCache.containsKey(routeKey)) { - // // Prevent duplicate copies of the same stop on top of each other - // markersCache[routeKey] = {}; - // for (final (idx, stop) in r.stops) { - // // iterate through all stops in this route - // // TODO: Implement favorite stops - // // final isFavorite = _favoriteStops.contains(stop.id); - - - // // TODO: ****** See why the duplicate cache isn't working in some cases! - - // // List routesServed = ["BB", "CS", "CN", "CSX"]; - // Set routesServed = stopIdToRouteIds[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_${stop.id}_${Object.hashAll(r.points)}'), - // position: stop.location, - // flat: true, - // // icon: BitmapDescriptor.defaultMarker, - // icon: - // // TODO: Reimplement this isRide/isNotRide/isFavorite/etc logic - // // favoriteStops.contains(stop.id) // Used to be isFavorite - // // ? (stop.isRide ? MapImageService.favRideStopIcon : MapImageService.favStopIcon) - // // : (stop.isRide ? MapImageService.rideStopIcon : MapImageService. stopIcon), - // (displayFancyIcons) ? await MapImageService.getFancyStopIcon(stop.id, stop.rotation, routesServed.toList()) : MapImageService.stopIcon, - - // // NEXT STEPS TODO: - // // * Stagger the marker updates across frames, instead of trying to load them in all at once. Process maybe 50-100 markers at a time before waiting - // // * Add support for favorited stops (add the favorite/nonfavorite flag as part of the cache key to make sure our cache won't give us an old icon by mistake') - // // * See if I can fix the rotation bug? Some stops have strange rotation--see if there is an existing function to "smooth out" the rotation so that it follows the polyline - // // * Make the TheRide stop numbers ovals instead of circles - // // * Also sort the list of stops every time! - // // * And figure out why I'm getting so many ErrorSummary errors - // // We can also think about "snapping"/"binning" the rotation to e.g. 20-degree increments to make the cache a little smaller - - // // TODO: Maybe only generate fancy stop icons if we can confirm the Marker is within view? - - - // // favoriteStops.contains(stop.id) // Used to be isFavorite - // // ? (stop.isRide ? _favRideStopIcon : _favStopIcon) - // // : (stop.isRide ? _rideStopIcon : _stopIcon), - // consumeTapEvents: true, - // onTap: () { - // onStopClicked(stop); - // }, - // rotation: displayFancyIcons ? 0.0 : stop.rotation, - // anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), - // ); - // // _routeStopMarkers[routeKey]?[stop.id] = marker; - - // markersCache[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; - // } - // } - // } - - // markers = {}; - // markers = markersCache.values.expand((Map m) { - // return m.values; - // }).toSet(); } catch (err) { debugPrint("Error: $err"); } @@ -520,18 +268,6 @@ class BaseRoutesLayer extends CompositeMapLayer { await Future.delayed(Duration(milliseconds: 200)); } - - // for (int i = 0; i < (routesCache.length / 3); i++) { - // // debugPrint("Reloading markers (staggered) for route ${routesCache[i].routeId}"); - // // await reloadMarkersForRoutes([routesCache[i]]); - // await reloadMarkersForRoutes(routesCache.sublist(i * 3, math.min((i + 1) * 3, routesCache.length))); - // // if (isVisible) onUpdate(); - // if (isVisible) onUpdate(); - // await SchedulerBinding.instance.endOfFrame; - // await Future.delayed(Duration(milliseconds: 200)); - - // } - if (isVisible) onUpdate(); } @@ -590,11 +326,6 @@ class BaseRoutesLayer extends CompositeMapLayer { await rootBundle.load('assets/favbusStopRide.png'), ); - // Refresh markers with new icons - // TODO: See if we need this! - // if (mounted) { - // _refreshAllMarkers(); - // } } catch (e) { // Fallback to default markers if custom loading fails // These are now set as initial values diff --git a/lib/services/map_layers/live_buses_layer.dart b/lib/services/map_layers/live_buses_layer.dart index bf4617e..0d31443 100644 --- a/lib/services/map_layers/live_buses_layer.dart +++ b/lib/services/map_layers/live_buses_layer.dart @@ -114,7 +114,6 @@ class LiveBusesLayer extends CompositeMapLayer { rotation: bus.heading, anchor: const Offset(0.5, 0.5), onTap: () { - debugPrint("****** ON TAP"); showRipple(bus.position); onBusClicked(bus); }, @@ -298,7 +297,6 @@ class LiveBusesLayer extends CompositeMapLayer { controller?.forward(); controller?.repeat(); - debugPrint("***** Finished starting animation"); } void reload() { diff --git a/lib/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart index 1e68938..ae30af7 100644 --- a/lib/services/map_layers/navigation_layer.dart +++ b/lib/services/map_layers/navigation_layer.dart @@ -28,7 +28,6 @@ class NavigationLayer extends CompositeMapLayer { void reload() { // reloadMarkers(); // reloadPolylines(); - debugPrint("**** RELOADING NAVIGATIONLAYER, we have ${markers.length} markers and ${polylines} polylines"); if (isVisible) onUpdate(); } diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 312887d..1de93ed 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -976,8 +976,6 @@ class NavigationManager { this.stageList.clear(); for (Leg leg in journey.legs) { - // if (leg.") - debugPrint("Adding ${leg.origin}->${leg.destination} leg"); // TODO: Call initWithLeg(leg) constructor here if it's a Bus leg if (leg.mode == LegMode.walk) { diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 666eff1..f249543 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -33,14 +33,11 @@ class _NavigationOverlayState extends State TimelineInfo timelineInfo = TimelineInfo(); void updateTimeline() { // Call this after all the stages are loaded (or stages change) - // debugPrint("***** Updating timeline!"); timelineInfo = widget.navigationManager.getTimeline(); - // debugPrint("***** Timeline now has ${timelineSteps.length} things!"); } @override void initState() { - // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); super.initState(); updateTimeline(); widget.navigationManager.registerOverlay(this); // does not set to null, see the navigation manager From 87b42dd93f58fec63caf7f8aed94a3d374a622d6 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:30:18 -0700 Subject: [PATCH 099/121] Added Github Actions compile test --- .github/workflows/ci.yaml | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/ci.yaml 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 From 57adf96cebcf8a4b86d7cadbcebe1404b37fc933 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:59:14 -0700 Subject: [PATCH 100/121] Wired up navigation and floor plan flow --- lib/screens/map_screen.dart | 92 ++++++++++++++--- lib/services/map_layers/live_buses_layer.dart | 2 +- .../navigation/navigation_manager.dart | 44 +++++++-- lib/widgets/directions_sheet.dart | 5 +- lib/widgets/floorplan_overlay_widget.dart | 7 +- lib/widgets/journey_results_widget.dart | 98 +++++++++---------- lib/widgets/navigation_overlay_widget.dart | 80 ++++++++------- 7 files changed, 209 insertions(+), 119 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index e6f4dac..703204c 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -124,6 +124,8 @@ class _MaizeBusCoreState extends State { {}; // 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; // route ids that are part of the active journey @@ -178,6 +180,7 @@ class _MaizeBusCoreState extends State { 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 @@ -598,6 +601,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; @@ -1126,14 +1140,39 @@ 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(); - }); + // _bottomSheetController?.closed.then((_) { + // hideJourney(); + // }); } _showJourneySheetOnReopen() { @@ -1165,7 +1204,9 @@ class _MaizeBusCoreState extends State { style: TextStyle(fontSize: 30, fontWeight: FontWeight.w700), ), SizedBox(height: 15), - JourneyBody(journey: currDisplayed), + JourneyBody( + journey: currDisplayed, + ), ], ), ); @@ -1507,6 +1548,12 @@ class _MaizeBusCoreState extends State { onPopInvokedWithResult: (didPop, result) { 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) { @@ -1514,6 +1561,8 @@ class _MaizeBusCoreState extends State { _bottomSheetController = null; _removeSearchLocationMarker(); } + + setState(() {}); // Make sure the widgets reload }, child: Stack( children: [ @@ -1521,9 +1570,9 @@ class _MaizeBusCoreState extends State { child: CompositeMapWidget( initialCenter: startLatLng, mapLayers: [ - // baseRoutesLayer, - // liveBusesLayer, - // journeyLayer, + baseRoutesLayer, + liveBusesLayer, + journeyLayer, navigationLayer ], onMapCreated: _onMapCreated, @@ -2212,21 +2261,36 @@ class _MaizeBusCoreState extends State { ), ), ), + + FilledButton( + onPressed: () { + setState(() { + _floorplanOverlayEnabled = true; + }); + }, + child: Text("Floorplan") + ) ], ), ], ), ), - // Positioned.fill( - // child: RepaintBoundary( - // child: FloorplanOverlay() - // ) - // ), - Positioned.fill( + _floorplanOverlayEnabled ? Positioned.fill( + child: RepaintBoundary( + child: FloorplanOverlay( + onClosed: () { + setState(() { + _floorplanOverlayEnabled = false; + }); + } + ) + ) + ) : SizedBox.shrink(), + _navigationOverlayEnabled ? Positioned.fill( child: RepaintBoundary( child: NavigationOverlay(navigationManager: navigationManager) ) - ), + ) : SizedBox.shrink(), ], ), ) diff --git a/lib/services/map_layers/live_buses_layer.dart b/lib/services/map_layers/live_buses_layer.dart index 0d31443..8733b4b 100644 --- a/lib/services/map_layers/live_buses_layer.dart +++ b/lib/services/map_layers/live_buses_layer.dart @@ -57,7 +57,7 @@ class LiveBusesLayer extends CompositeMapLayer { late Animation animation; int nextAnimationFrameTime = 0; int animationStartedTime = 0; - static const int FRAME_DURATION = 200; // Frame duration in ms for animations + static const int FRAME_DURATION = 100; // Frame duration in ms for animations static const int ANIMATION_DURATION = 11000; //4000; // Animation duration in ms diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 1de93ed..abf73cc 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -535,6 +535,8 @@ class Walking extends NavigationStage { 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) @@ -561,6 +563,10 @@ class Walking extends NavigationStage { 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(LatLng newLocation) { @@ -575,8 +581,7 @@ class Walking extends NavigationStage { // if it has reached new waypoint, update index, length left, and percent complete _nextIndex++; - // TODO: - // update length and percent_complete here + // TODO: update length and percent_complete here return true; } @@ -644,21 +649,45 @@ class Walking extends NavigationStage { // Initializes the Walking stage given a Leg. @override - void initWithLeg(Leg leg) { - final path = leg.pathCoords; + void initWithLeg(Leg leg_in) { + final path = leg_in.pathCoords; + leg = leg_in; if (path == null || path.isEmpty) { throw ArgumentError( - 'Walking leg from ${leg.origin} to ${leg.destination} has no path.', + 'Walking leg from ${leg_in.origin} to ${leg_in.destination} has no path.', ); } points = List.unmodifiable(path); _nextIndex = 0; - length = leg.duration; + 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 + ], + ) + ]; + + } + + } @@ -971,7 +1000,7 @@ class NavigationManager { _activateStageSub(stageList[currentStage]); } - void initFromJourney(Journey journey) { + void initFromJourney(Journey journey, Color walkingLineColor) { this.stageList.clear(); @@ -980,6 +1009,7 @@ class NavigationManager { 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) { diff --git a/lib/widgets/directions_sheet.dart b/lib/widgets/directions_sheet.dart index 81559c2..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((_) { diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index 9f8b829..b3e173a 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -141,6 +141,11 @@ class _FloorSelectorState extends State { class FloorplanOverlay extends StatefulWidget { // const FloorplanOverlauy + Function? onClosed; + + FloorplanOverlay({ + required this.onClosed + }); @override State createState() => _FloorplanOverlayState(); @@ -188,7 +193,7 @@ class _FloorplanOverlayState extends State { 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 ), diff --git a/lib/widgets/journey_results_widget.dart b/lib/widgets/journey_results_widget.dart index 917698e..0618af0 100644 --- a/lib/widgets/journey_results_widget.dart +++ b/lib/widgets/journey_results_widget.dart @@ -61,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; @@ -98,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, @@ -109,6 +81,7 @@ class JourneyResultsWidget extends StatefulWidget { required this.dest, required this.onChangeSelection, this.onSelectJourney, + this.onStartNavigation, required this.scrollController }); @@ -238,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, + ), ), ], ), @@ -423,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,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) { @@ -617,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/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index f249543..8e96713 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -178,46 +178,40 @@ class _NavigationOverlayState extends State 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; - }); - } - } - ), - - MaterialButton( - color: Colors.red.shade900, - child: Text("Show Oops dialog"), - onPressed: () => widget.navigationManager.showOopsDialog(), - ), + // 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 @@ -391,7 +385,11 @@ class _NavigationOverlayState extends State ], ), - + MaterialButton( + color: Colors.red.shade900, + child: Text("Show Oops dialog"), + onPressed: () => widget.navigationManager.showOopsDialog(), + ), // Expanded(child: SizedBox.expand()), // SizedBox.expand(), // const Spacer(), From 8b592d302d49ba6c1a2ece40acb8f5efd12316f1 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Fri, 21 Aug 2026 18:11:05 -0400 Subject: [PATCH 101/121] navigation bar closer to figma --- lib/constants.dart | 4 +- lib/globals.dart | 4 + lib/screens/map_screen.dart | 1 + lib/widgets/floating_draggable_sheet.dart | 157 +++++++++++++++++++++ lib/widgets/navigation_overlay_widget.dart | 93 +++++++----- 5 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 lib/widgets/floating_draggable_sheet.dart diff --git a/lib/constants.dart b/lib/constants.dart index 79dbd80..b3d3eb5 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -157,7 +157,7 @@ const Map lightColors = { ColorType.secondaryButtonText: maizeBusBlue, ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97), - ColorType.navigationStepsGray: Color.fromARGB(255, 217, 217, 217) + ColorType.navigationStepsGray: Color.fromARGB(255, 219, 228, 237) }; const Map darkColors = { @@ -198,7 +198,7 @@ const Map darkColors = { ColorType.secondaryButtonText: Color.fromARGB(255, 49, 129, 199), ColorType.mapWalkingLine: Color.fromARGB(255, 178, 219, 255), - ColorType.navigationStepsGray: Color.fromARGB(255, 93, 93, 93) + ColorType.navigationStepsGray: Color.fromARGB(255, 219, 228, 237) }; // returns true if the current theme is dark mode diff --git a/lib/globals.dart b/lib/globals.dart index a13019e..faf6c4f 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -13,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/screens/map_screen.dart b/lib/screens/map_screen.dart index 703204c..1b716e2 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -290,6 +290,7 @@ class _MaizeBusCoreState extends State { 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(); 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/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart index 8e96713..d2d6950 100644 --- a/lib/widgets/navigation_overlay_widget.dart +++ b/lib/widgets/navigation_overlay_widget.dart @@ -2,10 +2,12 @@ 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'; @@ -419,19 +421,20 @@ class _NavigationOverlayState extends State // TODO: Add a scrim that fades in when you drag up on the progress bar so that the background is darkened behind the DraggableScrollableSheet - DraggableScrollableSheet( - initialChildSize: 0.12, // TODO: Compute the height of the progress bar dynamically instead of using 12% of screen height as a hardcoded number - minChildSize: 0.12, - maxChildSize: 0.85, - snap: true, + 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 Container( - decoration: BoxDecoration( - color: getColor(context, ColorType.infoCardColor), - borderRadius: const BorderRadius.vertical(top: Radius.circular(25)), - boxShadow: [ /* TODO: Add a nice box shadow */ ] - ), - child: ListView( + return ListView( controller: scrollController, padding: EdgeInsets.all(15), children: [ @@ -465,8 +468,8 @@ class _NavigationOverlayState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( - width: 50, - height: 4, + width: 60, + height: 5, child: DecoratedBox( decoration: BoxDecoration( color: Colors.grey.shade400, // TODO: Make this a real color in constants.dart @@ -486,25 +489,32 @@ class _NavigationOverlayState extends State LayoutBuilder( builder: (context, constraints) { - const double dotSize = 24.0; - final double dotLeft = (constraints.maxWidth * this.timelineInfo.activePositionPercentage) - (dotSize / 2); + 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, - alignment: Alignment.center, children: [ Padding( - padding: EdgeInsets.only(top: dotSize, bottom: dotSize), + padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Row( + 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: 10, + height: barHeight, decoration: BoxDecoration(color: item.color), ) ); @@ -529,6 +539,17 @@ class _NavigationOverlayState extends State // 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), + ), + ), + ], ), ), ), @@ -545,21 +566,16 @@ class _NavigationOverlayState extends State Positioned( // TODO: Make this thing animate smoooooothly! left: dotLeft, - // top: -dotSize / 4, - // top: -dotSize, + top: dotTop, child: Container( width: dotSize, height: dotSize, decoration: BoxDecoration( - color: Color(0xFF4286F5), + color: Colors.white, border: Border.all( - color: Colors.white, - // color: Color(0x666896DD), - width: 2.0 + color: maizeBusBlue, + width: 5 ), - boxShadow: [ - BoxShadow(color: Color(0x666896DD), spreadRadius: 16) - ], shape: BoxShape.circle ), ), @@ -570,12 +586,13 @@ class _NavigationOverlayState extends State ), Padding( padding: EdgeInsets.only(left: 10, right: 10, bottom: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("Arrive in 10 mins"), - Text("ETA 9:35PM") - ], + child: Text( + "eta 3:21 ● 21 min", + style: TextStyle( + color: maizeBusBlue, + fontWeight: FontWeight.w700, + fontSize: 18 + ), ) ) ] @@ -585,7 +602,7 @@ class _NavigationOverlayState extends State SizedBox.square(dimension: 20.0,), Column( - + crossAxisAlignment: CrossAxisAlignment.start, children: widget.navigationManager.stageList.asMap().entries.map((entry) { int index = entry.key; @@ -594,11 +611,12 @@ class _NavigationOverlayState extends State bool shouldRoundTopCorners = (index == 0) || stage.hasRoundedCorners(); return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Padding(padding: EdgeInsets.only(left: 20)), + Padding(padding: EdgeInsets.only(left: 10)), Container( // Gray background behind colorful line segment width: 30, height: 50, @@ -759,7 +777,6 @@ class _NavigationOverlayState extends State // ) ] - ) ); } ), From cf9163424737e23ee33036c451a57618ec5a5855 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Sat, 22 Aug 2026 17:02:39 -0400 Subject: [PATCH 102/121] added demo floor plans feature duderstadt --- .gitignore | 3 + assets/floorplans/icons/bathroomF.png | Bin 0 -> 1787 bytes assets/floorplans/icons/bathroomM.png | Bin 0 -> 1591 bytes assets/floorplans/icons/bathroomN.png | Bin 0 -> 2140 bytes assets/floorplans/icons/elevator.png | Bin 0 -> 1870 bytes assets/floorplans/icons/escalator.png | Bin 0 -> 1971 bytes assets/floorplans/icons/food.png | Bin 0 -> 1795 bytes assets/floorplans/icons/info.png | Bin 0 -> 1535 bytes assets/floorplans/icons/stairs.png | Bin 0 -> 1302 bytes lib/models/floorplan.dart | 244 ++++++++++++ lib/screens/map_screen.dart | 4 + lib/services/floorplan_marker_service.dart | 117 ++++++ lib/services/floorplan_service.dart | 71 ++++ lib/services/floorplan_style.dart | 136 +++++++ lib/services/map_layers/floorplans_layer.dart | 350 ++++++++++++++++++ lib/utils/floorplan_projection.dart | 123 ++++++ lib/widgets/composite_map_widget.dart | 16 +- pubspec.yaml | 2 + test/floorplan_test.dart | 173 +++++++++ 19 files changed, 1238 insertions(+), 1 deletion(-) create mode 100644 assets/floorplans/icons/bathroomF.png create mode 100644 assets/floorplans/icons/bathroomM.png create mode 100644 assets/floorplans/icons/bathroomN.png create mode 100644 assets/floorplans/icons/elevator.png create mode 100644 assets/floorplans/icons/escalator.png create mode 100644 assets/floorplans/icons/food.png create mode 100644 assets/floorplans/icons/info.png create mode 100644 assets/floorplans/icons/stairs.png create mode 100644 lib/models/floorplan.dart create mode 100644 lib/services/floorplan_marker_service.dart create mode 100644 lib/services/floorplan_service.dart create mode 100644 lib/services/floorplan_style.dart create mode 100644 lib/services/map_layers/floorplans_layer.dart create mode 100644 lib/utils/floorplan_projection.dart create mode 100644 test/floorplan_test.dart diff --git a/.gitignore b/.gitignore index 2c4ebdb..db2cc6d 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ app.*.symbols !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock local.properties + +# floorplan jsons +assets/floorplans/*.json \ No newline at end of file diff --git a/assets/floorplans/icons/bathroomF.png b/assets/floorplans/icons/bathroomF.png new file mode 100644 index 0000000000000000000000000000000000000000..574718dd2091cc5cd06770e5933bcc5266cd6545 GIT binary patch literal 1787 zcmVYxN7{?!{R0)-uHwg|@6r+^35|#*RI8{}X zR;mP-loR2EInoR1CusZ$2&W$7+~_F@hgLxXTco)qA{D8WibUC@Y7QXfjYFIkX*&Pi zS=iY7zTTO!cf&KGhEbZYDOR)FQdGam>hO^nc!uRngQGhFZaZa zsbZa%JT)TxgZDFe))$s_UVfrRfLTrW9@Cq3rn80e4|kWTZNgD0F>A!22h6g<5^GHG zA3sW}VU{ylXN}JUvxa4{i9L?wZ+w?Oj8pSq@G`{5pa#XDjbgwo@7GKsf-Em37PH@s zY>`65F=}2g)r4g(MfpdOgggc!+C&yJw2;S0L>q{CIc%R9N=Vs`rE>6wmKV0@)z)j; z+diQ7lP9RFr-MGpou-fbdPyP%g>*oHLUIui)_O*gn18fJi%&PGQhDS0`>vjobamvS zOhFN%yBt)>S4iQ(N@RJoRDl&v7nf*rZ97!`FTd+EizLRmm_Pbe2payNU(AvOp3JO- z77@U5qts6bLV$yQ6+DGx*2F$f5a$}hL}DK0OTUy?*Tn|O_o!W#1=2@G+Pu)kd)$+%i&TiA+ek+hH7 z+c_Y~47yq65g}z;Bk16R?Id7asel?|YYVE7HfIwKtWSOPAt|87D&GpJ!Wsdle`ou8 zC@!D!X`>>(cfN31`?hK`q&%bj6XGjsZoa0KTkhe$?m^Tl6b&jHP^Uf7gh@CNF zVhYLL<=-gdo8iwwi`m|Bg079^sH<0rqj559KQV(AVmdfM8i1+d@3X74zvDhWg!Nv% zaZv{m0b-Wtk-%jOIYm{Sr)m(8_d1ru5 zpXnscK*~-Q!=Fo}83?U8MFfaFKA!oDG{qEByZ-UP&kL^C(aaa-Hb_HEA+w|rutdMm znECr{>7lbAy0x-M2!y3rKg=fnP`f6smyobFuk~yvt9*Gu;tFCI&c(_`OM|E@iN8Jg zQ|1a{Ale8yKf4yGKKju`jU0~_LIyb)FW|v7aXcFk(jbMnjTyrN$Mfqi%cKL!Epn>( z2PFYx%Q#sYb!HbrM#@aBClDY>a)8a_nHANJ$AFM~qyV!?&&F%g)u4&bKUyV)7|JX% zu7mMnac)!P*iwy@yC6b~jsA0Zgo7j18kR6?W&H#PVe&U=W7AZxr0+=N0EGoGxsV7&VS(oh>Z-o>W4N8$~ zH*%@30wOO6*=-WC)C$fQM>Z_+WyLX)kZg*1@nPj?Q-AUN6+L;l682A+sVgHt4Afk2 z?|46P7H~+;JGa(Vza#b=V&7q^%MZBG>YI&o^9u8Tiq-oQkA%^L)45MAf+$p9aEnL6 zDwUoyra1+--Lf@83aj+pz<7)?$YXTl8X@OmqH>v9R8#QUyY08saGP39+(}Uwm@A$_ z3grvb0vHsah411Q7ZZ+Wl3LsjI*uXCDk6@&GU)hHa83oi2ozFy@gYiC%nK2Ba7miC zBR_mBOvO;^+QO`_Q;N<9Tj{>s>uR-$ zwFYJhT4H(v0nrsT&WM9q2N{=`9yFeS)iCRnd{s^US;Grh4YSTGhf|tj=>qE;XOca! z^siyD5jTnsADc>(>=`%6nvEbCy&UzB;d-G=$ZACMgP>V@w?7isy2Nv&Xz$X|F9tF) dioX!Z=6?skB>Agd4k-Wt002ovPDHLkV1j5>H;VuO literal 0 HcmV?d00001 diff --git a/assets/floorplans/icons/bathroomM.png b/assets/floorplans/icons/bathroomM.png new file mode 100644 index 0000000000000000000000000000000000000000..3c9d0170abe42441077641b073b30a0973698c95 GIT binary patch literal 1591 zcmV-72FUq|P)CF87D6zD5D2ID>n9PJELll}Y&QdYq!e)#{R>Yu z*)uCaekhQT10bSHU@?6QIY1)1u$Y(qwwbvVK3Y)R`76V-&grJ-kG&c5o;voC!Gp(8~$*+n1uqq+xpeFhzxsfkn$1X z5MZuTfwn>lXJVg+0=PY73(8lY|6+TxB0A1~S_&zvw6c)X{d*4)hQs3OugVK$Ei#bt{lG7b4D7&b{zFG|=RgPleRsjm!vnE|#ve@@qMrxP5nEW1M15#O$`LE{by zxiTyRx5`E2`So9qqD>FIjE+5Pk4d8r@I%1+I>CQ63&n6Oku7dCM9&#^M7!5sghIIO zOF~43=iT3K{RYMGmYpmMxd4Ui)&512a0jIUTS%qt<9~8hu+OlC%)$_rN#!2drx+n) z;$o=XXohBtkTGF|jE7wo(t;r}#v|k5fQ1aSGu6-%i-kOdAu5*f$apY9#(;fR+z1&R zK75ZMdGg+ZB6@JmB`Rh9;tqedpoqEonM9WZG;A0s^A}f0WvL5zi^_Fc*ymjmvHo6K z>z=zbL+j(OK_QlV7ROyhwXt4oO zc2mVqDM|-xNn0VM@&yrxFmQ*{trDO23ipVLiEDSCM?^LiIU4@jr z_!1Hpb1vWst|+I)0>JyjSN#<9wXQAQdKn3JJ{+ff+h;71W0?x_S#0tqd%uN)CQ#yS z(wr2UwM#(~AcslmmAIIiDU&T{0%nm8yk$j();SN3 zh5loM-rrItL@Mr3dw*n`$sJHXE#-3WoQE!`s;`Q4Qx(0EW}99}UZgX#X-P!PWEwm4 zg0f^qkq$VwZciOzZ9+JLmPBvD0&-K7o)L#|5*e37Z;(C#8xc;Hd|g%kLFxr;L^%1% zxl_6;l7+o*O(^b(m46J2i+rQp;Pau&yr4s-E002ovPDHLkV1oEz;syW! literal 0 HcmV?d00001 diff --git a/assets/floorplans/icons/bathroomN.png b/assets/floorplans/icons/bathroomN.png new file mode 100644 index 0000000000000000000000000000000000000000..3266260d1633095cce5acc2d34cdd2570f7de10f GIT binary patch literal 2140 zcmV-i2&4CjP)-4zRlACDQBlx)(MlI4m|}z!lV+mSg+dY)YSId)=RYqx zxtW)9o-=dr%uRl9a$ho&nfagRIWNz7Nr4)S5TYnwdS%)z(>j^3I}n@GJQ^tfa@q3#@sY4MZHYT2*i`=v`iIIALeAbC{r&r19%Xb z&bklQD5hm9Qy!5wzDLy0`c#>g=|{=~!g1lVBHK)hEL*6&xl^LCF04yMI4y>3ARH^K zhXU6yix{*J1L4$u%_Jfy%2W}| za#PzPT@lYv^MbA>EOSifBrPFVfQTO2Vk!%{f<*Lyn0r;rOeG;X70be_*EBxzgq}Tl zDc}ERf7iLbjdql}Xv_8uR1a_upsjrkF|kOo0*Vzmw$G(m`NZ`?tVLb^3rg`65K z7FfXLfie4GRQU9(?RH86T>I*!aeDaRY2d-8`F+#@am^o;i$zl8 z=J15Fhya$Wb9@-jXJ66aPrt{CXdgTGiY{FkvCq)}A;8|K144xqPRG7Xil971(q%|m zuveCGL54seRd6h>t4U<y%9`TAn+AJU{Z$SRiz$+1RY(BQQ}lZ1oU&^LjZ5zCCNCGG$f zHXAmqH*R{5IG`xs(;vfIdIV>d4oK!@zI8f3&b z{tteO>?H%ZJrq0)quXYDUGUl0JIH|J{&uBb$m3)H`prH2w~{X3mSelIvDf8zgJ}km zGee~8eXxZTZ|C0_@x`GJmBnn8frNucOEQ?icTJCjM@qIZs^HqkPVS=wIOM&tgBEUg z@FFDydJMl^o0Lt9;ARo5=)jR(x`Bg8&?)%&!rjnodO&b^K@u5APRw!}CUAK|4pB|v znFQ#*q0x(nYi)anIySsZJ4+kc%^ekxiRiA@_Ox(KgwZ~h9%EY5*MN9s)e~}#lA9Hl z3D7=-m~HKA?N&vKnRi|ad3uc-xNRyC0j#c0k$BAOB-nV7QUtCB_l9f3t&%7g@i!6Z zSTyV&`e8=T0Su7}(jw0gvquvL;)F;ZaIs@DI?e1&*xVfclSXdOP#T~a#jMS@q4~3$ zQUJB6Uqk>4x*>(&g7ec75x{ep;lLtYh93xEGC9EG#_&X*D;^6%J|`x&OJC!;bS-f4JJ%;Er&vZ7 z$*o|*z`Vwswk`QW!Wz>k*SM8d1F_eNvxN)`N|<62)Mya)&9=BJ?=77(lXmIAzn`DB zWfecX=FhO=NlHNpQ^JB87=K9{#=uw=KRh9ow=Pf(N|+K9)IfwisX6CC^z7fF8@;!@ zwK$&q_y8A=j07#ZOFrtwh^)nH2e2R{#(hwh`q7;{x`Z0J5?8K*yJwd^t>U8{^-z_^03D5P~07$KKp;@~Q+QcHi>+U=;J zh7+{v;(i8P0}CWnNLTrOS_N1XqebN4mlP8f&mgUGB5pedl~F`&d1cYcrQhEb3?o)Z zw-)as1u=JOuHdR?znux@ho4b7P-$G-HS5C^Q~4lX_~oIGNChm@NHVslvselluF#{p zj7j#Si0%eTm@kdZLL$46&O-Q?phqvb@ha5+$}}MI0ru8Sd=YD+2uIM0$R-dFMNxiQ z9KzYixFWJa{sgQ=IJ@LG%H{9nUcg#}vtKzXr6G|nu)cGm&NXq}{|$?W`=BWBv8dE! z&A3HQZv+$BtGb&ECq=`Ati^Rd2wJ4K>m%`9r~4jLwC8E*KL)Z24gQBf9{&Tyu+*V= SLUQZ?0000s0 literal 0 HcmV?d00001 diff --git a/assets/floorplans/icons/elevator.png b/assets/floorplans/icons/elevator.png new file mode 100644 index 0000000000000000000000000000000000000000..0eef4d7e621a4e58b88f2ae412aff6925fdbb857 GIT binary patch literal 1870 zcmV-U2eJ5xP)H=F&y?Ay(o-RypMe)Idy@6F71P!3>>S^Qx0g5wEsw8Uwvs`{QN zjySIIvWmwY9UZIGWICt`Z~=SyVS<+r$rG}d(g6Zjcv--a3+zy<0ugzc;-$>=QRZbr zSU_vQKa9=G954T}<}$}j7M6KJmRbIoV3j~DkTcayf||<>io`s`*eNb7x!o~eXn-Y)zNp|V+>cgsIzeuVg(gW2tj*fzVh>L zmW>zF2V#El$p_T^N>@}B^rIKzoK#XD3MUjP<`4J(Y#A{DKFi|z!?-Gb{2fW8prYTj zD2AoA*ne&$E6(3ZRR*{=(@`fw1r??q`6M6H9FPoKJC9|mO|pp8UsPw&(0KOtXCIRZ zjDPhV)d@`h1}}x`6E!VVC_h1Mk{IJ!+0}mxGx<~#t}RZDR)r|LHR_kTH=satXFjn=&RY%!1fkyHX)bEVq`m&&Kg*!MFqk9jA=xez_~* z{7cjb=$n7!qqin>AEW*qS5Wq1vR0<__q|4E-#8X}4$i5+uZQXdeR1jT1NT0<56wej z+c$f^h1#Tv%fshuGCF>yrx8kbHr#Fl;CO3!jWjcX8{Y;;NfQw7%TI@0)BNWN51V?r zY0!F|XL#y2cO+@>dO`m9Yu7DN7LfrqFSA{N2U@vwCY`twtr4ME7-*#l>9{n()J*E+ zwvQBlTJtltQ;O*?y?*sRX#&$VEqrwrX#%b3cW#DTJGJ2x9Ma&)!vTT1ZgIFHxW zWcoG))pFqtAq~8+?>NFx9fso4iwtmBS4ZC?4OsB<=P6_S;bxqez#oQ|BKpK2_=03Z zBRD5bvFQm2nrZ*(^<&8b1wkAM5#nF-0ja*GhS=0?p%!I`Olu1xu0;i7PFXa?rmj=U zw1uuX+f)y6E%fs6fLroN<|bcn21&IA<^-$Z3Ab$y(-t&k+JdGG1a+uQTI^}b8l=tj zg`Z=ifGCiV$v8ULGqQyur>dGkLVB?9&S}aR2ZBB#!@!uoaPf@Na|%31kT6v-4Iblb zb|^!F({f~{zQ|7F898^7e%;)bGJOb%`Wjd@+9}DJaHE%36mSKtkf9*Qnbh(y-CKjH z`#7CqHm>plr@Ro?H+p^TTcn9SY|-Hd_7yS|cyD<9Fd%j#7ylIb9yguA2c`nkufD{A znDdg}325`U@dYiC;RIt-O)+OR72LRjidAA2GMHd&sww6Sab$Crp#K-$o>=(+^GHqqh?W1&+`rl%&hZSR5J|>|;ct zd5D)O_$&zt6=*ROS0PqV(X1oAK9Ud;+e#RL0*g$6 zCPVQcDQ_lp;)i zD>fsRY>J@OFmG20i@de_n48!u9`{nM*iPVx@<<$W@e5zi0UB>Qp}xi*B>(^b07*qo IM6N<$f&?XQH~;_u literal 0 HcmV?d00001 diff --git a/assets/floorplans/icons/escalator.png b/assets/floorplans/icons/escalator.png new file mode 100644 index 0000000000000000000000000000000000000000..9fe35c40ccc60e0b088ec3c1e53eda47d0aecc8e GIT binary patch literal 1971 zcmV;k2Tb^hP)uXtWiSyNF(9TUMj&oE z5Iwa$c*2Bw;0zo+7}$95WT0nEx*oWAY3oJp!Ch@p6WY>ZG!>I}5h9v^R!o{8CeQbs zc~d8|@6DUpc{B57+fVX#I{&&m-~GM!`@P@1dD}%6z!-D*qQVQ7`^3@_yPeSCYoav8 za*3Bktao*FEmFaBQ2}rPd-!6Mm)FS?vWLt7fg8L`VaWw|>mjNmzVXUM86Rj5FJXvNKheWom%_Xol27uI;+@%ifRrqJKZwFz)}a} zR{tbkelt{a8Mx+&xCh4)RCvA$ZC=ogz7*#`ZIx$UoOzkH0OxttkJKVkP~n6Sw42IT zmL0UCN1~jRPS8~f61Gj`u%9{d;;#bi3!}Ma)p%8TV+AGk$Ai&oo$D`l`w-=o8FWT4%9|E=S+WBU`7 z1pL40?^kK!y4>pLSALgyegym#-j+7}4Pgtl+B$L3QQ;B9e0S*8#9{)RGdysBMtG62 z(ELqx>gnLhhM;Az&MWmLrI-)|Vk*L%*c|KME8V{S&tugo!y<9y6130?Sqwb-!m83r z=*-~Z)MB=YKMKYrDClXjl_wh{-?g#MozZ+VDu# zuHihKZvwnWR4oXAp_$ap>poKaR^>a9@QBJD!7`)7G?B^!2*sk_D+GpXI%q>3vIt$m zZLSG1HR(GQ6$on0jkh@A(aA&mmHT1YiavKNAYT8Tr*sMCC|nCYOcsDD zJ~8*Raz7kHRCuJbYmGD4MNX0zwLFp_z2Qh&IK^-cPvWwG+`LJWQqXQm~e*g=U%X z(53QnVl6id%`)MkOXcNA6N28Rj7gjDNMqSK^B4N^O=<%qA!e5E@Z}$=vO94S9#NLv zMn))07848VI)>SFSskA^;gQa=+sq1Og)3+y?$H=egds30FP#Kb@sWY&l>6f(JcMa9fdca7Xu z03}R##AOmM{(boxDF%>&g_V2K?YJ;hy<^YsHFA-8j9to*23}kX6;FvZR7Wn~lY8`2 zuN`z9g9$M$9=jBWjTv}?UC$}i(d&=?m2Ov!R7*;pW0x}GWB-q!pf!r&?M;){*rkkV z_NB@<;TL?(Ifis?Gu?) zM@thPDyphSymgOvD<`;`<^6kvP4^$Jr+yN+-G{Xb0u#R~tI^g31j*1&KoEJ?Q=C|( zEqtoFDTlPTk;NPvW3)w8H{S?IGH+8$z`uz=Hfb=Epm6vy9|g>0loO43%MsZd->D-{AX6;xbE zoC8Gyq%^mj==RE`DQBcMQqPoL;J6DXxR6GILjfsEkWkg2Ca8f!N>oQxlS*x~L5bAz zAx+=&$JXn$*B*PE^=#%Rz4h*TKRkcGH*Y*1k8_X#7-JePbTnG_+e^(}t$9AaVVhwu ze@9!O*STD70a{EBS^x^zjf*1MFjzu%gBVEQ3feR+QD6hw6%r9`0&RwEA2Vo0I|AAR zER2p;LaVaYQerX-%Pb*hSoSC}nS`Z{MVn&Tr^K=@EN(HK5(8jO| z*Z@wr1!eNrmr9BL5!{|#aUa?sz(Q# zC8;n$Zv$sWFWvkJ?ruJ;dvEB4SI`E`=NEtdrr|pu9e)pc3tjNn#u|M6=WN}&-okEN z|2{SJ&X~*1Gv#XZ|@juzL@@(RUbL>fR3tSwKMtgI4B)R@n8X2An+l z?;of>-mHHQ3+dop=f%4|2MnUJc;A5OTi0Oy@mBqBqOEQ{gt>c5C~PkjUpr~;sF)l> zZ7!gw7yMTGCM7%6eZP=ZuHL?3Mu%5SFXXkKUjG(WJyFETSr*Xs7nJ3Nc4?&ziTMLs zR48ITpZ`8o{3cLN@y+@Tg4)U#K@vz59x)wA)Guy)S63ZzatgK5y>I$?s8rrbqQr%E z#_XL%NI-*Db%M&~os`WJgssUhC};vN)xBQo^-!t2bN0?3kQ5R#p_iU^zo-c+n|D&S zrlybxenU@Bz$v@q$i(fTI!MHFbrnQG)y8^A`?v|tC>RsBpr=71maF$bBy?nzfKlg( zDJXk7SW6lbcvQgeB}#6(6ZVdW>L3{jGP5t$OsmF zG&B?P82i-4pK5>S>gGCJU%M5!e(}Ky6rSne^14F4LwMLHgobNs)P1Kx$}B!GObcRq z=^F4t9$E!)^k9Fi0CWm@DNnrhb?pI>P(P^vG!pc6kPrbq?{)Nb?F9k!lb}ND&z?>p zp;My}kbDOA^@Aw-&^r_K;JYzBR0Dws3Y{9g0XvB5&_jZ})H&ZJ08O}u9uQo&4eSl} z#CKmj4oT@j&pg!A2ci%zD0z)2&!NH&?9(94|2nb%h^TNUw8R5${Se_f1Qj-{0YCoo zaO_4t?XZ&v#cZoXDuR;NB(0YyQA8;$dDr;SVc@05Jm3ETh$a<5iP6Jv!JE$?fjCfM z&)~W9s8Gf~^Xfa1J=GCYV5rx~#TgyK1D%n)%5heb>df(@L$SixIRiQu&;w6Ruwjgx zx6xAS3)o0qP$I0L1XUAM_lihpi}zQcbo0lC_b(28n0BX%LC5k2q%5da&kdQ?z$dd` zHLU;m#F#nuNEC%<{YBe`9x8+C&=uGwf(n$uuDH1TO@bPr5XFKLR4k^T6;O!6*kzB# z<-Wj4(ZB{x(cv`gE1*cq&-Ad%{X&t5EA-$9S_VZ@D)e9qYF{N*0YzMDGw`@ON2Wm$ zmoJ?sZb8eSvVB}C&Xby;cGxQ~<)`ev5X{>jS_TC#lkOLFf}+wDP|1mhviCAYzaINZ zP(-Ccs|G=BFTDbaFv@-x(FUDUItMD_hv6H^zJl7yUj{`GlR=kw2cGq`ueNRZ1g9Cq z2)YuP2s@2D1}gO3kQO7&KG0N9+pSgBf68RoltZBd9qc(aRDEiR89Mh$9wLB0L{t6J zz+4>?B53G!Q#$h5tIJQ`@mgb(zW*M@%6wYqEdYCbIT}$}j;55jm-~%*iU&Y}j6oy|%5hPL~oRlIp63Re;1*B?P35gYT zB-Cz@N_Xt)E@9K~3y6O~(_fHe*Ik<>u&@c6N|k6pDM$h22^9?zP#ZN;DyVdO&di;5 zVtYJao_lBP`$#9_iEdT<&ADG^?l~?&GeQW9E)}vfWM#4?dG*YXP1csSL$)ukJEc;o z4ULqb7!r=fsgj+CD_|LeVMp2)iFzBu2#ZIyLRJ@QXpyaxRWJWK|Rq zdgDt%o%cnhN%kd*fN;C;b&+qHBF`R5Z|<7V+lAv&7w(E79|+eA>!O(OAASY85$+DO0{5+K~!Zby* zoWbgFXS7UV;nsir2EVlrBDKHp#yMOhQ^XpH`9;45Vap#L7cD4YYwu2E5t*;abK|s6 z35NhH{Tc)cDcp^H6$;?-@a~^`h|A5Nz6~4#GNAA7r>BszODhXGZ9lk=Bs_X@==>2B zVWE#EK_02BOS2d}J11rLuYD*au#0yIDZQ}@MLc|*TtuezAMdF68q(o5^-4Gt{l$KlKhYamBx`^b2_fnqZIo@byK+<#XC|Ys5P-;@}7= zUYx(t#-+DEh%Dv=4F^|Gf23=o@pH+0F)pta^HSE2gzI#3oA~Jjs zqRa=loahhT+(O6TsT&SR{MtHj7TlBLFFWJm>67zxokq^!cxA&AvW3B&3L7zK0kpIGv7Tb8_U@$@s04G=62$>$| z6xW!{ytiRUN`rrR%bzV6lG5NGo{+;?x*&UOFBiuXl5eyY*C;3r?}a0z94Afpj?bp| z!oNk_fFTattsCCWb0Os~Z5X0b^WSs^EjD3@O2dECDJ16`nn@>|jn4eUV?xSF=LQU6 z)H;9BD;{aQvOl$6*|J?iO0Vpjz&OI#2w2_pjF6L<+_(WlC$*s5?S79MuEKP2KO54) zdJ-t4RK5lSVn2RUl zWpFLb%s~NsK72Jw!${AyrCVP{hLaDc(s6o76gb1-LiY76Zu24gsEy+mP~v0KTr4EB z6Kq~0{L%jbRaPdaFgJA~byjT%9G_i|Qvf+hNw3Vs)J%iya@;D?v5&0C&^p(_u`zsX z(&t+)B`C$6=guba*fd36%52jcsfuD&Hm!@4l}rZ?y&;Qi zLlgt9t(&VP)-HrAXkFwJ7LbRc;;cA?TgbRB@_oU#@^y9jdxaOU6XDh?=Sk_7 z$QJg#HKBMWR{k|C9`b|ofX_*#X>!JGxVw=b_!_s!@SxBpWGAxwv7i=SUP)BoO2{X;qo5A?1ZN6FfdrvJh$f~%6d(>E1%Z&3ne#eveD?X@UhMVFPg-w$ zcHsPEW@o+L9e2)O$Wo!IFkU3^`?qq5EWola*PHPb;2kbsL+b*WrO zAmk#Eqpxfd zmcJ~OP1St*s_DYg7kr@VckQA)fI*Bzc@_MnH>q5Lo%mE%O!NvCP!SUIE>&+~ub7NO zV1w397{$ zV)FjFt@tPyta1yg-JHd2yJf~toae+$ zmsdeQ^9cmz9=ZNhEv5>pgAjMxO^sLD4!5Jr(}Gl%pgWkKh@O{i5_CE3U}_tx+f4^T zoJ*w5!?c5m7OGu+2SOUDuK!=kFBl!tnkCO(zQ>!LFPJ6wZ(hZz6UU+-aZKT;zZO4t zLQrj;U0A~I??3qZa~Cymi?njdO1Zg8C3lF%kM&^g|7t!~(wlx9l*4$xVHX-IrP{E4< z2CDh+%L^RXf@-d?LlsL6WEH9z!g9drE1QLkqXJ>O>hNFx!@ZD0TijOH3#uFhHwCMh z%?qk{^4p+}zGN+e#!*352?xQ`0bXvtsP`8!DYhS}xqQ-fBi``!4#S$761SM literal 0 HcmV?d00001 diff --git a/lib/models/floorplan.dart b/lib/models/floorplan.dart new file mode 100644 index 0000000..acc19c7 --- /dev/null +++ b/lib/models/floorplan.dart @@ -0,0 +1,244 @@ +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; + } +} + +/// 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/screens/map_screen.dart b/lib/screens/map_screen.dart index 1b716e2..92d6950 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -8,6 +8,7 @@ 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/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'; @@ -150,6 +151,7 @@ class _MaizeBusCoreState extends State { final LiveBusesLayer liveBusesLayer = LiveBusesLayer(); final JourneyLayer journeyLayer = JourneyLayer(); final NavigationLayer navigationLayer = NavigationLayer(); + final FloorplansLayer floorplansLayer = FloorplansLayer(); // GoogleMaps styles String _darkMapStyle = "{}"; @@ -171,6 +173,7 @@ class _MaizeBusCoreState extends State { navigationManager.init(); baseRoutesLayer.init(_favoriteStops, _selectedRoutes, onStopClicked); + floorplansLayer.load(); journeyLayer.init( _showBusSheet, _activeJourneyBusIds, @@ -1571,6 +1574,7 @@ class _MaizeBusCoreState extends State { child: CompositeMapWidget( initialCenter: startLatLng, mapLayers: [ + floorplansLayer, baseRoutesLayer, liveBusesLayer, journeyLayer, 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_layers/floorplans_layer.dart b/lib/services/map_layers/floorplans_layer.dart new file mode 100644 index 0000000..6806740 --- /dev/null +++ b/lib/services/map_layers/floorplans_layer.dart @@ -0,0 +1,350 @@ + +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]; + } + + /// Loads the floorplan and builds the first floor's geometry. + 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/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/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart index 3db7be4..f6bc084 100644 --- a/lib/widgets/composite_map_widget.dart +++ b/lib/widgets/composite_map_widget.dart @@ -6,6 +6,10 @@ 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 @@ -21,6 +25,10 @@ abstract class CompositeMapLayer { // 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 @@ -127,6 +135,7 @@ class CompositeMapWidgetState extends State GoogleMapController? _mapController; Set allMarkers = {}; Set allPolylines = {}; + Set allPolygons = {}; CameraPosition? oldCameraPosition; ValueNotifier> _ripples = ValueNotifier([]); final _rebuildWatchdog = RebuildWatchdog('CompositeMapWidget'); @@ -187,6 +196,10 @@ class CompositeMapWidgetState extends State 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: [ @@ -199,6 +212,7 @@ class CompositeMapWidgetState extends State myLocationButtonEnabled: false, markers: allMarkers, polylines: allPolylines, + polygons: allPolygons, cameraTargetBounds: CameraTargetBounds( LatLngBounds( southwest: LatLng( @@ -215,7 +229,7 @@ class CompositeMapWidgetState extends State // markers: curMarkers.union(widget.staticMarkers), initialCameraPosition: CameraPosition( target: widget.initialCenter, - zoom: 15.0, + zoom: INITIAL_MAP_ZOOM, ), style: isDarkMode(context) ? _darkMapStyle : _lightMapStyle, onMapCreated: (GoogleMapController controller) { diff --git a/pubspec.yaml b/pubspec.yaml index a90b1f1..d55fd9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,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..a894d8b --- /dev/null +++ b/test/floorplan_test.dart @@ -0,0 +1,173 @@ +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(1)); + expect(floor.walls, hasLength(919)); + expect(floor.rooms, hasLength(147)); + expect(floor.pois, hasLength(195)); + expect(floor.outline, hasLength(224)); + }); + + 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('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); + }); + }); +} From 1619a40a1c75c17311243355f747eb36e2e29f99 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sun, 23 Aug 2026 13:41:37 -0400 Subject: [PATCH 103/121] Updated camera position variable --- analysis_options.yaml | 9 + android/gradle.properties | 7 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 2 +- lib/screens/map_screen.dart | 425 +++++++++--------- 5 files changed, 239 insertions(+), 206 deletions(-) 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/gradle.properties b/android/gradle.properties index 475a628..71624e8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,7 +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 -# This builtInKotlin flag was added automatically by Flutter migrator -android.builtInKotlin=false +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 39c1887..ff0c397 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ 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 diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 92d6950..211c9e6 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -79,7 +79,8 @@ class _MaizeBusCoreState extends State { Loadpoint("Initializing...", 0), ); GoogleMapController? _mapController; - CameraPosition? _currentCameraPos; + final ValueNotifier _currentCameraPos = + ValueNotifier(null); bool? _userLocVisible; static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; @@ -183,7 +184,8 @@ class _MaizeBusCoreState extends State { navigationManager.setMapLayer(navigationLayer); navigationLayer.init(); - navigationLayer.isVisible = false; // Hide the navigation layer until we're ready to show it + 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 @@ -285,11 +287,11 @@ class _MaizeBusCoreState extends State { final prefs = await SharedPreferences.getInstance(); globalFollowDistanceThresholdMeters = - prefs.getDouble('follow_distance_threshold_meters') ?? - globalFollowDistanceThresholdMeters; + prefs.getDouble('follow_distance_threshold_meters') ?? + globalFollowDistanceThresholdMeters; globalGpsUpdateDistanceFilterMeters = - prefs.getInt('gps_update_distance_filter_meters') ?? - globalGpsUpdateDistanceFilterMeters; + prefs.getInt('gps_update_distance_filter_meters') ?? + globalGpsUpdateDistanceFilterMeters; screenRadius = await ScreenCornerRadius.get(); // load screen radius screenRadiusLoaded = true; @@ -340,12 +342,12 @@ class _MaizeBusCoreState extends State { ); } - 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 - ); + 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([ @@ -389,7 +391,7 @@ class _MaizeBusCoreState extends State { await Future.delayed(const Duration(milliseconds: 180)); } - Future startLocationUpdates() async { + Future startLocationUpdates() async { if (!await Geolocator.isLocationServiceEnabled()) return; LocationPermission perm = await Geolocator.checkPermission(); @@ -409,41 +411,42 @@ class _MaizeBusCoreState extends State { distanceFilter: globalGpsUpdateDistanceFilterMeters, ); - _posSub = Geolocator.getPositionStream(locationSettings: settings).listen( - (Position p) async { - // Keep this lightweight; do a minimal amount of work here and defer heavy updates. - if (!mounted || _mapController == null) return; - - // If follow mode is disabled, don't recenter automatically. - if (!_followUser) return; - if (_userHasInteractedWithMap) return; - - // Only move camera if user has moved more than threshold to avoid jitter. - final shouldMove = _lastCenteredPos == null || - Geolocator.distanceBetween( - _lastCenteredPos!.latitude, - _lastCenteredPos!.longitude, - p.latitude, - p.longitude, - ) > - globalFollowDistanceThresholdMeters; - - if (!shouldMove) 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?.zoom, - bearing: _currentCameraPos?.bearing, - ); + _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( + Position p, + ) async { + // Keep this lightweight; do a minimal amount of work here and defer heavy updates. + if (!mounted || _mapController == null) return; + + // If follow mode is disabled, don't recenter automatically. + if (!_followUser) return; + if (_userHasInteractedWithMap) return; + + // Only move camera if user has moved more than threshold to avoid jitter. + final shouldMove = + _lastCenteredPos == null || + Geolocator.distanceBetween( + _lastCenteredPos!.latitude, + _lastCenteredPos!.longitude, + p.latitude, + p.longitude, + ) > + globalFollowDistanceThresholdMeters; + + if (!shouldMove) 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: Update any navigation manager / UI that depends on live position here. + }); // TODO: Consider throttling updates or using a timer if animateCamera is too frequent. } @@ -587,6 +590,7 @@ class _MaizeBusCoreState extends State { @override void dispose() { _loadingMessageNotifier.dispose(); + _currentCameraPos.dispose(); _connectivitySubscription?.cancel(); Provider.of(context, listen: false).stopBusUpdates(); @@ -1157,17 +1161,14 @@ class _MaizeBusCoreState extends State { navigationManager.initFromJourney( journey, - getColor(context, ColorType.mapWalkingLine) + getColor(context, ColorType.mapWalkingLine), ); setState(() { _navigationOverlayEnabled = true; - }); - // TODO: Center the map to the start location - }, ); }, @@ -1208,9 +1209,7 @@ class _MaizeBusCoreState extends State { style: TextStyle(fontSize: 30, fontWeight: FontWeight.w700), ), SizedBox(height: 15), - JourneyBody( - journey: currDisplayed, - ), + JourneyBody(journey: currDisplayed), ], ), ); @@ -1240,14 +1239,10 @@ class _MaizeBusCoreState extends State { void _onCameraMove(CameraPosition position) { if (!mounted) return; + _currentCameraPos.value = position; if (!_isProgrammaticCameraMove) { _userHasInteractedWithMap = true; } - - // Note: Please avoid calling setState() inside _onCameraMove since Flutter has to rebuild the map each time and it causes stuttering. Thanks! - // setState(() { - // _currentCameraPos = position; - // }); } void _onCameraIdle() async { @@ -1480,12 +1475,13 @@ class _MaizeBusCoreState extends State { } 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 ), ), @@ -1578,7 +1574,7 @@ class _MaizeBusCoreState extends State { baseRoutesLayer, liveBusesLayer, journeyLayer, - navigationLayer + navigationLayer, ], onMapCreated: _onMapCreated, onCameraMove: _onCameraMove, @@ -1846,7 +1842,6 @@ class _MaizeBusCoreState extends State { ), ), - // reminder widget SizedBox(height: 30.0), _journeyOverlayActive || _isOffline @@ -1861,130 +1856,78 @@ class _MaizeBusCoreState extends State { Spacer(), - (!_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) * - vec_math.degrees2Radians - : 0, - child: FaIcon( - 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: () { - _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, + child: Transform.rotate( + angle: + _currentCameraPos + .value != + null + ? (-_currentCameraPos + .value! + .bearing - + 45) * + vec_math + .degrees2Radians + : 0, + child: FaIcon( + FontAwesomeIcons + .compass, color: getColor( context, ColorType @@ -1993,13 +1936,87 @@ class _MaizeBusCoreState extends State { ), ), ), + ), + ), + + // 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, + ), + ), + ], + 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, + ), + ), + ), + ), + ), + ], ), ], ), - ], - ), - ) - : SizedBox.shrink(), + ) + : SizedBox.shrink(); + }, + ), // if showing journey, show close and reopen button (_journeyOverlayActive) @@ -2273,29 +2290,35 @@ class _MaizeBusCoreState extends State { _floorplanOverlayEnabled = true; }); }, - child: Text("Floorplan") - ) + child: Text("Floorplan"), + ), ], ), ], ), ), - _floorplanOverlayEnabled ? Positioned.fill( - child: RepaintBoundary( - child: FloorplanOverlay( - onClosed: () { - setState(() { - _floorplanOverlayEnabled = false; - }); - } - ) - ) - ) : SizedBox.shrink(), - _navigationOverlayEnabled ? Positioned.fill( - child: RepaintBoundary( - child: NavigationOverlay(navigationManager: navigationManager) - ) - ) : SizedBox.shrink(), + _floorplanOverlayEnabled + ? Positioned.fill( + child: RepaintBoundary( + child: FloorplanOverlay( + onClosed: () { + setState(() { + _floorplanOverlayEnabled = false; + }); + }, + ), + ), + ) + : SizedBox.shrink(), + _navigationOverlayEnabled + ? Positioned.fill( + child: RepaintBoundary( + child: NavigationOverlay( + navigationManager: navigationManager, + ), + ), + ) + : SizedBox.shrink(), ], ), ) From fdcdd0f21ea5e8607cb7287ffa17d1a384d90cc9 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sun, 23 Aug 2026 22:42:45 -0400 Subject: [PATCH 104/121] Make sure center button doesn't disable recentering --- .gitignore | 3 ++- lib/screens/map_screen.dart | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index db2cc6d..ff4106c 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,5 @@ app.*.symbols local.properties # floorplan jsons -assets/floorplans/*.json \ No newline at end of file +assets/floorplans/*.json +android/app/src/main/kotlin/com/ishankumar/bluebus/MainActivity.kt diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 211c9e6..bfc778f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1470,6 +1470,7 @@ class _MaizeBusCoreState extends State { ); } finally { _isProgrammaticCameraMove = false; + _userHasInteractedWithMap = false; // Reset user interaction flag after programmatic move } } } From 5f7f8ba5113eb24e71c925ffecd9d38973bd44f9 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sun, 23 Aug 2026 23:10:10 -0400 Subject: [PATCH 105/121] Centering Debug Logs, Fixed auto centering --- lib/screens/map_screen.dart | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index bfc778f..92e459b 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'dart:async'; import 'dart:convert'; import 'dart:ui' as ui; +import 'dart:developer'; import 'package:bluebus/globals.dart'; import 'package:bluebus/models/bus_stop.dart'; import 'package:bluebus/providers/theme_provider.dart'; @@ -69,7 +70,7 @@ class _MaizeBusCoreState extends State { // TODO: Follow-mode state. When true, the map recenters on location updates. Position? _lastCenteredPos; bool _userHasInteractedWithMap = false; - bool _isProgrammaticCameraMove = false; + bool _isProgrammaticCameraMove = true; bool _followUser = true; NavigationManager navigationManager = NavigationManager(); @@ -414,12 +415,21 @@ class _MaizeBusCoreState extends State { _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( Position p, ) async { + log("Received location update: ${p.latitude}, ${p.longitude}"); // Keep this lightweight; do a minimal amount of work here and defer heavy updates. - if (!mounted || _mapController == null) return; + 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) return; + if (_userHasInteractedWithMap) { + log("Ignoring location update: user has interacted with map"); + return; + } // Only move camera if user has moved more than threshold to avoid jitter. final shouldMove = @@ -431,7 +441,11 @@ class _MaizeBusCoreState extends State { p.longitude, ) > globalFollowDistanceThresholdMeters; - + if (shouldMove) { + log("Centering map on new location: ${p.latitude}, ${p.longitude}"); + } else { + log("Ignoring location update: ${p.latitude}, ${p.longitude}"); + } if (!shouldMove) return; _lastCenteredPos = p; @@ -1469,8 +1483,9 @@ class _MaizeBusCoreState extends State { ), ); } finally { - _isProgrammaticCameraMove = false; - _userHasInteractedWithMap = false; // Reset user interaction flag after programmatic move + _isProgrammaticCameraMove = true; + _userHasInteractedWithMap = + false; // Reset user interaction flag after programmatic move } } } From fcd046c9eaddca03fb293d57800f6d2c07203cd9 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Mon, 24 Aug 2026 14:32:06 -0400 Subject: [PATCH 106/121] Fixed Centering Location fully, and moving camera now disables recentering --- lib/screens/map_screen.dart | 48 +++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 92e459b..b049c4f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -306,6 +306,11 @@ class _MaizeBusCoreState extends State { 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, + ); } } @@ -411,11 +416,18 @@ class _MaizeBusCoreState extends State { accuracy: LocationAccuracy.bestForNavigation, distanceFilter: globalGpsUpdateDistanceFilterMeters, ); + var isFirstLocationUpdate = true; _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( Position p, ) async { 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"); @@ -430,23 +442,45 @@ class _MaizeBusCoreState extends State { 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 = - _lastCenteredPos == null || + lastCentered == null || Geolocator.distanceBetween( - _lastCenteredPos!.latitude, - _lastCenteredPos!.longitude, + lastCentered.latitude, + lastCentered.longitude, p.latitude, p.longitude, ) > globalFollowDistanceThresholdMeters; - if (shouldMove) { + + 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) return; + if (!(shouldMove && !userMoved)) return; _lastCenteredPos = p; @@ -1255,11 +1289,15 @@ class _MaizeBusCoreState extends State { if (!mounted) return; _currentCameraPos.value = position; if (!_isProgrammaticCameraMove) { + log("noted nonprogrammatic camera move"); _userHasInteractedWithMap = true; } } void _onCameraIdle() async { + // The next camera movement is user-controlled unless a new animation starts. + _isProgrammaticCameraMove = false; + // check if user location is within viewport bounds LatLngBounds? viewportBounds = await _mapController?.getVisibleRegion(); if (viewportBounds != null) { From 1ec54b9d72d897d8908ca34c41607ab3269fd031 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Mon, 24 Aug 2026 19:15:17 -0400 Subject: [PATCH 107/121] connected floor switcher --- lib/models/floorplan.dart | 26 ++++ lib/screens/map_screen.dart | 1 + lib/services/map_layers/floorplans_layer.dart | 10 +- lib/widgets/floorplan_overlay_widget.dart | 100 ++++++++++++-- test/floorplan_test.dart | 124 +++++++++++++++++- 5 files changed, 248 insertions(+), 13 deletions(-) diff --git a/lib/models/floorplan.dart b/lib/models/floorplan.dart index acc19c7..21f8353 100644 --- a/lib/models/floorplan.dart +++ b/lib/models/floorplan.dart @@ -119,6 +119,32 @@ class FloorplanFloor { } 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. diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b049c4f..5b7361a 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2355,6 +2355,7 @@ class _MaizeBusCoreState extends State { ? Positioned.fill( child: RepaintBoundary( child: FloorplanOverlay( + floorplansLayer: floorplansLayer, onClosed: () { setState(() { _floorplanOverlayEnabled = false; diff --git a/lib/services/map_layers/floorplans_layer.dart b/lib/services/map_layers/floorplans_layer.dart index 6806740..151f6fb 100644 --- a/lib/services/map_layers/floorplans_layer.dart +++ b/lib/services/map_layers/floorplans_layer.dart @@ -57,8 +57,14 @@ class FloorplansLayer extends CompositeMapLayer { return all[_floorIndex]; } - /// Loads the floorplan and builds the first floor's geometry. - Future load() async { + 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) { diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index b3e173a..46a3d35 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -1,4 +1,6 @@ import 'package:bluebus/constants.dart'; +import 'package:bluebus/models/floorplan.dart'; +import 'package:bluebus/services/map_layers/floorplans_layer.dart'; import 'package:flutter/material.dart'; @@ -7,22 +9,44 @@ 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 { - // const FloorSelector + /// 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; + + const FloorSelector({ + super.key, + required this.floors, + required this.initialIndex, + required this.onFloorSelected, + }); @override State createState() => _FloorSelectorState(); } class _FloorSelectorState extends State { - List floors = ["1", "2", "3"]; - int selectedIndex = 1; + late int selectedIndex = 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 = index; + selectedIndex = clamped; }); + + if (changed) widget.onFloorSelected(clamped); } @@ -143,8 +167,13 @@ 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.onClosed, + required this.floorplansLayer }); @override @@ -152,7 +181,52 @@ class FloorplanOverlay extends StatefulWidget { } class _FloorplanOverlayState extends State { - List floors = ["1", "2", "3"]; // TODO: Change type as necessary + /// 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 []; + int selectedIndex = 0; + bool loadFinished = false; + + FloorplansLayer get layer => widget.floorplansLayer; + + @override + void initState() { + super.initState(); + loadFloors(); + } + + /// 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] + ..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; + }); + } + + void selectFloor(int index) { + setState(() { + selectedIndex = index; + }); + // The selector works in display order, the layer in the data's own order. + layer.setFloorIndex(layer.floors.indexOf(floors[index])); + } + + 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) { @@ -162,7 +236,9 @@ class _FloorplanOverlayState extends State { children: [ Container( decoration: BoxDecoration( - color: maizeBusBlue, + // Transparent so the floorplan the map is drawing underneath shows + // through -- this overlay is just the chrome around it. + color: Colors.transparent, // gradient: LinearGradient( // begin: Alignment.topLeft, // end: Alignment(0.8, 1), @@ -207,7 +283,7 @@ class _FloorplanOverlayState extends State { child: Padding( padding: EdgeInsetsGeometry.only(left: 20, right: 20, top: 7, bottom: 7), child: Text( - "Duderstadt Floor 400", + title, style: TextStyle(color: Colors.black,), textAlign: TextAlign.center, ), @@ -228,7 +304,13 @@ class _FloorplanOverlayState extends State { children: [ - FloorSelector(), + // 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, + ), // IconButton.filled( diff --git a/test/floorplan_test.dart b/test/floorplan_test.dart index a894d8b..25ebfde 100644 --- a/test/floorplan_test.dart +++ b/test/floorplan_test.dart @@ -41,11 +41,36 @@ void main() { test('parses the asset', () { expect(source.floorplan.building, 'Duderstadt'); - expect(source.floorplan.floors, hasLength(1)); + 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(224)); + 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', () { @@ -102,6 +127,101 @@ void main() { }); }); + 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; From 80b707f0fb55caefbe707448a6639aebfc8951a5 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Mon, 24 Aug 2026 21:06:33 -0400 Subject: [PATCH 108/121] More centering fixes --- lib/screens/map_screen.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b049c4f..9548fcf 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -82,6 +82,8 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; final ValueNotifier _currentCameraPos = ValueNotifier(null); + final ValueNotifier _currentPhonePosition = + ValueNotifier(null); bool? _userLocVisible; static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; @@ -305,6 +307,7 @@ class _MaizeBusCoreState extends State { // permission = await Geolocator.requestPermission(); Position? pos = await Geolocator.getLastKnownPosition(); if (pos != null) { + _currentPhonePosition.value = pos; startLatLng = LatLng(pos.latitude, pos.longitude); _currentCameraPos.value = CameraPosition( target: startLatLng, @@ -422,6 +425,7 @@ class _MaizeBusCoreState extends State { Position p, ) async { log("Received location update: ${p.latitude}, ${p.longitude}"); + _currentPhonePosition.value = p; if (isFirstLocationUpdate) { isFirstLocationUpdate = false; log("Ignoring first location update"); @@ -639,6 +643,7 @@ class _MaizeBusCoreState extends State { void dispose() { _loadingMessageNotifier.dispose(); _currentCameraPos.dispose(); + _currentPhonePosition.dispose(); _connectivitySubscription?.cancel(); Provider.of(context, listen: false).stopBusUpdates(); @@ -1928,13 +1933,13 @@ class _MaizeBusCoreState extends State { // face north button is only visible when not facing north Visibility( visible: - _currentCameraPos.value != - null && - _currentCameraPos - .value! - .bearing != - 0, - child: DecoratedBox( + _currentCameraPos.value != + null && + _currentCameraPos + .value! + .bearing != + 0, + child: DecoratedBox( decoration: BoxDecoration( boxShadow: [ BoxShadow( From 938bdaddabbdd0dce2973ba03206134f3f5162ed Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Mon, 24 Aug 2026 21:34:05 -0400 Subject: [PATCH 109/121] TestingNewCenterBehavior --- lib/screens/map_screen.dart | 127 +++++++++++++++--------------------- 1 file changed, 54 insertions(+), 73 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b43edb0..f5aa5ff 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -69,7 +69,8 @@ class _MaizeBusCoreState extends State { StreamSubscription? _posSub; // TODO: Follow-mode state. When true, the map recenters on location updates. Position? _lastCenteredPos; - bool _userHasInteractedWithMap = false; + final ValueNotifier _userHasInteractedWithMap = + ValueNotifier(false); bool _isProgrammaticCameraMove = true; bool _followUser = true; @@ -82,8 +83,6 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; final ValueNotifier _currentCameraPos = ValueNotifier(null); - final ValueNotifier _currentPhonePosition = - ValueNotifier(null); bool? _userLocVisible; static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; @@ -307,7 +306,6 @@ class _MaizeBusCoreState extends State { // permission = await Geolocator.requestPermission(); Position? pos = await Geolocator.getLastKnownPosition(); if (pos != null) { - _currentPhonePosition.value = pos; startLatLng = LatLng(pos.latitude, pos.longitude); _currentCameraPos.value = CameraPosition( target: startLatLng, @@ -425,7 +423,6 @@ class _MaizeBusCoreState extends State { Position p, ) async { log("Received location update: ${p.latitude}, ${p.longitude}"); - _currentPhonePosition.value = p; if (isFirstLocationUpdate) { isFirstLocationUpdate = false; log("Ignoring first location update"); @@ -442,7 +439,7 @@ class _MaizeBusCoreState extends State { // If follow mode is disabled, don't recenter automatically. if (!_followUser) return; - if (_userHasInteractedWithMap) { + if (_userHasInteractedWithMap.value) { log("Ignoring location update: user has interacted with map"); return; } @@ -510,7 +507,7 @@ class _MaizeBusCoreState extends State { if (!enabled) return; // When enabling follow mode, reset last-centered so next position recenters immediately. _lastCenteredPos = null; - _userHasInteractedWithMap = false; + _userHasInteractedWithMap.value = false; }); } @@ -643,7 +640,7 @@ class _MaizeBusCoreState extends State { void dispose() { _loadingMessageNotifier.dispose(); _currentCameraPos.dispose(); - _currentPhonePosition.dispose(); + _userHasInteractedWithMap.dispose(); _connectivitySubscription?.cancel(); Provider.of(context, listen: false).stopBusUpdates(); @@ -1295,8 +1292,9 @@ class _MaizeBusCoreState extends State { _currentCameraPos.value = position; if (!_isProgrammaticCameraMove) { log("noted nonprogrammatic camera move"); - _userHasInteractedWithMap = true; + _userHasInteractedWithMap.value = true; } + } void _onCameraIdle() async { @@ -1527,7 +1525,7 @@ class _MaizeBusCoreState extends State { ); } finally { _isProgrammaticCameraMove = true; - _userHasInteractedWithMap = + _userHasInteractedWithMap.value = false; // Reset user interaction flag after programmatic move } } @@ -1999,74 +1997,57 @@ class _MaizeBusCoreState extends State { ), // 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, - ), + ValueListenableBuilder( + valueListenable: + _userHasInteractedWithMap, + builder: (context, userMoved, child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: userMoved && + (_userLocVisible == null || + _userLocVisible!) + ? DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ).withAlpha(50), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + borderRadius: + BorderRadius.circular(25), ), - ], - borderRadius: - BorderRadius.circular( - 25, - ), - ), - child: FloatingActionButton.small( - onPressed: () { - _setFollowMode( - true, - ); - _centerOnLocation( - true, - ); - }, - heroTag: - 'location_fab', - backgroundColor: - getColor( + child: FloatingActionButton.small( + onPressed: () { + _setFollowMode(true); + _centerOnLocation(true); + }, + heroTag: 'location_fab', + backgroundColor: getColor( context, - ColorType - .mapButtonSecondary, + ColorType.mapButtonSecondary, ), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular( - 56, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(56), + ), + child: Icon( + Icons.my_location, + color: getColor( + context, + ColorType.mapButtonPrimary, ), - ), - child: Icon( - Icons.my_location, - color: getColor( - context, - ColorType - .mapButtonPrimary, + ), ), - ), - ), - ), + ) + : SizedBox.shrink(), + ); + }, ), ], ), From 0318c5fa3a87d0f2516b1888b4ef94c7ff82e45e Mon Sep 17 00:00:00 2001 From: Pronkle Date: Wed, 26 Aug 2026 18:26:15 -0400 Subject: [PATCH 110/121] minor change to stage event system using emit, starting refactoring for oops code --- .../navigation/navigation_manager.dart | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 312887d..2c191c4 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -89,9 +89,24 @@ sealed class NavigationStage { return false; } - final _eventController = StreamController(); + // 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 - Stream get events => _eventController.stream; + /// 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(); @@ -104,7 +119,7 @@ sealed class NavigationStage { void receiveLocationUpdate(LatLng newLocation) { // 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.: - // _controller.add(StageComplete()) // If the user has reached the end! + // emit(StageComplete()) // If the user has reached the end! } } From 0fb3983fd182a52b470beaeb4cfcbbfb65510875 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:39:11 -0400 Subject: [PATCH 111/121] Fleshed out floor switcher --- android/bluebus_android.iml | 2 +- lib/widgets/floorplan_overlay_widget.dart | 457 +++++++++++++++++++++- 2 files changed, 439 insertions(+), 20 deletions(-) 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/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index 46a3d35..5f20386 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -1,7 +1,14 @@ +// import 'dart:js_interop'; + +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'; const FLOOR_SELECTOR_WIDTH = 50.0; @@ -17,12 +24,14 @@ class FloorSelector extends StatefulWidget { /// 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 @@ -31,6 +40,7 @@ class FloorSelector extends StatefulWidget { class _FloorSelectorState extends State { late int selectedIndex = widget.initialIndex; + late int preselectedIndex = widget.initialIndex; double yDragDistance = 0.0; List get floors => widget.floors; @@ -44,6 +54,7 @@ class _FloorSelectorState extends State { setState(() { yDragDistance = 0; selectedIndex = clamped; + preselectedIndex = selectedIndex; }); if (changed) widget.onFloorSelected(clamped); @@ -82,6 +93,14 @@ class _FloorSelectorState extends State { 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); + } }); @@ -163,6 +182,175 @@ class _FloorSelectorState extends State { } } +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; @@ -180,12 +368,19 @@ class FloorplanOverlay extends StatefulWidget { State createState() => _FloorplanOverlayState(); } -class _FloorplanOverlayState extends State { +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; @@ -193,6 +388,130 @@ class _FloorplanOverlayState extends State { 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 @@ -201,7 +520,7 @@ class _FloorplanOverlayState extends State { await layer.load(); if (!mounted) return; - final List ordered = [...layer.floors] + 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); @@ -210,17 +529,42 @@ class _FloorplanOverlayState extends State { floors = ordered; selectedIndex = activeIndex < 0 ? 0 : activeIndex; loadFinished = true; + + alignedFloors = getAlignedFloors(floors); + scaleFactor = getScaleFactor(); }); } void selectFloor(int index) { + final double oldValue = _floorOffset.value; 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..."; @@ -238,7 +582,7 @@ class _FloorplanOverlayState extends State { decoration: BoxDecoration( // Transparent so the floorplan the map is drawing underneath shows // through -- this overlay is just the chrome around it. - color: Colors.transparent, + color: Color(0xFF0B5394), // gradient: LinearGradient( // begin: Alignment.topLeft, // end: Alignment(0.8, 1), @@ -255,6 +599,63 @@ class _FloorplanOverlayState extends State { // 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 + ) + ) + ), + 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 @@ -274,26 +675,43 @@ class _FloorplanOverlayState extends State { style: IconButton.styleFrom(backgroundColor: Colors.white), // TODO: Make this dynamic for light/dark mode ), SizedBox(width: 10,), - 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, - ), - ) - ) + 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( @@ -310,6 +728,7 @@ class _FloorplanOverlayState extends State { floors: [for (final floor in floors) floor.shortName], initialIndex: selectedIndex, onFloorSelected: selectFloor, + onFloorPreselected: selectFloor, ), From 93937d71d74f51ca116a8c022c8359ebbf6f56f4 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:50:01 -0400 Subject: [PATCH 112/121] Fixed overflowed routes in fancy icons, haptics --- lib/services/map_image_service.dart | 34 +++++++++++++++++++++ lib/widgets/floorplan_overlay_widget.dart | 36 ++++++++++++----------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 9bda233..e4d88ab 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -382,6 +382,25 @@ class MapImageService { // 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, @@ -472,6 +491,21 @@ class MapImageService { 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( diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index 5f20386..bd65f04 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -9,6 +9,7 @@ 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; @@ -535,8 +536,9 @@ class _FloorplanOverlayState extends State with TickerProvider }); } - void selectFloor(int index) { + void selectFloor(int index) async { final double oldValue = _floorOffset.value; + await Haptics.vibrate(HapticsType.medium); setState(() { selectedIndex = index; _floorOffset = Tween(begin: oldValue, end: index.toDouble()) @@ -617,22 +619,22 @@ class _FloorplanOverlayState extends State with TickerProvider 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 - ) - ) - ), + // 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 + // ) + // ) + // ), CustomPaint( size: Size(MediaQuery.sizeOf(context).width + 200, 300), painter: FloorplanPreviewPainter( From 93782f8ef4509b4e1c7c1b1895b4c0293bc1ad49 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:31:02 -0400 Subject: [PATCH 113/121] Fixed floor selection --- lib/screens/map_screen.dart | 4 +++- lib/widgets/floorplan_overlay_widget.dart | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f5aa5ff..84bf250 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -225,7 +225,9 @@ class _MaizeBusCoreState extends State { void onStopClicked(BusStop stop) { try { Haptics.vibrate(HapticsType.light); - } catch (e) {} + } catch (e) { + debugPrint("Haptics error: $e"); + } _showStopSheet( stop.id, diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index bd65f04..4ef0882 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -142,7 +142,6 @@ class _FloorSelectorState extends State { return InkWell( onTap: () { - debugPrint("Clicked!"); snapToIndex(index); // setState(() { // selectedIndex = index; @@ -537,6 +536,7 @@ class _FloorplanOverlayState extends State with TickerProvider } void selectFloor(int index) async { + // _controller.reset(); final double oldValue = _floorOffset.value; await Haptics.vibrate(HapticsType.medium); setState(() { @@ -550,6 +550,21 @@ class _FloorplanOverlayState extends State with TickerProvider 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(() { @@ -730,7 +745,7 @@ class _FloorplanOverlayState extends State with TickerProvider floors: [for (final floor in floors) floor.shortName], initialIndex: selectedIndex, onFloorSelected: selectFloor, - onFloorPreselected: selectFloor, + onFloorPreselected: preselectFloor, ), From cba71448b40f8c6dc87330b4fdd01144a818fe2e Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sat, 29 Aug 2026 12:54:36 -0400 Subject: [PATCH 114/121] Fixed recenter button --- .gitignore | 1 + .metadata | 12 ++++++------ lib/screens/map_screen.dart | 22 +++------------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index ff4106c..6b78d34 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ 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/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f5aa5ff..9463f17 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -83,7 +83,7 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; final ValueNotifier _currentCameraPos = ValueNotifier(null); - bool? _userLocVisible; + bool? _userLocVisible; static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; @@ -1300,20 +1300,6 @@ class _MaizeBusCoreState extends State { void _onCameraIdle() async { // The next camera movement is user-controlled unless a new animation starts. _isProgrammaticCameraMove = false; - - // check if user location is within viewport bounds - LatLngBounds? viewportBounds = await _mapController?.getVisibleRegion(); - if (viewportBounds != null) { - Position? pos = await _getLastKnownLocation(); - if (pos != null) { - if (!mounted) return; - setState(() { - _userLocVisible = !viewportBounds.contains( - LatLng(pos.latitude, pos.longitude), - ); - }); - } - } } void _showBusSheet(String busID) { @@ -2003,9 +1989,7 @@ class _MaizeBusCoreState extends State { builder: (context, userMoved, child) { return AnimatedSwitcher( duration: const Duration(milliseconds: 250), - child: userMoved && - (_userLocVisible == null || - _userLocVisible!) + child: userMoved ? DecoratedBox( decoration: BoxDecoration( boxShadow: [ @@ -2045,7 +2029,7 @@ class _MaizeBusCoreState extends State { ), ), ) - : SizedBox.shrink(), + : const SizedBox.shrink(), ); }, ), From 83e64f436f93b0f15d03526b95200ae199574224 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sat, 29 Aug 2026 12:56:36 -0400 Subject: [PATCH 115/121] Commented out debug logs --- lib/screens/map_screen.dart | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 670110d..07d8cb4 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -424,10 +424,10 @@ class _MaizeBusCoreState extends State { _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( Position p, ) async { - log("Received location update: ${p.latitude}, ${p.longitude}"); + // log("Received location update: ${p.latitude}, ${p.longitude}"); if (isFirstLocationUpdate) { isFirstLocationUpdate = false; - log("Ignoring first location update"); + // log("Ignoring first location update"); return; } @@ -435,14 +435,14 @@ class _MaizeBusCoreState extends State { if (!mounted || _mapController == null) { if (!mounted) log("Ignoring location update: widget not mounted"); if (_mapController == null) - log("Ignoring location update: map controller not initialized"); + // 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"); + // log("Ignoring location update: user has interacted with map"); return; } final lastCentered = _lastCenteredPos; @@ -471,17 +471,15 @@ class _MaizeBusCoreState extends State { ) == 0; if (cameraTarget == null) { - log("null camera target"); + // log("null camera target"); return; } if (shouldMove && !userMoved) { - log("Centering map on new location: ${p.latitude}, ${p.longitude}"); + // 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}", - ); + // log("Ignoring location update: ${p.latitude}, ${p.longitude}"); + // log("Camera Position: ${cameraTarget.latitude}, ${cameraTarget.longitude}",); } if (!(shouldMove && !userMoved)) return; @@ -1293,7 +1291,7 @@ class _MaizeBusCoreState extends State { if (!mounted) return; _currentCameraPos.value = position; if (!_isProgrammaticCameraMove) { - log("noted nonprogrammatic camera move"); + // log("noted nonprogrammatic camera move"); _userHasInteractedWithMap.value = true; } From 772bf3f00efdb769b1151165c632fa1a9de6a581 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:26:29 -0400 Subject: [PATCH 116/121] Fixed stop rotation bug --- lib/services/map_image_service.dart | 57 ++++++++++++++++++- .../map_layers/base_routes_layer.dart | 22 +++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index e4d88ab..33260dc 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -35,6 +35,7 @@ class MapImageService { 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? @@ -427,7 +428,7 @@ class MapImageService { // String cacheKey = rotation.round().toString() + "," + routesServed.join(","); // String cacheKey = routesServed.join(","); // Temporary, for testing - String cacheKey = "${stopId}_$isFavorite"; + String cacheKey = "fancyicon_${stopId}_$isFavorite"; if (_fancyStopIconsCache.containsKey(cacheKey)) { @@ -529,6 +530,60 @@ class MapImageService { 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; diff --git a/lib/services/map_layers/base_routes_layer.dart b/lib/services/map_layers/base_routes_layer.dart index 827b8fe..41ff45d 100644 --- a/lib/services/map_layers/base_routes_layer.dart +++ b/lib/services/map_layers/base_routes_layer.dart @@ -96,6 +96,12 @@ class BaseRoutesLayer extends CompositeMapLayer { 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) {} } @@ -217,16 +223,24 @@ class BaseRoutesLayer extends CompositeMapLayer { routesServed.toList() ) ) : ( - favoriteStops.contains(entry.stop.id) // Used to be isFavorite - ? (entry.stop.isRide ? _favRideStopIcon : _favStopIcon) - : (entry.stop.isRide ? _rideStopIcon : _stopIcon) + + 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: displayFancyIcons ? 0.0 : entry.stop.rotation, + rotation: 0.0, + // rotation: displayFancyIcons ? 0.0 : entry.stop.rotation, anchor: displayFancyIcons ? MapImageService.getFancyStopIconOffset() : Offset(0.5, 0.5), ); From 7011a27bfe210877154eb061a211239b98b52c86 Mon Sep 17 00:00:00 2001 From: iswheeler <22535264+iswheeler@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:41:39 -0400 Subject: [PATCH 117/121] Added floorplan selector rotation/squish --- lib/widgets/floorplan_overlay_widget.dart | 29 +++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/widgets/floorplan_overlay_widget.dart b/lib/widgets/floorplan_overlay_widget.dart index 4ef0882..f55b827 100644 --- a/lib/widgets/floorplan_overlay_widget.dart +++ b/lib/widgets/floorplan_overlay_widget.dart @@ -1,5 +1,6 @@ // import 'dart:js_interop'; +import 'dart:math' as math; import 'dart:ui'; import 'package:bluebus/constants.dart'; @@ -650,16 +651,24 @@ class _FloorplanOverlayState extends State with TickerProvider // ) // ) // ), - 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 - ), - ), + 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 + ), + ), + ) + ) + + ] ) From 0705109cd534b3ee4dbd8130d38a95d1fa6caf1a Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sat, 29 Aug 2026 14:05:38 -0400 Subject: [PATCH 118/121] Added proper receiveLocationUpdate code for navigationmanager --- lib/screens/map_screen.dart | 79 ++-- .../navigation/navigation_manager.dart | 374 ++++++++++-------- 2 files changed, 258 insertions(+), 195 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 07d8cb4..3085221 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:ui' as ui; 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'; @@ -69,8 +70,9 @@ class _MaizeBusCoreState extends State { StreamSubscription? _posSub; // TODO: Follow-mode state. When true, the map recenters on location updates. Position? _lastCenteredPos; - final ValueNotifier _userHasInteractedWithMap = - ValueNotifier(false); + final ValueNotifier _userHasInteractedWithMap = ValueNotifier( + false, + ); bool _isProgrammaticCameraMove = true; bool _followUser = true; @@ -83,7 +85,7 @@ class _MaizeBusCoreState extends State { GoogleMapController? _mapController; final ValueNotifier _currentCameraPos = ValueNotifier(null); - bool? _userLocVisible; + bool? _userLocVisible; static const _defaultCenter = LatLng(42.276463, -83.7374598); static LatLng startLatLng = _defaultCenter; @@ -424,6 +426,7 @@ class _MaizeBusCoreState extends State { _posSub = Geolocator.getPositionStream(locationSettings: settings).listen(( Position p, ) async { + navigationManager.receiveLocationUpdate(p); // log("Received location update: ${p.latitude}, ${p.longitude}"); if (isFirstLocationUpdate) { isFirstLocationUpdate = false; @@ -436,7 +439,7 @@ class _MaizeBusCoreState extends State { if (!mounted) log("Ignoring location update: widget not mounted"); if (_mapController == null) // log("Ignoring location update: map controller not initialized"); - return; + return; } // If follow mode is disabled, don't recenter automatically. @@ -446,7 +449,7 @@ class _MaizeBusCoreState extends State { return; } final lastCentered = _lastCenteredPos; - + final cameraTarget = _currentCameraPos.value?.target; // Only move camera if user has moved more than threshold to avoid jitter. @@ -1294,7 +1297,6 @@ class _MaizeBusCoreState extends State { // log("noted nonprogrammatic camera move"); _userHasInteractedWithMap.value = true; } - } void _onCameraIdle() async { @@ -1917,13 +1919,13 @@ class _MaizeBusCoreState extends State { // face north button is only visible when not facing north Visibility( visible: - _currentCameraPos.value != - null && - _currentCameraPos - .value! - .bearing != - 0, - child: DecoratedBox( + _currentCameraPos.value != + null && + _currentCameraPos + .value! + .bearing != + 0, + child: DecoratedBox( decoration: BoxDecoration( boxShadow: [ BoxShadow( @@ -1988,7 +1990,9 @@ class _MaizeBusCoreState extends State { _userHasInteractedWithMap, builder: (context, userMoved, child) { return AnimatedSwitcher( - duration: const Duration(milliseconds: 250), + duration: const Duration( + milliseconds: 250, + ), child: userMoved ? DecoratedBox( decoration: BoxDecoration( @@ -1996,35 +2000,54 @@ class _MaizeBusCoreState extends State { BoxShadow( color: getColor( context, - ColorType.mapButtonShadow, + ColorType + .mapButtonShadow, ).withAlpha(50), - blurRadius: 4, - offset: Offset(0, 2), + blurRadius: + 4, + offset: + Offset( + 0, + 2, + ), ), ], borderRadius: - BorderRadius.circular(25), + BorderRadius.circular( + 25, + ), ), child: FloatingActionButton.small( onPressed: () { - _setFollowMode(true); - _centerOnLocation(true); + _setFollowMode( + true, + ); + _centerOnLocation( + true, + ); }, - heroTag: 'location_fab', - backgroundColor: getColor( - context, - ColorType.mapButtonSecondary, - ), + heroTag: + 'location_fab', + backgroundColor: + getColor( + context, + ColorType + .mapButtonSecondary, + ), elevation: 0, shape: RoundedRectangleBorder( borderRadius: - BorderRadius.circular(56), + BorderRadius.circular( + 56, + ), ), child: Icon( - Icons.my_location, + Icons + .my_location, color: getColor( context, - ColorType.mapButtonPrimary, + ColorType + .mapButtonPrimary, ), ), ), diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart index 336cc96..5d93054 100644 --- a/lib/services/navigation/navigation_manager.dart +++ b/lib/services/navigation/navigation_manager.dart @@ -2,7 +2,7 @@ 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'; @@ -18,7 +18,7 @@ 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} +enum LineType { Dotted, Dashed } class NavigationStageStep { String title; @@ -32,7 +32,7 @@ class NavigationStageStep { this.subtitle, required this.time, required this.color, - required this.lineType + required this.lineType, }); String getTitle() { @@ -54,7 +54,6 @@ class NavigationStageStep { LineType getLineType() { return lineType; } - } sealed class NavigationStage { @@ -67,12 +66,15 @@ sealed class NavigationStage { 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) - + 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 []; } @@ -89,12 +91,19 @@ sealed class NavigationStage { 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 + 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: @@ -115,39 +124,33 @@ sealed class NavigationStage { void initWithLeg(Leg leg) { // Do cool stuff to set up your Stage with an e.g. walking or bus leg } - - void receiveLocationUpdate(LatLng newLocation) { - // 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! - } - } enum RerouteReason { wrongBus, - walkPathChanged + 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 +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 label; // expanded name + final Color color; final String? busNumber; // 3067 :) BusPromptOption({ - required this.code, - required this.label, - required this.color, - this.busNumber + 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); @@ -176,11 +179,11 @@ class NavOnBusState { List<(int, BusStop)> get stops { final (depIdx, (depPointIdx, _)) = _line.stops.indexed.firstWhere( - (x) => x.$2.$2.id == _departureStop + (x) => x.$2.$2.id == _departureStop, ); final (arrIdx, (arrPointIdx, _)) = _line.stops.indexed - .skip(depIdx) - .firstWhere((x) => x.$2.$2.id == _arrivalStop); + .skip(depIdx) + .firstWhere((x) => x.$2.$2.id == _arrivalStop); return _line.stops .sublist(depIdx, arrIdx + 1) .map( @@ -193,11 +196,11 @@ class NavOnBusState { List get points { final (depIdx, (depPointIdx, _)) = _line.stops.indexed.firstWhere( - (x) => x.$2.$2.id == _departureStop + (x) => x.$2.$2.id == _departureStop, ); final (arrIdx, (arrPointIdx, _)) = _line.stops.indexed - .skip(depIdx) - .firstWhere((x) => x.$2.$2.id == _arrivalStop); + .skip(depIdx) + .firstWhere((x) => x.$2.$2.id == _arrivalStop); return _line.points.sublist(depPointIdx, arrPointIdx + 1); } @@ -286,8 +289,8 @@ class NavOnBus extends NavigationStage { } @override - void receiveLocationUpdate(LatLng newLocation) { - lastPosition = newLocation; + void receiveLocationUpdate(Position p) { + lastPosition = LatLng(p.latitude, p.longitude); // TODO: determine if stage is over } @@ -365,7 +368,9 @@ class NavOnBus extends NavigationStage { final pos = lastPosition; if (pos == null) return 0; // project lastPosition onto polyline - final (idx, _) = pos.nearestPolylineIndexAndDistanceContinuous(state.points); + final (idx, _) = pos.nearestPolylineIndexAndDistanceContinuous( + state.points, + ); // return how many stops were passed return state.stops.takeWhile((x) => x.$1 <= idx).length - 1; } @@ -424,24 +429,30 @@ class NavOnBus extends NavigationStage { } } -typedef Edge = ({ BusStop from, BusStop to, List points }); -typedef AdjacencyEntry = ({ BusStop from, Set<({ BusStop stop, List points })> tos }); +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 + 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; + 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 + final Map adjacency = {}; // for stpids // make the adjacency structure ... for (final line in candidates) { (int, BusStop)? prev; @@ -449,8 +460,10 @@ BusRouteLine? determineRouteOfBusLeg( 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))); + adjacency + .putIfAbsent(prevStop.id, () => (from: prevStop, tos: Set())) + .tos + .add((stop: stop, points: line.points.sublist(prevIdx, i + 1))); } prev = (i, stop); } @@ -473,8 +486,10 @@ BusRouteLine? determineRouteOfBusLeg( for (final entry in neighbors.tos) { queue.addLast(( entry.stop.id, - edges.followedBy([(from: neighbors.from, to: entry.stop, points: entry.points)]).toList() - )); + edges.followedBy([ + (from: neighbors.from, to: entry.stop, points: entry.points), + ]).toList(), + )); } } } @@ -487,9 +502,9 @@ BusRouteLine? determineRouteOfBusLeg( for (final e in edges) { points.removeLast(); points.addAll(e.points); - stops.add((points.length - 1, e.to)); + stops.add((points.length - 1, e.to)); } - + return BusRouteLine( points: points, stops: stops, @@ -499,19 +514,19 @@ BusRouteLine? determineRouteOfBusLeg( ); } -class ChooseBus extends NavigationStage{ +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. + //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, + // 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 + // This could be simplified more, probably by picking up data from another function } // oops stage // TODOs: MOVING TO NAVIGATION MANAGER -// class MissedBus extends NavigationStage { +// class MissedBus extends NavigationStage { // // using the new title information method // @override // String getTitle() { @@ -519,53 +534,58 @@ class ChooseBus extends NavigationStage{ // return "Oops!"; // } -// // information for the popup +// // information for the popup // @override -// String getSubtitle() { -// // Looks like these are for pop-ups, so maybe this can be part of a user prompt? +// 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_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.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. +//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) + 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.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) + 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 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) + + 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); + math.cos(b.latitude * math.pi / 180) * + math.sin(dLon / 2) * + math.sin(dLon / 2); return 2 * R * math.asin(math.sqrt(s)); } @@ -574,19 +594,23 @@ class Walking extends NavigationStage { 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); + 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. + 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(LatLng newLocation) { - currWalkingPos = newLocation; - + 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) { @@ -618,7 +642,8 @@ class Walking extends NavigationStage { // 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 + if (currWalkingPos != null) { + // if GPS is broken/off, use distance from _nextIndex to the destination distLeft = _distMeters(currWalkingPos!, points[_nextIndex]); } // calculate remaining walking distance @@ -697,17 +722,12 @@ class Walking extends NavigationStage { // PatternItem.dash(30), // Longer dashes PatternItem.gap(15), // Longer gaps ], - ) + ), ]; - } - - } - class DemoStage extends NavigationStage { - String getTitle() { return "This is a demo! #$favoriteNumber"; } @@ -733,11 +753,12 @@ class DemoStage extends NavigationStage { required this.startPoint, required this.endPoint, required this.color, - required this.lineType + required this.lineType, }); @override - Color getColor() { // Return a random color + Color getColor() { + // Return a random color // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber return color; } @@ -746,26 +767,30 @@ class DemoStage extends NavigationStage { List getMarkers() { return [ Marker( - markerId: MarkerId("${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}"), - position: this.startPoint + 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 - ) + 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() - ) + polylineId: PolylineId( + "${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}", + ), + points: [this.startPoint, this.endPoint], + color: this.getColor(), + ), ]; } @@ -777,15 +802,17 @@ class DemoStage extends NavigationStage { time: '1:23 AM', color: getColor(), // Use the stage's color in our demo // lineType: LineType.Dashed, - lineType: this.lineType + 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", + 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 + lineType: this.lineType, ), NavigationStageStep( title: "Step 3", @@ -793,8 +820,8 @@ class DemoStage extends NavigationStage { time: '7:89 AM', color: getColor(), // Use the stage's color in our demo // lineType: LineType.Dashed, - lineType: this.lineType - ) + lineType: this.lineType, + ), ]; // Get navigation stage steps } @@ -804,7 +831,8 @@ class DemoStage extends NavigationStage { final _eventController = StreamController(); - Stream get events => _eventController.stream; // This is so the NavigationController can do yourStage.events and access your event controller + 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)) @@ -821,14 +849,14 @@ class DemoStage extends NavigationStage { // Do cool stuff to set up your Stage with an e.g. walking or bus leg } - void receiveLocationUpdate(LatLng newLocation) { + 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 { +class TimelineStep { double estimated_time; double percentage; Color color; @@ -836,18 +864,18 @@ class TimelineStep { TimelineStep({ required this.estimated_time, required this.percentage, // Percentage of the entire progress bar occupied by this timeline step - required this.color + 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 + 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 + this.activePositionPercentage = 0.0, }) : timelineSteps = timelineSteps ?? []; - } class NavigationManager { @@ -856,39 +884,37 @@ class NavigationManager { 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 + 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; @@ -903,6 +929,13 @@ class NavigationManager { } } + 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(); @@ -920,9 +953,9 @@ class NavigationManager { _stageEventSub = stage.events.listen((event) { switch (event) { case StageComplete(): - // Move on to the next stage + // Move on to the next stage case StageReroute(:final reason): - // Handle the reroute + // Handle the reroute } }); } @@ -933,15 +966,14 @@ class NavigationManager { } 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 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; @@ -949,27 +981,30 @@ class NavigationManager { if (i < currentStage) { activePositionTime = activePositionTime + currentStageLength; } else if (i == currentStage) { - activePositionTime += currentStageLength * stageList[i].percent_complete; + 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 - ) + 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); - + return TimelineInfo( + timelineSteps: timelineSteps, + activePositionPercentage: activePositionPercentage, + ); } // Some way for the navigation widget to @@ -985,20 +1020,26 @@ class NavigationManager { // 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 + 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!"); + 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(); + 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() { @@ -1016,7 +1057,6 @@ class NavigationManager { } void initFromJourney(Journey journey, Color walkingLineColor) { - this.stageList.clear(); for (Leg leg in journey.legs) { @@ -1024,7 +1064,9 @@ class NavigationManager { 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.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) { @@ -1048,12 +1090,10 @@ class NavigationManager { _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 @@ -1063,4 +1103,4 @@ 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 \ No newline at end of file +// TODO: Call dispose() on stages as they are removed From 4bc72e2bec5633ffb0c8821a3e8293e169498009 Mon Sep 17 00:00:00 2001 From: Gustavo Rodriguez Date: Sat, 29 Aug 2026 16:29:32 -0400 Subject: [PATCH 119/121] Correct Get off Bus icon in nav --- lib/screens/map_screen.dart | 2 +- lib/services/map_image_service.dart | 223 ++++++++++++++++++++-------- 2 files changed, 160 insertions(+), 65 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3085221..9a0d3a1 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -964,7 +964,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(() {}); diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart index 33260dc..505f1b2 100644 --- a/lib/services/map_image_service.dart +++ b/lib/services/map_image_service.dart @@ -23,9 +23,18 @@ 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 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; @@ -34,12 +43,12 @@ class MapImageService { 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 _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, ); @@ -49,19 +58,21 @@ class MapImageService { static BitmapDescriptor favStopIcon = BitmapDescriptor.defaultMarkerWithHue( BitmapDescriptor.hueAzure, ); - static BitmapDescriptor favRideStopIcon = 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; @@ -246,21 +257,29 @@ class MapImageService { _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'); + _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!); + 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 @@ -305,7 +324,8 @@ class MapImageService { _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 + + // 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; @@ -349,7 +369,15 @@ class MapImageService { return frame.image; } - static void drawRouteIconOntoCanvas(Canvas canvas, int x, int y, int width, int height, String routeId, bool isRide) { + 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; @@ -361,29 +389,51 @@ class MapImageService { fontSize: width / 2, fontWeight: FontWeight.w900, letterSpacing: -1, - fontFamily: 'Urbanist' - ) + fontFamily: 'Urbanist', + ), ), textAlign: TextAlign.center, - textDirection: TextDirection.ltr + 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), + 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); + 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( + 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) { + static void drawRouteOverflowIconOntoCanvas( + Canvas canvas, + int x, + int y, + int width, + int height, + int numOverflowed, + ) { final textPainter = TextPainter( text: TextSpan( text: "+$numOverflowed", @@ -391,15 +441,20 @@ class MapImageService { fontSize: width * 0.65, fontWeight: FontWeight.w600, letterSpacing: -1, - fontFamily: 'Urbanist' - ) + fontFamily: 'Urbanist', + ), ), textAlign: TextAlign.center, - textDirection: TextDirection.ltr + 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))); - + textPainter.paint( + canvas, + Offset( + x + width / 2 - (textPainter.width / 2), + y + height / 2 - (textPainter.height / 2), + ), + ); } static void drawRotatedImage( @@ -413,7 +468,10 @@ class MapImageService { canvas.rotate(angleRadians); canvas.drawImage( image, - Offset(-image.width / 2, -image.height / 2), // shift so `center` is the pivot + Offset( + -image.width / 2, + -image.height / 2, + ), // shift so `center` is the pivot Paint(), ); canvas.restore(); @@ -421,16 +479,22 @@ class MapImageService { 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 + // 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]!; } @@ -449,25 +513,26 @@ class MapImageService { ..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); + 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) + 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 @@ -475,26 +540,35 @@ class MapImageService { // 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 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; + xDrawPos = + FANCY_STOP_ICON_XHEADROOM + + STOP_ICON_WIDTH + + FANCY_STOP_ICON_MARGIN; } if (i == 5 && routesServed.length > 6) { - // if (i == 5) { + // if (i == 5) { // We're on the last element and there will be overflow debugPrint("DRAWING OVERFLOW ICON!! $stopId"); drawRouteOverflowIconOntoCanvas( @@ -503,11 +577,11 @@ class MapImageService { yDrawPos, ROW_ICON_SIZE.toInt(), ROW_ICON_SIZE.toInt(), - (routesServed.length - 6) + 1 + (routesServed.length - 6) + 1, ); break; } - + String routeId = routesServed[i]; drawRouteIconOntoCanvas( canvas, @@ -516,27 +590,36 @@ class MapImageService { ROW_ICON_SIZE.toInt(), // width ROW_ICON_SIZE.toInt(), // height routeId, - isRide); + 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 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()); + BitmapDescriptor output = BitmapDescriptor.fromBytes( + byteData!.buffer.asUint8List(), + ); _fancyStopIconsCache[cacheKey] = output; return output; } - static Future getNormalStopIcon(String stopId, bool isFavorite, bool isRide, double rotation) async { - + 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]!; } @@ -557,17 +640,18 @@ class MapImageService { await _loadStopIcons(); } - ui.Image? targetImage = isFavorite ? - (isRide ? _favRideStopIconImage : _favStopIconImage) : - (isRide ? _rideStopIconImage : _stopIconImage); + 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) + (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 @@ -576,22 +660,33 @@ class MapImageService { // 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 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()); + 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 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(); From a1aa23fb12a8e3f241ca9030cebc64ffbc9f7ade Mon Sep 17 00:00:00 2001 From: Static Date: Sat, 29 Aug 2026 19:06:29 -0400 Subject: [PATCH 120/121] fixed bus stop arrow direction --- lib/bluebus_api.dart | 44 ++--------------------------------------- lib/theride_api.dart | 43 ++-------------------------------------- lib/utils/geometry.dart | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 83 deletions(-) diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index 2fe35a9..eeac445 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -34,7 +34,6 @@ class BlueBusApi { 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, @@ -42,25 +41,7 @@ class BlueBusApi { ), ); if (point['typ'] == 'S') { - // get rotation of stop - double stopRotation; - if (isLast) { - // use the previous 2 points to calculate rotation - 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, - ); - } else { - // use the next 2 points to calculate rotation - 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, - ); - } + final stopRotation = routeStopRotation(pointList, i); stops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } @@ -89,8 +70,6 @@ class BlueBusApi { 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, @@ -98,26 +77,7 @@ class BlueBusApi { ), ); if (point['typ'] == 'S') { - // get rotation of stop - double stopRotation; - if (isLast) { - // use the previous 2 points to calculate rotation - 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, - ); - - } else { - // use the next 2 points to calculate rotation - 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, - ); - } + final stopRotation = routeStopRotation(detourPointList, i); detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, false))); } } diff --git a/lib/theride_api.dart b/lib/theride_api.dart index eac07a8..8f9b7e7 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -32,7 +32,6 @@ class RideAPI { 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, @@ -40,25 +39,7 @@ class RideAPI { ), ); if (point['typ'] == 'S') { - // get rotation of stop - double stopRotation; - if (isLast) { - // use the previous 2 points to calculate rotation - 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, - ); - } else { - // use the next 2 points to calculate rotation - 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, - ); - } + final stopRotation = routeStopRotation(pointList, i); stops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } @@ -88,8 +69,6 @@ class RideAPI { 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, @@ -97,25 +76,7 @@ class RideAPI { ), ); if (point['typ'] == 'S') { - // get rotation of stop - double stopRotation; - if (isLast) { - // use the previous 2 points to calculate rotation - 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, - ); - } else { - // use the next 2 points to calculate rotation - 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, - ); - } + final stopRotation = routeStopRotation(detourPointList, i); detourStops.add((i, BusStop.fromJson(point, routeId, stopRotation, true))); } } diff --git a/lib/utils/geometry.dart b/lib/utils/geometry.dart index 7d2bd9a..f261710 100644 --- a/lib/utils/geometry.dart +++ b/lib/utils/geometry.dart @@ -21,6 +21,45 @@ double pointRotation(double lat1, double lon1, double lat2, double lon2) { 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; From fe8e4d1a89b743ebc83ffe5cdcf01f32d09afa75 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Sat, 29 Aug 2026 16:36:02 -0400 Subject: [PATCH 121/121] added demo building outlines --- assets/floorplans/demoPolygons.csv | 8 + ios/Runner.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 122 +++++++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++ .../xcshareddata/swiftpm/Package.resolved | 122 +++++++++++ lib/screens/map_screen.dart | 3 + .../map_layers/demo_buildings_layer.dart | 204 ++++++++++++++++++ 7 files changed, 499 insertions(+) create mode 100644 assets/floorplans/demoPolygons.csv create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 lib/services/map_layers/demo_buildings_layer.dart 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/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 JourneyLayer journeyLayer = JourneyLayer(); final NavigationLayer navigationLayer = NavigationLayer(); final FloorplansLayer floorplansLayer = FloorplansLayer(); + final DemoBuildingsLayer demoBuildingsLayer = DemoBuildingsLayer(); // DEMO BUILDINGS // GoogleMaps styles String _darkMapStyle = "{}"; @@ -1615,6 +1617,7 @@ class _MaizeBusCoreState extends State { child: CompositeMapWidget( initialCenter: startLatLng, mapLayers: [ + demoBuildingsLayer, // DEMO BUILDINGS floorplansLayer, baseRoutesLayer, liveBusesLayer, 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; + } +}