From 750834c37a9f07db1e230df6e7cd4fa1554d6c46 Mon Sep 17 00:00:00 2001 From: Ishan Kumar Date: Thu, 19 Mar 2026 17:12:55 -0400 Subject: [PATCH 01/85] 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 02/85] 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 03/85] 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 04/85] 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 05/85] 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 06/85] 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 07/85] 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 08/85] 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 09/85] 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 10/85] 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 11/85] 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 12/85] 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 13/85] 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 14/85] 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 15/85] 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 16/85] 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 17/85] 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 18/85] 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 19/85] 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 20/85] 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 21/85] 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 22/85] 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 23/85] 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 24/85] 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 25/85] 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 26/85] 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 27/85] 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 28/85] 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 29/85] 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 30/85] 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 31/85] 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 32/85] 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 33/85] 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 34/85] 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 35/85] 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 36/85] 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 37/85] 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 38/85] 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 39/85] 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 40/85] 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 41/85] 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 42/85] 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 43/85] 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 44/85] 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 45/85] 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 46/85] 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 47/85] 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 48/85] 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 49/85] 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 50/85] 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 51/85] 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 52/85] 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 53/85] 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 54/85] 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 55/85] 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 56/85] 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 57/85] 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 58/85] 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 59/85] 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 60/85] 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 61/85] 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 62/85] 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 63/85] 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 64/85] 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 65/85] 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 66/85] 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 67/85] 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 68/85] 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 69/85] 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 70/85] 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 71/85] 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 72/85] 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 73/85] 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 74/85] 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 75/85] 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 76/85] 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 77/85] 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 78/85] 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 79/85] 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 80/85] 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 81/85] 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 82/85] 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 83/85] 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 84/85] 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 85/85] 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; + } }