diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4cc0d1f..85ea8ec 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,7 +29,7 @@ require(flutter.compileSdkVersion >= 35); android { namespace = "com.ishankumar.maizebus" compileSdk = flutter.compileSdkVersion - ndkVersion = "28.1.13356709" + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -38,9 +38,7 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } + // REMOVED compilerOptions from here because it was causing "Unresolved reference" defaultConfig { applicationId = "com.ishankumar.maizebus" @@ -73,15 +71,18 @@ android { } } +// BULLETPROOF FIX: Configure Kotlin compiler tasks directly at the bottom of the file +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { - // the following two might be needed if issues happen - // https://pub.dev/packages/flutter_local_notifications#-android-setup - // implementation("androidx.window:window:1.0.0") - // implementation("androidx.window:window-java:1.0.0") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") implementation(platform("com.google.firebase:firebase-bom:34.6.0")) } flutter { source = "../.." -} +} \ No newline at end of file diff --git a/android/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/assets/destination.png b/assets/destination.png new file mode 100644 index 0000000..a4eb084 Binary files /dev/null and b/assets/destination.png differ diff --git a/assets/start.png b/assets/start.png new file mode 100644 index 0000000..fa26cd9 Binary files /dev/null and b/assets/start.png differ diff --git a/lib/bluebus_api.dart b/lib/bluebus_api.dart index e30e6c7..2fe35a9 100644 --- a/lib/bluebus_api.dart +++ b/lib/bluebus_api.dart @@ -8,28 +8,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; @@ -48,7 +27,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; @@ -64,27 +43,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))); } } @@ -105,7 +82,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; @@ -122,26 +99,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))); } } @@ -166,7 +143,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 +170,11 @@ class BlueBusApi { } return buses; - } catch (e){ - + } catch (e) { // on error return a blank list return []; } } } + +// TODO: Make bus routes have better fallback, so if one route fails to be processed it doesn't tank the rest of them diff --git a/lib/constants.dart b/lib/constants.dart index 9d2d9e6..79dbd80 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -5,13 +5,15 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; final String currentVersion = '2.0.2'; bool isCurrentVersionEqualOrHigher(String otherVersion) { - final List currentParts = - currentVersion.split('.').map(int.parse).toList(); - final List otherParts = - otherVersion.split('.').map(int.parse).toList(); + final List currentParts = currentVersion + .split('.') + .map(int.parse) + .toList(); + final List otherParts = otherVersion.split('.').map(int.parse).toList(); - final int length = - (currentParts.length < otherParts.length) ? currentParts.length : otherParts.length; + final int length = (currentParts.length < otherParts.length) + ? currentParts.length + : otherParts.length; for (int i = 0; i < length; i++) { if (currentParts[i] > otherParts[i]) { @@ -26,10 +28,10 @@ bool isCurrentVersionEqualOrHigher(String otherVersion) { } // Backend url for the api -// const String BACKEND_URL = 'https://mbus-310c2b44573c.herokuapp.com/mbus/api/v3'; +// const String BACKEND_URL = 'https://mbus-310c2b44573c.herokuapp.com/mbus/api/v3'; const String BACKEND_URL = String.fromEnvironment( 'BACKEND_URL', - defaultValue: 'https://busapi.maizebus.com/mbus/api/v3' + defaultValue: 'https://busapi.maizebus.com/mbus/api/v3', ); //const String BACKEND_URL = String.fromEnvironment('BACKEND_URL', defaultValue: 'https://www.efeakinci.host/mbus/api/v3'); //const String BACKEND_URL = String.fromEnvironment("BACKEND_URL", defaultValue: "http://10.0.2.2:3000/mbus/api/v3/"); @@ -50,6 +52,16 @@ const Map fallback_code_to_name = { 'NES': 'North-East Shuttle', }; +final _whitespacePattern = RegExp(r'\s+'); + +String normalizeStopName(String rawStopName) { + // Remove random characters (add them to list if needed), collapse whitespace to a single space, and trim edges. + return rawStopName + .replaceAll('%', '') + .replaceAll(_whitespacePattern, ' ') + .trim(); +} + String getPrettyRouteName(String code) { for (Map route in globalAvailableRoutes) { if (route['id'] == code) { @@ -71,24 +83,40 @@ const Color maizeBusBlueDarkMode = Color.fromARGB(255, 80, 150, 210); const Color maizeBusBlue = Color.fromARGB(255, 11, 83, 148); enum ColorType { - primary, secondary, opposite, background, backgroundGradientStart, - - mapButtonPrimary, mapButtonSecondary, - mapButtonIcon, mapButtonShadow, - - inputBackground, inputText, - - highlighted, dim, error, + primary, + secondary, + opposite, + background, + backgroundGradientStart, + + mapButtonPrimary, + mapButtonSecondary, + mapButtonIcon, + mapButtonShadow, + + inputBackground, + inputText, + + highlighted, + dim, + error, shadow, - - sliderBackground, sliderButton, + + sliderBackground, + sliderButton, // info card colors (in route selector, favorites sheet, etc.) - infoCardColor, infoCardHighlighted, + infoCardColor, + infoCardHighlighted, // all the buttons except for the main map buttons - importantButtonBackground, importantButtonText, - secondaryButtonBackground, secondaryButtonText, + importantButtonBackground, + importantButtonText, + secondaryButtonBackground, + secondaryButtonText, + + mapWalkingLine, // Color for the walking line on the map + navigationStepsGray } const Map lightColors = { @@ -96,24 +124,29 @@ const Map lightColors = { ColorType.secondary: Color.fromARGB(255, 226, 231, 236), ColorType.opposite: Colors.black, ColorType.background: Colors.white, - ColorType.backgroundGradientStart: Color.fromARGB(0, 255, 255, 255), // same as background but transparent - - ColorType.mapButtonPrimary: maizeBusBlue, + ColorType.backgroundGradientStart: Color.fromARGB( + 0, + 255, + 255, + 255, + ), // same as background but transparent + + ColorType.mapButtonPrimary: Color.fromARGB(255, 11, 83, 148), ColorType.mapButtonSecondary: Color.fromARGB(190, 255, 255, 255), ColorType.mapButtonIcon: Colors.white, - ColorType.mapButtonShadow: Color.fromARGB(77, 133, 133, 133), + ColorType.mapButtonShadow: Color.fromARGB(77, 133, 133, 133), ColorType.highlighted: Color.fromARGB(255, 120, 192, 255), ColorType.dim: Color.fromARGB(255, 215, 228, 241), ColorType.error: Color.fromARGB(255, 242, 41, 41), ColorType.shadow: Color.fromARGB(95, 187, 187, 187), - + ColorType.sliderButton: Colors.white, - ColorType.sliderBackground: Color.fromARGB(255, 200, 228, 255), + ColorType.sliderBackground: Color.fromARGB(255, 200, 228, 255), - ColorType.infoCardColor: Color.fromARGB(255, 255, 255, 255), - ColorType.infoCardHighlighted: Color.fromARGB(255, 200, 228, 255), + ColorType.infoCardColor: Color.fromARGB(255, 255, 255, 255), + ColorType.infoCardHighlighted: Color.fromARGB(255, 200, 228, 255), ColorType.inputBackground: Color.fromARGB(255, 227, 227, 227), ColorType.inputText: Colors.black, @@ -122,6 +155,9 @@ const Map lightColors = { ColorType.importantButtonText: Colors.white, ColorType.secondaryButtonBackground: Color.fromARGB(255, 215, 228, 241), ColorType.secondaryButtonText: maizeBusBlue, + + ColorType.mapWalkingLine: Color.fromARGB(255, 7, 55, 97), + ColorType.navigationStepsGray: Color.fromARGB(255, 217, 217, 217) }; const Map darkColors = { @@ -129,32 +165,40 @@ const Map darkColors = { ColorType.secondary: Color.fromARGB(255, 40, 54, 72), ColorType.opposite: Colors.white, ColorType.background: Color.fromARGB(255, 32, 33, 34), - ColorType.backgroundGradientStart: Color.fromARGB(0, 32, 33, 34), // same as background but transparent + ColorType.backgroundGradientStart: Color.fromARGB( + 0, + 32, + 33, + 34, + ), // same as background but transparent ColorType.mapButtonPrimary: Color.fromARGB(255, 255, 255, 255), ColorType.mapButtonSecondary: Color.fromARGB(187, 104, 104, 134), ColorType.mapButtonIcon: maizeBusBlue, - ColorType.mapButtonShadow: Color.fromARGB(95, 68, 68, 68), + ColorType.mapButtonShadow: Color.fromARGB(95, 68, 68, 68), ColorType.highlighted: Color.fromARGB(255, 49, 129, 199), ColorType.dim: Color.fromARGB(255, 47, 54, 60), ColorType.error: Color.fromARGB(255, 255, 114, 114), ColorType.shadow: Color.fromARGB(95, 68, 68, 68), - + ColorType.sliderButton: Color.fromARGB(255, 32, 33, 34), ColorType.sliderBackground: Color.fromARGB(255, 33, 71, 105), ColorType.infoCardColor: Color.fromARGB(255, 47, 54, 60), ColorType.infoCardHighlighted: Color.fromARGB(255, 33, 71, 105), - ColorType.inputBackground:Color.fromARGB(255, 47, 54, 60), + ColorType.inputBackground: Color.fromARGB(255, 47, 54, 60), ColorType.inputText: Colors.white, ColorType.importantButtonBackground: Color.fromARGB(255, 49, 129, 199), ColorType.importantButtonText: Colors.white, ColorType.secondaryButtonBackground: Color.fromARGB(255, 47, 54, 60), ColorType.secondaryButtonText: Color.fromARGB(255, 49, 129, 199), + + ColorType.mapWalkingLine: Color.fromARGB(255, 178, 219, 255), + ColorType.navigationStepsGray: Color.fromARGB(255, 93, 93, 93) }; // returns true if the current theme is dark mode @@ -171,6 +215,12 @@ Color getColor(BuildContext context, ColorType type) { return isDarkMode(context) ? darkColors[type]! : lightColors[type]!; } +/// Convert a Color to a BitmapDescriptor hue value +double colorToHue(Color color) { + final hsl = HSLColor.fromColor(color); + return hsl.hue; +} + BoxShadow infoCardShadowLight = BoxShadow( color: Color.fromARGB(80, 38, 114, 181), blurRadius: 5, @@ -184,7 +234,7 @@ BoxShadow infoCardShadowDark = BoxShadow( offset: Offset(0, 3), ); -// Gets the correct shadow depending on +// Gets the correct shadow depending on BoxShadow getInfoCardShadow(BuildContext context) { return isDarkMode(context) ? infoCardShadowDark : infoCardShadowLight; } @@ -199,12 +249,12 @@ Color getGradientLerpColor(BuildContext context, double percentage) { return Color.lerp( getColor(context, ColorType.backgroundGradientStart), getColor(context, ColorType.background), - percentage + percentage, )!; } LinearGradient getStopHeroImageGradient(BuildContext context) { - // A slightly smoother gradient than sRGB + // A slightly smoother gradient than sRGB return LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -213,13 +263,13 @@ LinearGradient getStopHeroImageGradient(BuildContext context) { getGradientLerpColor(context, 0.15), getGradientLerpColor(context, 0.5), getGradientLerpColor(context, 0.75), - getGradientLerpColor(context, 1) + getGradientLerpColor(context, 1), ], // original stops by Isaac // stops: [0.6, 0.65, 0.74, 0.85, 1] // adjusted stops by Ishan - using the same ratios but less tall - stops: [0.67, 0.71, 0.79, 0.88, 1] + stops: [0.67, 0.71, 0.79, 0.88, 1], ); } @@ -229,38 +279,39 @@ class TrapezoidClip extends CustomClipper { @override Path getClip(Size size) { Path path = Path(); - path.lineTo(size.width, 0); - path.lineTo(size.width - size.height, size.height); + path.lineTo(size.width, 0); + path.lineTo(size.width - size.height, size.height); path.lineTo(0, size.height); - path.close(); - return path; + path.close(); + return path; } + @override bool shouldReclip(CustomClipper oldClipper) { - return false; + return false; } } + class TrapezoidClipReversed extends CustomClipper { @override Path getClip(Size size) { Path path = Path(); - path.moveTo(size.width, 0); - path.lineTo(size.width, size.height); + path.moveTo(size.width, 0); + path.lineTo(size.width, size.height); path.lineTo(0, size.height); path.lineTo(size.height, 0); - path.close(); - return path; + path.close(); + return path; } + @override bool shouldReclip(CustomClipper oldClipper) { - return false; + return false; } } // TEXT -enum TextType { - modalHeader, logo, bold, normal, small, sectionHeader -} +enum TextType { modalHeader, logo, bold, normal, small, sectionHeader } TextStyle getTextStyle(TextType type, Color? color) { double size, height; @@ -291,7 +342,13 @@ TextStyle getTextStyle(TextType type, Color? color) { weight = FontWeight.w700; height = 26.4; } - return TextStyle(color: color, fontFamily: 'Urbanist', fontSize: size, fontWeight: weight, height: height / size); + return TextStyle( + color: color, + fontFamily: 'Urbanist', + fontSize: size, + fontWeight: weight, + height: height / size, + ); } // THEMES @@ -304,15 +361,13 @@ ThemeData lightMode = ThemeData( // Default button themes floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: lightColors[ColorType.mapButtonPrimary], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(56), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(56)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: lightColors[ColorType.mapButtonPrimary], - ) + ), ), dividerTheme: DividerThemeData( @@ -322,43 +377,35 @@ ThemeData lightMode = ThemeData( // set default text color textTheme: TextTheme( - bodyMedium: TextStyle( - color: Colors.black, - fontFamily: 'Urbanist' - ) - ) + bodyMedium: TextStyle(color: Colors.black, fontFamily: 'Urbanist'), + ), ); ThemeData darkMode = ThemeData( brightness: Brightness.dark, fontFamily: 'Urbanist', - + // Default button themes floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: darkColors[ColorType.mapButtonPrimary], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(56), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(56)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: darkColors[ColorType.mapButtonPrimary], - ) + ), ), - + dividerTheme: DividerThemeData( thickness: 2, color: darkColors[ColorType.dim], ), - + // set default text color textTheme: TextTheme( - bodyMedium: TextStyle( - color: Colors.white, - fontFamily: 'Urbanist' - ) - ) + bodyMedium: TextStyle(color: Colors.white, fontFamily: 'Urbanist'), + ), ); //data types @@ -387,17 +434,15 @@ class Location { class ArrivalTimeLocation extends Location { final String arrivalTime; - ArrivalTimeLocation( - this.arrivalTime, - Location loc, - ) : super( - loc.name, - loc.abbrev, - loc.aliases, - loc.isBusStop, - stopId: loc.stopId, - latlng: loc.latlng, - ); + ArrivalTimeLocation(this.arrivalTime, Location loc) + : super( + loc.name, + loc.abbrev, + loc.aliases, + loc.isBusStop, + stopId: loc.stopId, + latlng: loc.latlng, + ); } class StartupDataHolder { @@ -406,7 +451,13 @@ class StartupDataHolder { String updateMessage; String persistantMessageTitle; String persistantMessage; - StartupDataHolder(this.version, this.updateTitle, this.updateMessage, this.persistantMessageTitle, this.persistantMessage); + StartupDataHolder( + this.version, + this.updateTitle, + this.updateMessage, + this.persistantMessageTitle, + this.persistantMessage, + ); } class Loadpoint { @@ -417,10 +468,7 @@ class Loadpoint { const SheetBoxShadow = BoxShadow( color: Color.fromRGBO(0, 0, 0, 0.2), - offset: const Offset( - 0.0, - 0.0, - ), + offset: const Offset(0.0, 0.0), blurRadius: 100.0, spreadRadius: 40.0, ); diff --git a/lib/globals.dart b/lib/globals.dart index 87e317a..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/main.dart b/lib/main.dart index 4d99735..cd3ca4f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'screens/onboarding_screen.dart'; import 'services/bus_repository.dart'; import 'providers/bus_provider.dart'; import 'providers/theme_provider.dart'; +import 'package:flutter/services.dart'; + // This function initializes the Flutter app and runs the MainApp widget void main() async { @@ -15,6 +17,15 @@ void main() async { await NotificationService.initPlugin(); await IncomingBusReminderService.start(); + // make navigation bar transparent. Looks really nice on Android + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + ), + ); + // make flutter draw behind navigation bar + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + runApp( MultiProvider( providers: [ @@ -40,6 +51,7 @@ class MainApp extends StatelessWidget { return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, ), child: Consumer( // rebuilds when ThemeProvider changes builder: (context, themeObj, child) => MaterialApp( diff --git a/lib/models/bus_route_line.dart b/lib/models/bus_route_line.dart index 0bdee50..ee3266c 100644 --- a/lib/models/bus_route_line.dart +++ b/lib/models/bus_route_line.dart @@ -5,15 +5,18 @@ import 'bus_stop.dart'; class BusRouteLine { final String routeId; final List points; - final List stops; + + /// bus stops along with the index of the associated point + // INVARIANT: indicies are in ascending order + final List<(int, BusStop)> stops; final Color? color; final String? imageUrl; BusRouteLine({ - required this.routeId, - required this.points, + required this.routeId, + required this.points, required this.stops, this.color, this.imageUrl, }); -} \ No newline at end of file +} diff --git a/lib/models/bus_stop.dart b/lib/models/bus_stop.dart index 6bad99d..5e46c0a 100644 --- a/lib/models/bus_stop.dart +++ b/lib/models/bus_stop.dart @@ -1,4 +1,5 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; +import '../constants.dart'; class BusStop { final String id; @@ -13,7 +14,7 @@ class BusStop { factory BusStop.fromJson(Map json, String routeId, double rotation, bool isRide) { return BusStop( id: json['stpid'] ?? '', - name: json['stpnm'] ?? '', + name: normalizeStopName(json['stpnm'] ?? ''), location: LatLng(json['lat']?.toDouble() ?? 0, json['lon']?.toDouble() ?? 0), routeId: routeId, rotation: rotation, @@ -33,7 +34,7 @@ class BusStopWithPrediction { factory BusStopWithPrediction.fromJson(Map json) { return BusStopWithPrediction( id: json['stpid'] ?? '', - name: json['stpnm'] ?? '', + name: normalizeStopName(json['stpnm'] ?? ''), prediction: json['prdctdn'] as String, busRouteCode: json['rt'] ?? '' ); @@ -52,10 +53,10 @@ class BusWithPrediction { factory BusWithPrediction.fromJson(Map json) { return BusWithPrediction( id: json['rt'] ?? '', - destination: json['des'] ?? '', + destination: normalizeStopName(json['des'] ?? ''), prediction: json['prdctdn'] as String, direction: json['rtdir'] as String, vehicleId: json['vid'] ?? 'none' ); } -} \ No newline at end of file +} diff --git a/lib/models/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/screens/map_screen.dart b/lib/screens/map_screen.dart index 41fa536..7b95858 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,18 +1,26 @@ 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'; 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/composite_map_widget.dart'; import 'package:bluebus/widgets/dialog.dart'; import 'package:bluebus/widgets/directions_sheet.dart'; +import 'package:bluebus/widgets/floorplan_overlay_widget.dart'; import 'package:bluebus/widgets/journey_results_widget.dart'; import 'package:bluebus/widgets/loading_screen.dart'; +import 'package:bluebus/widgets/navigation_overlay_widget.dart'; import 'package:bluebus/widgets/reminder_widgets.dart'; import 'package:bluebus/widgets/search_sheet_main.dart'; import 'package:bluebus/widgets/stop_sheet.dart'; @@ -26,6 +34,7 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:vector_math/vector_math_64.dart' as vec_math; import '../widgets/map_widget.dart'; import '../widgets/route_selector_modal.dart'; import '../widgets/favorites_sheet.dart'; @@ -38,47 +47,11 @@ import '../services/route_color_service.dart'; import 'package:geolocator/geolocator.dart'; import '../constants.dart'; import './settings.dart'; -//import 'dart:convert'; +import 'package:screen_corner_radius/screen_corner_radius.dart'; final NEW_BUTTON_SHOW_TIME = DateTime.parse("2026-03-16 00:00:00Z"); final NEW_BUTTON_HIDE_TIME = DateTime.parse("2026-03-24 00:00:00Z"); -// Function to calculate rotation angle between two geographical points -// (used for bus stop icon orientation) -double pointRotation(double lat1, double lon1, double lat2, double lon2) { - const double degToRad = 0.017453292519943295; // π / 180 - const double radToDeg = 57.29577951308232; // 180 / π - - double dLat = lat2 - lat1; - double dLon = lon2 - lon1; - - // Scale longitude by cos(lat) to correct for east-west distance - double x = dLon * (Math.cos(lat1 * degToRad)); - double y = dLat; - - double angle = Math.atan2(x, y) * radToDeg; - - // Normalize to [0, 360) - if (angle < 0) angle += 360; - - return angle; -} - -Future resizeImage(ByteData image) async { - // Load and resize stop icon - final stopBytes = image; - final stopCodec = await ui.instantiateImageCodec( - stopBytes.buffer.asUint8List(), - targetWidth: 65, - targetHeight: 65, - ); - final stopFrame = await stopCodec.getNextFrame(); - final stopData = await stopFrame.image.toByteData( - format: ui.ImageByteFormat.png, - ); - return BitmapDescriptor.fromBytes(stopData!.buffer.asUint8List()); -} - class MaizeBusCore extends StatefulWidget { const MaizeBusCore({super.key}); @@ -87,8 +60,18 @@ class MaizeBusCore extends StatefulWidget { } class _MaizeBusCoreState extends State { - late bool canVibrate; + late bool canVibrate = false; late Journey currDisplayed; + ScreenRadius? screenRadius; + bool screenRadiusLoaded = false; + StreamSubscription? _posSub; + // TODO: Follow-mode state. When true, the map recenters on location updates. + Position? _lastCenteredPos; + bool _userHasInteractedWithMap = false; + bool _isProgrammaticCameraMove = false; + + bool _followUser = true; + NavigationManager navigationManager = NavigationManager(); Future? _dataLoadingFuture; final _loadingMessageNotifier = ValueNotifier( @@ -97,10 +80,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 +98,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 +115,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 +144,11 @@ 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 = "{}"; @@ -170,16 +165,42 @@ 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); _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 +212,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 +275,29 @@ class _MaizeBusCoreState extends State { Future _loadAllData() async { ThemeProvider theme = Provider.of(context, listen: false); theme.onSystemThemeUpdate(context); - await theme.loadTheme(); // load user theme data + await theme.loadTheme(); + + final prefs = await SharedPreferences.getInstance(); + globalFollowDistanceThresholdMeters = + prefs.getDouble('follow_distance_threshold_meters') ?? + globalFollowDistanceThresholdMeters; + globalGpsUpdateDistanceFilterMeters = + prefs.getInt('gps_update_distance_filter_meters') ?? + globalGpsUpdateDistanceFilterMeters; + + screenRadius = await ScreenCornerRadius.get(); // load screen radius + screenRadiusLoaded = true; + + //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(); @@ -312,9 +376,82 @@ class _MaizeBusCoreState extends State { _loadingMessageNotifier.value = Loadpoint('Starting app...', 5); busProvider.startBusUpdates(); busProvider.startRouteUpdates(); + // Start location updates in the background so startup doesn't block on + // permission dialogs or stream initialization. + startLocationUpdates(); await Future.delayed(const Duration(milliseconds: 180)); } + Future startLocationUpdates() async { + if (!await Geolocator.isLocationServiceEnabled()) return; + + LocationPermission perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.deniedForever) return; + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + } + if (perm != LocationPermission.whileInUse && + perm != LocationPermission.always) { + return; + } + + await _posSub?.cancel(); + + final settings = LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: globalGpsUpdateDistanceFilterMeters, + ); + + _posSub = Geolocator.getPositionStream(locationSettings: settings).listen( + (Position p) async { + // Keep this lightweight; do a minimal amount of work here and defer heavy updates. + if (!mounted || _mapController == null) return; + + // If follow mode is disabled, don't recenter automatically. + if (!_followUser) return; + if (_userHasInteractedWithMap) return; + + // Only move camera if user has moved more than threshold to avoid jitter. + final shouldMove = _lastCenteredPos == null || + Geolocator.distanceBetween( + _lastCenteredPos!.latitude, + _lastCenteredPos!.longitude, + p.latitude, + p.longitude, + ) > + globalFollowDistanceThresholdMeters; + + if (!shouldMove) return; + + _lastCenteredPos = p; + + // Center on the new streamed position while preserving the current camera view. + await _centerOnLocation( + false, + lat: p.latitude, + long: p.longitude, + zoom: _currentCameraPos?.zoom, + bearing: _currentCameraPos?.bearing, + ); + + // TODO: Update any navigation manager / UI that depends on live position here. + }, + ); + + // TODO: Consider throttling updates or using a timer if animateCamera is too frequent. + } + + // Call to programmatically enable/disable follow mode. Wire this to your location FAB. + void _setFollowMode(bool enabled) { + setState(() { + _followUser = enabled; + if (!enabled) return; + // When enabling follow mode, reset last-centered so next position recenters immediately. + _lastCenteredPos = null; + _userHasInteractedWithMap = false; + }); + } + // need this to make sure that the stop names exist in the cache Future _loadStopsForLaunch() async { // LOADS BOTH STOP TYPES @@ -336,7 +473,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 +536,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 +566,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 +618,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 +656,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 +683,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 +749,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 +765,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 +775,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 +861,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 +901,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 +919,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 +942,9 @@ class _MaizeBusCoreState extends State { setState(() { _selectedRoutes.clear(); _selectedRoutes.addAll(newSelection); + baseRoutesLayer.reload(); }); - _updateDisplayedRoutes(); + // _updateDisplayedRoutes(); // Save the new selection await _saveSelectedRoutes(); @@ -1172,8 +974,8 @@ class _MaizeBusCoreState extends State { if (isBusStop) { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showStopSheet( stopID, @@ -1184,8 +986,8 @@ class _MaizeBusCoreState extends State { } else { _centerOnLocation( false, - searchCoordinates.latitude, - searchCoordinates.longitude, + lat: searchCoordinates.latitude, + long: searchCoordinates.longitude, ); _showBuildingSheet(location); } @@ -1227,6 +1029,9 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } void _showDirectionsSheet( @@ -1301,10 +1106,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 +1131,9 @@ class _MaizeBusCoreState extends State { ); }, ); + _bottomSheetController?.closed.then((_) { + hideJourney(); + }); } _showJourneySheetOnReopen() { @@ -1355,441 +1172,50 @@ class _MaizeBusCoreState extends State { }, ); }, - ); - } - - // Display a Journey on the map - void _displayJourneyOnMap(Journey journey, Color walkLineColor) async { - currDisplayed = journey; - - // clear previous journey overlay - _displayedJourneyPolylines.clear(); - _displayedJourneyMarkers.clear(); - _activeJourneyBusIds.clear(); - _activeJourneyRoutes.clear(); - - final allPoints = []; - - // First, analyze the journey to find which legs are bus and which are walking - - for (int legIndex = 0; legIndex < journey.legs.length; legIndex++) { - final leg = journey.legs[legIndex]; - - // Determine if this is a walking or bus leg - walking legs don't have rt or trip - final bool isBusLeg = leg.rt != null && leg.trip != null; - // Determine leg type for processing - - if (isBusLeg) { - // Add route ID and vehicle ID to active sets for bus filtering - if (leg.rt != null) { - _activeJourneyRoutes.add(leg.rt!); - } - if (leg.trip != null) { - _activeJourneyBusIds.add(leg.trip!.vid); - } // Try to find a cached route polyline segment that follows streets - final startLatLng = getLatLongFromStopID(leg.originID); - final endLatLng = getLatLongFromStopID(leg.destinationID); - - bool usedRouteGeometry = false; - if (startLatLng != null && endLatLng != null) { - final routeVariants = _routePolylines.keys.where( - (key) => key.startsWith('${leg.rt}_'), - ); - - List? bestSegment; - double? bestLength; - - for (final routeKey in routeVariants) { - final poly = _routePolylines[routeKey]; - if (poly == null) continue; - final ptsList = poly.points; - if (ptsList.length < 2) continue; - - final seg = _extractRouteSegment(ptsList, startLatLng, endLatLng); - if (seg != null && seg.length >= 2) { - // compute approximate length - double len = 0; - for (int i = 1; i < seg.length; i++) { - final a = seg[i - 1]; - final b = seg[i]; - final dx = a.latitude - b.latitude; - final dy = a.longitude - b.longitude; - len += dx * dx + dy * dy; - } - if (bestSegment == null || len < bestLength!) { - bestSegment = seg; - bestLength = len; - } - } - } - - if (bestSegment != null) { - final polyline = Polyline( - polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), - points: bestSegment, - color: RouteColorService.getRouteColor(leg.rt!), - width: 6, - ); - _displayedJourneyPolylines.add(polyline); - - // add stop markers at endpoints of the segment (boarding/getting off) - _displayedJourneyMarkers.addAll([ - Marker( - flat: true, - markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), - position: bestSegment.first, - icon: - _getOn ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - Marker( - flat: true, - markerId: MarkerId( - 'journey_stop_${leg.destinationID}_$legIndex', - ), - position: bestSegment.last, - icon: - _getOff ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - ]); - - allPoints.addAll(bestSegment); - usedRouteGeometry = true; - } - } - - if (!usedRouteGeometry) { - // Fallback to simple path - final pts = []; - bool started = false; - for (final st in leg.trip!.stopTimes) { - if (st.stop == leg.originID) started = true; - if (started) { - final latlng = getLatLongFromStopID(st.stop); - if (latlng != null) { - pts.add(latlng); - allPoints.add(latlng); - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId('journey_stop_${st.stop}_$legIndex'), - position: latlng, - icon: - _stopIcon ?? - BitmapDescriptor.defaultMarkerWithHue( - _colorToHue(RouteColorService.getRouteColor(leg.rt!)), - ), - ), - ); - } - } - if (st.stop == leg.destinationID && started) break; - } - - if (pts.isNotEmpty) { - final poly = Polyline( - polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), - points: pts, - color: RouteColorService.getRouteColor(leg.rt!), - width: 6, - ); - _displayedJourneyPolylines.add(poly); - } - } - } else { - // Walking legs add a dotted line between origin and destination - // First try to get the locations from origin and destination IDs - LatLng? startLatLng = getLatLongFromStopID(leg.originID); - LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); - - // Walking leg information - - // Locations were not found, could be a building or custom location - // In this case, we need to look for coordinates in previous/next legs - // Also handle virtual origin/destination from the directions request - if (startLatLng == null) { - // resolve virtual origin - if (leg.originID == 'VIRTUAL_ORIGIN' && - _lastJourneyRequestOrigin != null) { - startLatLng = LatLng( - _lastJourneyRequestOrigin!['lat']!, - _lastJourneyRequestOrigin!['lon']!, - ); - } else if (leg.originID == 'VIRTUAL_DESTINATION' && - _lastJourneyRequestDest != null) { - startLatLng = LatLng( - _lastJourneyRequestDest!['lat']!, - _lastJourneyRequestDest!['lon']!, - ); - } - } - - // If still unresolved and this is a virtual origin, attempt to use device location - if (startLatLng == null && leg.originID == 'VIRTUAL_ORIGIN') { - try { - final pos = await Geolocator.getCurrentPosition().timeout( - Duration(seconds: 3), - ); - startLatLng = LatLng(pos.latitude, pos.longitude); - } catch (e) { - // ignore GPS resolution failure - } - } - - if (startLatLng == null && legIndex > 0) { - // Try to get end location from previous leg - final prevLeg = journey.legs[legIndex - 1]; - startLatLng = getLatLongFromStopID(prevLeg.destinationID); - } - - if (endLatLng == null) { - // resolve virtual destination - if (leg.destinationID == 'VIRTUAL_DESTINATION' && - _lastJourneyRequestDest != null) { - endLatLng = LatLng( - _lastJourneyRequestDest!['lat']!, - _lastJourneyRequestDest!['lon']!, - ); - } else if (leg.destinationID == 'VIRTUAL_ORIGIN' && - _lastJourneyRequestOrigin != null) { - endLatLng = LatLng( - _lastJourneyRequestOrigin!['lat']!, - _lastJourneyRequestOrigin!['lon']!, - ); - } - } - - // If still unresolved and this is a virtual destination, attempt device location fallback - if (endLatLng == null && leg.destinationID == 'VIRTUAL_DESTINATION') { - try { - final pos = await Geolocator.getCurrentPosition().timeout( - Duration(seconds: 3), - ); - endLatLng = LatLng(pos.latitude, pos.longitude); - } catch (e) { - print('Could not resolve VIRTUAL_DESTINATION via device GPS: $e'); - } - } - - if (endLatLng == null && legIndex < journey.legs.length - 1) { - // Try to get start location from next leg - final nextLeg = journey.legs[legIndex + 1]; - endLatLng = getLatLongFromStopID(nextLeg.originID); - } - - // Check if we have both coordinates before creating walking polyline - if (startLatLng != null && endLatLng != null) { - List pts = []; - if (leg.pathCoords != null && leg.pathCoords!.isNotEmpty) { - pts = leg.pathCoords!; - } else { - pts = [startLatLng, endLatLng]; - } - - // Create a dotted line for walking segments - final walkingPolyline = Polyline( - polylineId: PolylineId('walking_${journey.hashCode}_$legIndex'), - points: pts, - color: walkLineColor, // Walk line color - width: 6, // line width - patterns: [ - PatternItem.dash(30), // Longer dashes - PatternItem.gap(15), // Longer gaps - ], - ); - - _displayedJourneyPolylines.add(walkingPolyline); - allPoints.addAll([startLatLng, endLatLng]); - - // Only add destination marker if this is the final leg of the journey - if (legIndex == journey.legs.length - 1) { - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId( - 'journey_final_destination_${journey.hashCode}', - ), - position: endLatLng, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueRed, - ), - ), - ); - } - - // Add starting marker if this is the first leg of the journey - if (legIndex == 0) { - _displayedJourneyMarkers.add( - Marker( - flat: true, - markerId: MarkerId('journey_start_${journey.hashCode}'), - position: startLatLng, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueGreen, - ), - ), - ); - } // doing this for now bc couldnt figure out marker stuff better - } - } - } - - // mark that a journey overlay is active (this will hide other route polylines) - _journeyOverlayActive = true; - - // Build bus markers for buses matching active journey routes - // Filter by route first, then optionally by specific vehicle ID if available - _displayedJourneyBusMarkers.clear(); - final busProvider = Provider.of(context, listen: false); - for (final bus in busProvider.buses) { - // Show buses that are on routes used in the journey - if (_activeJourneyRoutes.contains(bus.routeId)) { - _displayedJourneyBusMarkers.add(_createBusMarker(bus)); - } - } - - // Final debug check - // Journey display complete (silently updated internal state) - - setState(() { - _updateAllDisplayedMarkers(); + ).whenComplete(() { + hideJourney(); }); + } - // Trying to move camera to include the journey bounds - if (_mapController != null && allPoints.isNotEmpty) { - try { - double south = allPoints.first.latitude; - double north = allPoints.first.latitude; - double west = allPoints.first.longitude; - double east = allPoints.first.longitude; - for (final p in allPoints) { - south = p.latitude < south ? p.latitude : south; - north = p.latitude > north ? p.latitude : north; - west = p.longitude < west ? p.longitude : west; - east = p.longitude > east ? p.longitude : east; - } - - // Adjust bounds to position route in top 1/3 of screen (accounting for bottom sheet) - final latSpan = north - south; - final adjustedSouth = - south - (latSpan) * 2; // Much more padding to bottom - final adjustedNorth = north; // Less padding to top - - final bounds = LatLngBounds( - southwest: LatLng(adjustedSouth, west), - northeast: LatLng(adjustedNorth, east), - ); - - await _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), - ); - } catch (e) { - // fallback to center on first point higher up - if (allPoints.isNotEmpty) { - // Calculate center of route points - double centerLat = 0; - double centerLon = 0; - for (final p in allPoints) { - centerLat += p.latitude; - centerLon += p.longitude; - } - centerLat /= allPoints.length; - centerLon /= allPoints.length; - - // Offset the center significantly north to place in top 1/3 - final offsetLat = centerLat + 0.008; // Roughly 800m north - - await _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition(target: LatLng(offsetLat, centerLon), zoom: 13), - ), - ); - } - } - } + void showJourney() { + journeyLayer.isVisible = true; + baseRoutesLayer.isVisible = false; + liveBusesLayer.isVisible = false; } - // Clear/hide the currently displayed journey overlays and return to normal route view - void _clearJourneyOverlays() { - if (!_journeyOverlayActive) return; - _displayedJourneyPolylines.clear(); - _displayedJourneyMarkers.clear(); - _displayedJourneyBusMarkers.clear(); - _activeJourneyBusIds.clear(); - _activeJourneyRoutes.clear(); - _journeyOverlayActive = false; - // making sure to remove search location marker when clearing journey - _removeSearchLocationMarker(); - setState(() {}); + void hideJourney() { + journeyLayer.isVisible = false; + baseRoutesLayer.isVisible = true; + liveBusesLayer.isVisible = true; } - // Haversine distance between two LatLngs in meters - double _haversineDistanceMeters(LatLng a, LatLng b) { - const R = 6371000; // Earth radius in meters - final lat1 = a.latitude * math.pi / 180.0; - final lat2 = b.latitude * math.pi / 180.0; - final dLat = (b.latitude - a.latitude) * math.pi / 180.0; - final dLon = (b.longitude - a.longitude) * math.pi / 180.0; - - final sa = - math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(lat1) * - math.cos(lat2) * - math.sin(dLon / 2) * - math.sin(dLon / 2); - final c = 2 * math.atan2(math.sqrt(sa), math.sqrt(1 - sa)); - return R * c; + void _onMapCreated(GoogleMapController controller) { + _mapController = controller; } - // Find nearest index and its distance on polyline to target. Returns a pair [index, distanceMeters] - List _nearestIndexAndDistanceOnPolyline( - List poly, - LatLng target, - ) { - int bestIdx = 0; - double bestDist = double.infinity; - for (int i = 0; i < poly.length; i++) { - final p = poly[i]; - final d = _haversineDistanceMeters(p, target); - if (d < bestDist) { - bestDist = d; - bestIdx = i; - } + void _onCameraMove(CameraPosition position) { + if (!mounted) return; + if (!_isProgrammaticCameraMove) { + _userHasInteractedWithMap = true; } - return [bestIdx, bestDist]; + setState(() { + _currentCameraPos = position; + }); } - // Helper to extract a contiguous segment from polyline points between two latlngs - // Return null if indices are invalid or segment is too short. - List? _extractRouteSegment( - List poly, - LatLng start, - LatLng end, - ) { - final sRes = _nearestIndexAndDistanceOnPolyline(poly, start); - final eRes = _nearestIndexAndDistanceOnPolyline(poly, end); - final si = sRes[0] as int; - final ei = eRes[0] as int; - final sDist = sRes[1] as double; - final eDist = eRes[1] as double; - - // If either nearest point is too far from the stop, we consider this polyline not a match - if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) - return null; - - if (si == ei) return null; - - // Ensure start < end in index space, if reversed, flip the sublist - if (si < ei) { - return poly.sublist(si, ei + 1); - } else { - final seg = poly.sublist(ei, si + 1); - return seg.reversed.toList(); + void _onCameraIdle() async { + // check if user location is within viewport bounds + LatLngBounds? viewportBounds = await _mapController?.getVisibleRegion(); + if (viewportBounds != null) { + Position? pos = await _getLastKnownLocation(); + if (pos != null) { + if (!mounted) return; + setState(() { + _userLocVisible = !viewportBounds.contains( + LatLng(pos.latitude, pos.longitude), + ); + }); + } } } @@ -1817,8 +1243,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 +1269,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 +1298,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 +1321,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 +1355,9 @@ class _MaizeBusCoreState extends State { ), ); return null; + } else { + //Center map once right after user grants location permissions + _centerOnLocation(true); } } @@ -1957,10 +1388,12 @@ class _MaizeBusCoreState extends State { } Future _centerOnLocation( - bool userLocation, [ + bool userLocation, { double lat = 0, double long = 0, - ]) async { + double? zoom, + double? bearing, + }) async { // at first create a default position. User location can overwrite later if needed Position position = Position( longitude: long, @@ -1982,14 +1415,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: userLocation ? 15.0 : 17.0, + _isProgrammaticCameraMove = true; + try { + await _mapController!.animateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(position.latitude, position.longitude), + zoom: zoom ?? (userLocation ? 15.0 : 17.0), + bearing: bearing ?? 0.0, + ), ), - ), - ); + ); + } finally { + _isProgrammaticCameraMove = false; + } } } @@ -2009,56 +1448,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 +1503,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 +1515,20 @@ 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, + navigationLayer + ], + onMapCreated: _onMapCreated, + onCameraMove: _onCameraMove, + onCameraIdle: _onCameraIdle, + ), + ), Padding( padding: EdgeInsets.only( top: globalTopPadding, @@ -2322,9 +1697,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 +1790,12 @@ class _MaizeBusCoreState extends State { ), ), + // Expanded( + // child: NavigationOverlay(navigationManager: navigationManager), + // ), + + + // reminder widget SizedBox(height: 30.0), _journeyOverlayActive || _isOffline @@ -2432,7 +1810,6 @@ class _MaizeBusCoreState extends State { Spacer(), - // temp row (might add settings button to it later) (!_journeyOverlayActive) ? Padding( padding: const EdgeInsets.only(bottom: 20), @@ -2486,7 +1863,7 @@ class _MaizeBusCoreState extends State { ? (-_currentCameraPos! .bearing - 45) * - (math.pi / 180) + vec_math.degrees2Radians : 0, child: Icon( FontAwesomeIcons.compass, @@ -2537,6 +1914,7 @@ class _MaizeBusCoreState extends State { ), child: FloatingActionButton.small( onPressed: () { + _setFollowMode(true); _centerOnLocation( true, ); @@ -2632,7 +2010,10 @@ class _MaizeBusCoreState extends State { ), ), child: ElevatedButton.icon( - onPressed: _clearJourneyOverlays, + onPressed: () { + hideJourney(); + // _clearJourneyOverlays + }, style: ElevatedButton.styleFrom( backgroundColor: getColor( context, @@ -2706,7 +2087,7 @@ class _MaizeBusCoreState extends State { ); } _showBusRoutesModal( - busProvider.routes, + _busProviderRef!.routes, ); }, heroTag: 'routes_fab', @@ -2839,6 +2220,16 @@ class _MaizeBusCoreState extends State { ], ), ), + // Positioned.fill( + // child: RepaintBoundary( + // child: FloorplanOverlay() + // ) + // ), + Positioned.fill( + child: RepaintBoundary( + child: NavigationOverlay(navigationManager: navigationManager) + ) + ), ], ), ) diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 022ba42..411e94a 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,3 +1,4 @@ +import 'package:bluebus/globals.dart'; import 'package:bluebus/widgets/custom_sliding_segmented_control.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/widgets.dart'; @@ -6,6 +7,7 @@ import 'package:bluebus/constants.dart'; import 'package:bluebus/providers/theme_provider.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class Settings extends StatefulWidget { const Settings({super.key}); @@ -15,6 +17,59 @@ class Settings extends StatefulWidget { } class _SettingsState extends State { + late final TextEditingController _followThresholdController; + final GlobalKey _followThresholdFormKey = GlobalKey(); + late final TextEditingController _gpsUpdateDistanceController; + final GlobalKey _gpsUpdateDistanceFormKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _followThresholdController = TextEditingController( + text: globalFollowDistanceThresholdMeters.toStringAsFixed(1), + ); + _gpsUpdateDistanceController = TextEditingController( + text: globalGpsUpdateDistanceFilterMeters.toString(), + ); + } + + @override + void dispose() { + _followThresholdController.dispose(); + _gpsUpdateDistanceController.dispose(); + super.dispose(); + } + + Future _saveFollowThreshold() async { + final parsed = double.tryParse(_followThresholdController.text.trim()); + if (parsed == null || parsed < 0) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('follow_distance_threshold_meters', parsed); + + if (!mounted) return; + setState(() { + globalFollowDistanceThresholdMeters = parsed; + }); + } + + Future _saveGpsUpdateDistanceFilter() async { + final parsed = int.tryParse(_gpsUpdateDistanceController.text.trim()); + if (parsed == null || parsed < 0) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('gps_update_distance_filter_meters', parsed); + + if (!mounted) return; + setState(() { + globalGpsUpdateDistanceFilterMeters = parsed; + }); + } + @override Widget build(BuildContext context) { ThemeProvider themeProvider = Provider.of(context, listen: false); @@ -100,6 +155,200 @@ class _SettingsState extends State { const Divider(), const SizedBox(height: 20), + const Text( + 'Map Follow Distance', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w600, + fontSize: 24, + ), + textAlign: TextAlign.left, + ), + + const SizedBox(height: 10), + + const Text( + 'How far you need to move before the map recenters while follow mode is enabled.', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w400, + fontSize: 16, + ), + ), + + const SizedBox(height: 12), + + Form( + key: _followThresholdFormKey, + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _followThresholdController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: 'Threshold in meters', + hintText: '8.0', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + validator: (value) { + final parsed = double.tryParse((value ?? '').trim()); + if (parsed == null) { + return 'Enter a valid number'; + } + if (parsed < 0) { + return 'Enter a value of 0 or higher'; + } + return null; + }, + onFieldSubmitted: (_) async { + final isValid = _followThresholdFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveFollowThreshold(); + } + }, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final isValid = _followThresholdFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveFollowThreshold(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + getColor(context, ColorType.importantButtonBackground), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + elevation: 0, + ), + child: Text( + 'Apply', + style: TextStyle( + color: getColor(context, ColorType.importantButtonText), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), + + const Text( + 'GPS Update Distance', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w600, + fontSize: 24, + ), + textAlign: TextAlign.left, + ), + + const SizedBox(height: 10), + + const Text( + 'How far you need to move (dead reckoning) before the GPS requests updated location', + style: TextStyle( + fontFamily: 'Urbanist', + fontWeight: FontWeight.w400, + fontSize: 16, + ), + ), + + const SizedBox(height: 12), + + Form( + key: _gpsUpdateDistanceFormKey, + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _gpsUpdateDistanceController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Distance in meters', + hintText: '5', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + validator: (value) { + final parsed = int.tryParse((value ?? '').trim()); + if (parsed == null) { + return 'Enter a valid number'; + } + if (parsed < 0) { + return 'Enter a value of 0 or higher'; + } + return null; + }, + onFieldSubmitted: (_) async { + final isValid = _gpsUpdateDistanceFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveGpsUpdateDistanceFilter(); + } + }, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () async { + final isValid = _gpsUpdateDistanceFormKey.currentState + ?.validate() ?? + false; + if (isValid) { + await _saveGpsUpdateDistanceFilter(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + getColor(context, ColorType.importantButtonBackground), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + elevation: 0, + ), + child: Text( + 'Apply', + style: TextStyle( + color: getColor(context, ColorType.importantButtonText), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), + Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/services/bus_info_service.dart b/lib/services/bus_info_service.dart index bc53017..30c1d87 100644 --- a/lib/services/bus_info_service.dart +++ b/lib/services/bus_info_service.dart @@ -32,12 +32,7 @@ Future> fetchNextBusStops(String busID) async { } // for bus stops -Future<(List, bool)> fetchStopData(String stopID) async { - - final prefs = await SharedPreferences.getInstance(); - final list = prefs.getStringList('favorite_stops') ?? []; - bool toReturn = list.contains(stopID); - +Future> fetchStopData(String stopID) async { Uri url; if (int.tryParse(stopID) != null) { @@ -53,8 +48,8 @@ Future<(List, bool)> fetchStopData(String stopID) async { if (response.statusCode == 200) { final Map data = json.decode(response.body); final List predictions = data['bustime-response']['prd']; - return (predictions.map((json) => BusWithPrediction.fromJson(json)).toList(), toReturn); + return predictions.map((json) => BusWithPrediction.fromJson(json)).toList(); } else { throw Exception('Failed to load bus stops'); } -} +} \ No newline at end of file diff --git a/lib/services/map_image_service.dart b/lib/services/map_image_service.dart new file mode 100644 index 0000000..c5f6108 --- /dev/null +++ b/lib/services/map_image_service.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:flutter/material.dart'; +import 'package: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 Future ensureRouteIconIsLoaded( + String routeId, + ) async { + if (_routeBusIcons.containsKey(routeId)) + return _routeBusIcons[routeId]; // Already in cache, no need to do anything else + + final prefs = await SharedPreferences.getInstance(); + final cachedBytes = prefs.getString('bus_icon_$routeId'); + if (cachedBytes != null) { + final bytes = base64.decode(cachedBytes); + _routeBusIcons[routeId] = BitmapDescriptor.fromBytes(bytes); + return _routeBusIcons[routeId]; // Icon is already cached! + } + + // Load bus icon for this route if not already loaded + // if (!_routeBusIcons.containsKey(routeId)) { + final imageUrl = RouteColorService.getRouteImageUrl(routeId); + if (imageUrl != null) { + await _loadRouteBusIcon(routeId, imageUrl); + return _routeBusIcons[routeId]; + } + // } + } + + // Check if a route has specific bus icon loaded + bool hasRouteBusIcon(String routeId) { + return _routeBusIcons.containsKey(routeId); + } + + // Get the number of route bus icons loaded + int get loadedBusIconCount => _routeBusIcons.length; + + // Refresh route specific bus icons + static void refreshRouteBusIcons() { + _routeBusIcons.clear(); + _loadRouteSpecificBusIcons(); + } + + // 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 bool isBusIconAvailable(Bus bus) { + return _routeBusIcons.containsKey(bus.routeId) || + _busIcon != + null; // TODO: Should this include a check for _busIcon like in getBusIcon()? + } + + static BitmapDescriptor getBusIcon(Bus bus) { + final routeColor = + bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId); + + if (_routeBusIcons.containsKey(bus.routeId)) { + return _routeBusIcons[bus.routeId]!; + } else if (_busIcon != null) { + return _busIcon!; + } else { + debugPrint( + "WARN: getBusIcon found no icon currently loaded, returning defaultMarkerWithHue", + ); + return BitmapDescriptor.defaultMarkerWithHue(colorToHue(routeColor)); + } + } + + static Future loadData() async { + await _loadRouteSpecificBusIcons(); + } +} 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..63ae296 --- /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..104108b --- /dev/null +++ b/lib/services/map_layers/journey_layer.dart @@ -0,0 +1,353 @@ +import 'package:bluebus/constants.dart'; +import 'package:bluebus/globals.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/journey.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'package:bluebus/utils/geometry.dart'; + +class JourneyLayer extends CompositeMapLayer { + // maximum allowed distance (meters) from a stop to a candidate polyline point + static const double _maxMatchDistanceMeters = 150.0; + + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + + Function(String s) _showBusSheet = (String s) { + debugPrint("Error: _showBusSheet was called but callback was never set"); + }; + + BitmapDescriptor? _getOn; + BitmapDescriptor? _getOff; + BitmapDescriptor? _destination; + BitmapDescriptor? _start; + + Set activeJourneyBusIds = {}; + Set activeJourneyRoutes = {}; + Set liveBusMarkers = {}; + + Map> routesCache = {}; + BuildContext? context; + + GoogleMapController? _mapController; + + void setMapController(GoogleMapController mapController_in) { + _mapController = mapController_in; + } + + void init( + Function(String s) showBusSheet_in, + Set activeJourneyBusIds_in, + Set activeJourneyRoutes_in, + BuildContext context_in, + ) { + // activeJourneyBusIds = activeJourneyBusIds_in; + // activeJourneyRoutes = activeJourneyRoutes_in; + // TODO: Get rid of activeJourneyBusIds and activeJourneyRoutes as they're passed in here + _showBusSheet = showBusSheet_in; + context = context_in; + loadMarkers(); + } + + Future loadMarkers() async { + _getOn = await MapImageService.resizeImage( + await rootBundle.load('assets/getOn.png'), + ); + _getOff = await MapImageService.resizeImage( + await rootBundle.load('assets/getOff.png'), + ); + _destination = await MapImageService.resizeImage( + await rootBundle.load('assets/destination.png'), + ); + _start = await MapImageService.resizeImage( + await rootBundle.load('assets/start.png'), + ); + } + + void setOnUpdate(Function() callback) { + onUpdate = callback; + } + + void refreshLiveBusMarkers(List allBuses) { + liveBusMarkers.clear(); + for (final bus in allBuses) { + // Show buses that are on routes used in the journey + if (activeJourneyBusIds.contains(bus.id)) { + BitmapDescriptor busIcon = MapImageService.getBusIcon(bus); + + liveBusMarkers.add( + Marker( + flat: true, + markerId: MarkerId('journey_bus_${bus.id}'), + consumeTapEvents: true, + position: bus.position, + icon: busIcon!, + rotation: bus.heading, + anchor: const Offset(0.5, 0.5), + onTap: () => _showBusSheet(bus.id), + ), + ); + } + } + } + + void setRoutesCache(List routes) { + routesCache.clear(); + for (BusRouteLine l in routes) { + routesCache.putIfAbsent(l.routeId, () => []).add(l); + } + } + + // Helper to extract a contiguous segment from polyline points between two latlngs + // Return null if indices are invalid or segment is too short. + List? _extractRouteSegment( + List poly, + LatLng start, + LatLng end, + ) { + final (si, sDist) = start.nearestPolylineIndexAndDistanceDiscrete(poly); + final (ei, eDist) = end.nearestPolylineIndexAndDistanceDiscrete(poly); + + // If either nearest point is too far from the stop, we consider this polyline not a match + if (sDist > _maxMatchDistanceMeters || eDist > _maxMatchDistanceMeters) { + return null; + } + + if (si == ei) return null; + + // Ensure start < end in index space, if reversed, flip the sublist + if (si < ei) { + return poly.sublist(si, ei + 1); + } else { + final seg = poly.sublist(ei, si + 1); + return seg.reversed.toList(); + } + } + + Future addBusLegMarkersAndPolylines( + Leg leg, + Journey journey, + int legIndex, + ) async { + // This accepts a bus leg that goes from, e.g. CCTC (C251) through several stops to a destination, e.g. Stop C251 + // and adds the necessary markers and polylines to the markers and polylines Sets + + if (leg.rt != null) activeJourneyRoutes.add(leg.rt!); + if (leg.trip != null) activeJourneyBusIds.add(leg.trip!.vid); + + final rt = leg.rt; + final line = rt != null + ? determineRouteOfBusLeg(routesCache, rt, leg.originID, leg.destinationID) + : null; + + // debugPrint("Tracing path from ${leg.originID} to ${leg.destinationID}"); + + final LatLng? startLatLng = getLatLongFromStopID(leg.originID); + final LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); + + if (startLatLng != null && endLatLng != null && line?.points != null) { + List? segment = _extractRouteSegment( + line!.points, + startLatLng, + endLatLng, + ); + if (segment == null) { + // debugPrint("ERROR: Line segment is null!"); + + // If something went wrong tracing streets between stops, just draw a straight + // line between the start and end + final polyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), + points: [startLatLng, endLatLng], + color: RouteColorService.getRouteColor(leg.rt!), + width: 6, + ); + polylines.add(polyline); + } else { + final polyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('journey_${journey.hashCode}_$legIndex'), + points: segment, + color: RouteColorService.getRouteColor(leg.rt!), + width: 6, + ); + polylines.add(polyline); + } + + // add stop markers at endpoints of the segment (boarding/getting off) + if ((segment?.first != null || startLatLng != null)) { + // Making sure the marker has a valid location + + // BitmapDescriptor iconBitmap = await RouteIcon.small( + // leg.rt!, + // ).toBitmapDescriptor(); + + // TODO: See what the UI team says about this--if it looks good, add an extra method to the RouteIcon class that generates a bitmap instead of having to render this whole thing to the widget tree (it'll be MUCH faster) + + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_stop_${leg.originID}_$legIndex'), + position: segment?.first ?? startLatLng, + icon: + _getOn ?? + // iconBitmap ?? + BitmapDescriptor.defaultMarkerWithHue( + colorToHue(RouteColorService.getRouteColor(leg.rt!)), + ), + anchor: Offset(0.5, 0.5), + ), + ); + } + if ((segment?.last != null || endLatLng != null)) { + // Making sure the marker has a valid location + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_stop_${leg.destinationID}_$legIndex'), + position: segment?.last ?? endLatLng, + icon: + _getOff ?? + BitmapDescriptor.defaultMarkerWithHue( + colorToHue(RouteColorService.getRouteColor(leg.rt!)), + ), + ), + ); + } + } + } + + void addWalkingLegMarkersAndPolylines( + Leg leg, + Journey journey, + int legIndex, + ) { + // Walking legs add a dotted line between origin and destination + // First try to get the locations from origin and destination IDs + LatLng? startLatLng = getLatLongFromStopID(leg.originID); + LatLng? endLatLng = getLatLongFromStopID(leg.destinationID); + + // NEXT STEPS TODO: Get these walking lines working and see if I can fix the straight-line bus segment problem (where it says ERROR: Line segment is null!) + + if (startLatLng == null && legIndex > 0) { + // Try to get end location from previous leg + final prevLeg = journey.legs[legIndex - 1]; + startLatLng = getLatLongFromStopID(prevLeg.destinationID); + } + + List pathCoords = leg.pathCoords ?? []; + + if (leg.pathCoords == null) { + if (startLatLng != null && endLatLng != null) { + // If there's no path available, draw a straight line if we can + pathCoords = [startLatLng, endLatLng]; + } + } + + // Create a dotted line for walking segments + final walkingPolyline = Polyline( + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + polylineId: PolylineId('walking_${journey.hashCode}_$legIndex'), + points: pathCoords, + color: (context != null) + ? getColor(context!, ColorType.mapWalkingLine) + : Colors.black, // Walk line color + width: 8, // line width + patterns: [ + PatternItem.dot, + // PatternItem.dash(30), // Longer dashes + PatternItem.gap(15), // Longer gaps + ], + ); + + polylines.add(walkingPolyline); + } + + void addRouteStartMarker(LatLng position, Journey journey) { + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_start_${journey.hashCode}'), + position: position, + icon: + _start ?? + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + ), + ); + } + + void addRouteEndMarker(LatLng position, Journey journey) { + markers.add( + Marker( + flat: true, + markerId: MarkerId('journey_final_destination_${journey.hashCode}'), + position: position, + icon: + _destination ?? + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + ), + ); + } + + void setJourney(Journey journey, Color walkLineColor) { + // Don't stop believin' + + // clear previous journey overlay + polylines.clear(); + markers.clear(); + activeJourneyBusIds.clear(); + activeJourneyRoutes.clear(); + + final allPoints = []; + + // First, analyze the journey to find which legs are bus and which are walking + + for (int legIndex = 0; legIndex < journey.legs.length; legIndex++) { + final leg = journey.legs[legIndex]; + + if (leg.destinationID == "VIRTUAL_DESTINATION" && + leg.pathCoords != null && + leg.pathCoords!.isNotEmpty) { + addRouteEndMarker(leg.pathCoords!.last, journey); + } + + // Determine if this is a walking or bus leg - walking legs don't have rt or trip + final bool isBusLeg = leg.rt != null && leg.trip != null; + // Determine leg type for processing + + if (isBusLeg) { + addBusLegMarkersAndPolylines(leg, journey, legIndex); + } else { + addWalkingLegMarkersAndPolylines(leg, journey, legIndex); + } + } + + if (isVisible) onUpdate(); // Tell the CompositeMapWidget to update + } + + void clearJourney() { + markers.clear(); + polylines.clear(); + if (isVisible) onUpdate(); + } +} diff --git a/lib/services/map_layers/live_buses_layer.dart b/lib/services/map_layers/live_buses_layer.dart new file mode 100644 index 0000000..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/services/map_layers/navigation_layer.dart b/lib/services/map_layers/navigation_layer.dart new file mode 100644 index 0000000..1e68938 --- /dev/null +++ b/lib/services/map_layers/navigation_layer.dart @@ -0,0 +1,54 @@ +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/bus_stop.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/composite_map_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class NavigationLayer extends CompositeMapLayer { + @override + bool isVisible = true; + @override + Set polylines = {}; + @override + Set markers = {}; + @override + Function() onUpdate = () {}; + Function(BusStop) onStopClicked = (BusStop s) { + debugPrint("Warning! onStopClicked called but no callback was registered"); + }; + + void init( + ) { + //... + } + + void reload() { + // reloadMarkers(); + // reloadPolylines(); + debugPrint("**** RELOADING NAVIGATIONLAYER, we have ${markers.length} markers and ${polylines} polylines"); + if (isVisible) onUpdate(); + } + + void setMarkers(Set markers_in) { + this.markers = markers_in; + } + + void setPolylines(Set polylines_in) { + this.polylines = polylines_in; + } + + void reloadMarkers() { + //... + } + + void reloadPolylines() { + //... + } + + void setOnUpdate(Function() callback) { + onUpdate = callback; + } +} diff --git a/lib/services/navigation/navigation_manager.dart b/lib/services/navigation/navigation_manager.dart new file mode 100644 index 0000000..1e2772a --- /dev/null +++ b/lib/services/navigation/navigation_manager.dart @@ -0,0 +1,825 @@ +import 'dart:async'; +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; +import 'package:bluebus/models/journey.dart'; +import 'package:bluebus/services/map_layers/navigation_layer.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +enum LineType { Dotted, Dashed} + +class NavigationStageStep { + String title; + String? subtitle; + String time; + Color color; + LineType lineType; // e.g. LineType.Dashed + + NavigationStageStep({ + required this.title, + this.subtitle, + required this.time, + required this.color, + required this.lineType + }); + + String getTitle() { + return title; + } + + String? getSubtitle() { + return subtitle; // Return null if no subtitle + } + + String getTime() { + return time; // Get the time + } + + Color? getColor() { + return color; // Return null for neutral gray + } + + LineType getLineType() { + return lineType; + } + +} + +sealed class NavigationStage { + // String title = "..."; //Don't use this anymore--implement getTitle() instead + String getTitle() { + return "Swim forward"; // Title displayed on the big bar at the top + } + + String getSubtitle() { + return "Swim for 200 meters"; // Subtitle displayed on the big bar at the top + } + + double length = 0.0; // Estimated length of your segment, in minutes (i.e. is it a 20-minute walk or 12-minute bus ride?) + double percent_complete = 0.0; // Estimated completion percentage of your segment (i.e. if you're 32% of the way through your walk) + + List getSteps() { + return []; // Get navigation stage steps + } + List getMarkers() { + return []; + } + + List getPolylines() { + return []; + } + + Color getColor() { + return Color(0xFFDBE4ED); + } + + bool hasRoundedCorners() { + return false; + } + + final _eventController = StreamController(); + + Stream get events => _eventController.stream; + + void dispose() { + _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 { + wrongBus, + walkPathChanged + // Feel free to add additional reasons as necessary +} + + +class BusPromptOption { + // DISCLAIMER: STRUCTURES SUBJECT TO CHANGE BECAUSE IM NOT SURE IF WE HAVE CUSTOM STRUCTURES + // going to remove this soon probably, since i can use the bus structure... + final String code; // "CN" "BB"... + final String label; // expanded name + final Color color; + final String? busNumber; // 3067 :) + BusPromptOption({ + required this.code, + required this.label, + required this.color, + this.busNumber + }); +} + +sealed class StageEvent {} +class StageComplete extends StageEvent {} +class StageReroute extends StageEvent { + final RerouteReason reason; // e.g. wrong bus, missed stop + StageReroute(this.reason); +} + +class NavOnBus extends NavigationStage { + String rt; + String departureStop; + String arrivalStop; + + Trip trip; + List<(LatLng, (int, BusStop)?)> busPath; + // BusRouteLine busPath; + + NavOnBus({ + required this.rt, + required this.departureStop, + required this.arrivalStop, + required this.trip, + required this.busPath, + }); + + 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"); + } + 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: 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); + } + +} + +typedef Edge = ({ BusStop from, BusStop to, List points }); +typedef AdjacencyEntry = ({ BusStop from, Set<({ BusStop stop, List points })> tos }); +BusRouteLine? determineRouteOfBusLeg( + Map> routesCache, String rt, String originID, String destinationID +) { + List candidates = routesCache[rt] ?? []; + + // happy path + final directLine = candidates + .where((line) { + final stpids = line.stops.map((s) => s.$2.id); + return stpids.skipWhile((stpid) => stpid != originID).contains(destinationID); + }) + .firstOrNull; + if (directLine != null) return directLine; + + // big sad path: graph traverse the entire route... + final Map adjacency = {}; // for stpids + // make the adjacency structure ... + for (final line in candidates) { + (int, BusStop)? prev; + for (final (i, stop) in line.stops) { + if (prev != null) { + final (prevIdx, prevStop) = prev; + // ignore: prefer_collection_literals (for better type inference) + adjacency.putIfAbsent(prevStop.id, () => (from: prevStop, tos: Set())) + .tos.add((stop: stop, points: line.points.sublist(prevIdx, i + 1))); + } + prev = (i, stop); + } + } + // do breadth first search ... + final Set explored = {}; + final queue = ListQueue<(String, List)>(); + queue.addLast((originID, [])); + + while (queue.isNotEmpty) { + final (stpid, edges) = queue.first; + if (stpid == destinationID) break; + queue.removeFirst(); + + if (explored.contains(stpid)) continue; + explored.add(stpid); + + final neighbors = adjacency[stpid]; + if (neighbors != null) { + for (final entry in neighbors.tos) { + queue.addLast(( + entry.stop.id, + edges.followedBy([(from: neighbors.from, to: entry.stop, points: entry.points)]).toList() + )); + } + } + } + + if (queue.isEmpty || queue.first.$2.isEmpty) return null; + final edges = queue.first.$2; + + List points = [LatLng(0.0, 0.0)]; + List<(int, BusStop)> stops = [(0, edges.first.from)]; + for (final e in edges) { + points.removeLast(); + points.addAll(e.points); + stops.add((points.length - 1, e.to)); + } + + return BusRouteLine( + points: points, + stops: stops, + routeId: candidates.first.routeId, + color: candidates.fold(null, (acc, next) => acc ?? next.color), + imageUrl: candidates.fold(null, (acc, next) => acc ?? next.imageUrl), + ); +} + +class ChooseBus extends NavigationStage{ + String title = "Choose a Bus"; + //Not sure if we actually need this. Depends on if we want to filter out some buses from certain stops. + List potentialBuses = []; + List potentialStops = []; + // If you have a list of buses to board and stops, + // this can help you display a bus and the stop you will board + // This could be simplified more, probably by picking up data from another function +} + +// oops stage +// TODOs: MOVING TO NAVIGATION MANAGER +// class MissedBus extends NavigationStage { +// // using the new title information method +// @override +// String getTitle() { +// // could be a more descriptive title who knows.. +// return "Oops!"; +// } + +// // information for the popup +// @override +// String getSubtitle() { +// // Looks like these are for pop-ups, so maybe this can be part of a user prompt? +// return "Looks like you might've missed your bus! Would you like to re-route?"; +// } + +// String route; // current route +// String nearest_stop; // nearest stop: ideally to get off +// String c_bus; // current bus i am/was on +// String c_pos; // current position (maybe not str lat lng?) + +// MissedBus({ +// // Constructor for more stuff +// required this.route, +// required this.nearest_stop, +// required this.c_bus, +// required this.c_pos, +// }); +// } + + +//I believe this is just NavWalking but I'm doing it here to be sure. +class Walking extends NavigationStage { + List points = [ //dummy pts taken from google maps by the cctc (replace later) + const LatLng(42.27792397921826, -83.73596985653457), + const LatLng(42.27756042901099, -83.7359661838265), + const LatLng(42.27754197988967, -83.73706331473826), + const LatLng(42.2775215703816, -83.73809993417933), + const LatLng(42.278481544159916, -83.73811396072821), + ]; + + 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) + @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; + } + + // if it has reached new waypoint, update index, length left, and percent complete + _nextIndex++; + + // TODO: + // update length and percent_complete here + + return true; + } + + @override + String getTitle() { + if (_nextIndex >= points.length) { + return "You've arrived!"; + } + final pos = currWalkingPos; + if (pos == null) { + return "Acquiring GPS…"; + } + final feet = (_distMeters(pos, points[_nextIndex]) * 3.28084).round(); + return "${_directionWord(pos)} in $feet ft"; + } + + // Calculates the distance left in the walking stage + // in feet based on the current user position + double getDistanceLeftFeet() { + double distLeft = 0; + if (currWalkingPos != null) { // if GPS is broken/off, use distance from _nextIndex to the destination + distLeft = _distMeters(currWalkingPos!, points[_nextIndex]); + } + // calculate remaining walking distance + for (int i = _nextIndex; i < points.length - 1; ++i) { + distLeft += _distMeters(points[i], points[i + 1]); + } + return (distLeft * 3.28084); + } + + // Get summary text that shows up in steps view + // such as "Walk 67 ft" + // @override + String getFixedTitle() { + return "Walk ${getDistanceLeftFeet().round()} ft"; + } + + @override + String getSubtitle() { + if (_nextIndex >= points.length) { + return "Walk complete"; + } + return "Waypoint ${_nextIndex + 1} of ${points.length}"; + } + + String _directionWord(LatLng pos) { + if (_nextIndex == 0) { + return "Head"; + } + final incoming = _bearing(points[_nextIndex - 1], pos); + final outgoing = _bearing(pos, points[_nextIndex]); + final diff = (outgoing - incoming + 360) % 360; + if (diff < 20 || diff > 340) { + return "Continue straight"; + } + if (diff <= 170) { + return "Turn right"; + } + if (diff >= 190) { + return "Turn left"; + } + return "U-turn"; + } + + // Initializes the Walking stage given a Leg. + @override + void initWithLeg(Leg leg) { + 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; + } +} + + +class DemoStage extends NavigationStage { + + String getTitle() { + return "This is a demo! #$favoriteNumber"; + } + + String getSubtitle() { + return "Look, here's a subtitle too #$favoriteNumber"; + } + + double length = 15.0; + double percent_complete = 0.110; + + LatLng startPoint; + LatLng endPoint; + + double favoriteNumber; + Color color = Colors.black; + LineType lineType; + + DemoStage({ + required this.favoriteNumber, + required this.length, + required this.percent_complete, + required this.startPoint, + required this.endPoint, + required this.color, + required this.lineType + }); + + @override + Color getColor() { // Return a random color + // return Color(this.favoriteNumber.hashCode | 0xFF000000); // Return a color derived from this.favoriteNumber + return color; + } + + @override + List getMarkers() { + return [ + Marker( + markerId: MarkerId("${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}"), + position: this.startPoint + ), + Marker( + markerId: MarkerId("${this.favoriteNumber}-${this.endPoint.latitude}-${this.endPoint.longitude}"), + position: this.endPoint + ) + ]; + } + @override + List getPolylines() { + return [ + Polyline( + polylineId: PolylineId("${this.favoriteNumber}-${this.startPoint.latitude}-${this.startPoint.longitude}"), + points: [ + this.startPoint, + this.endPoint + ], + color: this.getColor() + ) + ]; + } + + List getSteps() { + return [ + NavigationStageStep( + title: "Step 1", + subtitle: "Step 1 subtitle", + time: '1:23 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType + ), + NavigationStageStep( + title: "Step 2", + subtitle: "Step 2 subtitle", + time: '4:56 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType + ), + NavigationStageStep( + title: "Step 3", + subtitle: "Step 3 subtitle", + time: '7:89 AM', + color: getColor(), // Use the stage's color in our demo + // lineType: LineType.Dashed, + lineType: this.lineType + ) + ]; // Get navigation stage steps + } + + bool hasRoundedCorners() { + return favoriteNumber == 2; + } + + final _eventController = StreamController(); + + Stream get events => _eventController.stream; // This is so the NavigationController can do yourStage.events and access your event controller + + // To add stage events (i.e. if you miss the bus): + // _eventController.add(StageReroute(RerouteReason.wrongBus)) + // _eventController.add(StageReroute(RerouteReason.walkPathChanged)) + // _eventController.add(StageComplete()) // If your stage is complete! + // Note to all frontend devs: Feel free to add additional RerouteReasons if you need them! + + void dispose() { + _eventController.close(); + } + + // New! + void initWithLeg(Leg leg) { + // Do cool stuff to set up your Stage with an e.g. walking or bus leg + } + + void receiveLocationUpdate(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 { + double estimated_time; + double percentage; + Color color; + + TimelineStep({ + required this.estimated_time, + required this.percentage, // Percentage of the entire progress bar occupied by this timeline step + required this.color + }); +} + +class TimelineInfo { + List timelineSteps = []; + double activePositionPercentage = 0.0; // e.g. if the user is 31% of the way through the whole trip, this equals 0.31 + TimelineInfo({ + List? timelineSteps, + this.activePositionPercentage = 0.0 + }) : timelineSteps = timelineSteps ?? []; + +} + +class NavigationManager { + // TODO: Implement ChangeNotifier and learn how that works + + StreamSubscription? _stageEventSub; + + int currentStage = 0; // Stores the current navigation state index + List stageList = + [ + DemoStage( + favoriteNumber: 1, + length: 15, + percent_complete: 0.80, + startPoint: LatLng(42.281973, -83.765719), + endPoint: LatLng(42.281291, -83.743918), + color: darkColors[ColorType.navigationStepsGray]!, // TODO: Make this dynamic. This will be messy since we need to do something about context in getColor(context, color Type) + lineType: LineType.Dashed + ), + DemoStage( + favoriteNumber: 2, + length: 33, + percent_complete: 0.23, + startPoint: LatLng(42.281291, -83.743918), + endPoint: LatLng(42.287031, -83.743532), + color: Colors.purple, + lineType: LineType.Dotted + ), + DemoStage( + favoriteNumber: 3, + length: 4, + percent_complete: 0.0, + startPoint: LatLng(42.287031, -83.743532), + endPoint: LatLng(42.289689, -83.738435), + color: darkColors[ColorType.navigationStepsGray]!, + lineType: LineType.Dashed + ), + + ]; // Stores all the states for users to page back and forth + NavigationLayer? mapLayer; + + NavigationOverlayHost? _overlay; + + void registerOverlay(NavigationOverlayHost overlay) { + _overlay = overlay; + } + + void unregisterOverlay(NavigationOverlayHost overlay) { + if (_overlay == overlay) { + _overlay = null; + } + } + + // Call to update if state changes require an update + void notifyOverlay() { + _overlay?.onNavigationUpdated(); + } + + void _activateStageSub(NavigationStage stage) { + // TODO: Call this whenever the stage is activated + _stageEventSub?.cancel(); // Drop the old subscription + _stageEventSub = stage.events.listen((event) { + switch (event) { + case StageComplete(): + // Move on to the next stage + case StageReroute(:final reason): + // Handle the reroute + } + }); + } + + void setMapLayer(NavigationLayer mapLayer_in) { + this.mapLayer = mapLayer_in; + rebuildMarkersAndPolylines(); + } + + TimelineInfo getTimeline() { + + // TODO: Also return the user's position in the whole journey + + double total_estimated_time = 0.0; + double activePositionTime = 0.0; // This is the active position percentage before dividing by total estimated trip length + double activePositionPercentage = 0.0; + + for (int i = 0; i < stageList.length; i++) { + + double currentStageLength = stageList[i].length; + + total_estimated_time += currentStageLength; + + if (i < currentStage) { + activePositionTime = activePositionTime + currentStageLength; + } else if (i == currentStage) { + activePositionTime += currentStageLength * stageList[i].percent_complete; + } + + } + activePositionPercentage = activePositionTime / total_estimated_time; + + List timelineSteps = []; + + for (int i = 0; i < stageList.length; i++) { + timelineSteps.add(TimelineStep( + estimated_time: stageList[i].length, + percentage: stageList[i].length / total_estimated_time, + color: stageList[i].getColor() + // TODO: Define a color for the stage in the stage itself + // color: Colors.red + ) + ); + } + + return TimelineInfo(timelineSteps: timelineSteps, activePositionPercentage: activePositionPercentage); + + } + + // Some way for the navigation widget to + + void init() { + // Init as necessary + rebuildMarkersAndPolylines(); + } + + // Some sort of code to read the current stage and next stage to determine whether the user can "jump" (stage switch) + // Look at the bus times of the next stage and the walking position of the current stage to determine if the user is A) near the end of their walking path and B) the bus hasn't left yet + // Write a function to detect if the stage switch went wrong + // Allen: Add UI to ask the user about which new bus to take [Check with Ishan and Harvey] + // Isaac: I'll talk to Ishan (gc with Allen+Ishan+Harvey) about what the final logic is for the "Oops" stage + + void rebuildMarkersAndPolylines() { // Call this whenever markers or polylines change + if (this.mapLayer == null) { + debugPrint("Warning: Tried to rebuild markers and polylines but no map layer was registered with NavigationManager!"); + return; + } + Set markersToDisplay = stageList.expand((NavigationStage stage) => stage.getMarkers()).toSet(); + Set polylinesToDisplay = stageList.expand((NavigationStage stage) => stage.getPolylines()).toSet(); + + this.mapLayer!.setMarkers(markersToDisplay); + this.mapLayer!.setPolylines(polylinesToDisplay); + this.mapLayer!.reload(); + + // FUTURE TODO: Get some sample data for polylines/markers and conditionally show them on the map--define a "navigation mode" that can be active (or not) in map_screen.dart + + } + + NavigationStage getCurrentStage() { + return stageList[currentStage]; + } + + void nextStage() { + currentStage = (currentStage + 1) % stageList.length; + _activateStageSub(stageList[currentStage]); + } + + void previousStage() { + currentStage = (currentStage - 1) % stageList.length; + _activateStageSub(stageList[currentStage]); + } + + void initFromJourney(Journey journey) { + + 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 + + + // - Allen: Get “Oops” code started. Find a way to talk to the NavigationOverlayWidget + // - Find a way to get the two to talk to each other: I.e. whenever `NavigationOverlayWidget` is created, it calls a specific method inside NavigationManager that says "Hey, I'm here, please save me in a member variable", so when the "Oops" stage happens later you can call localReferenceToOverlayWidget.displayOopsDialog(...) + // The stage (e.g. "On bus") should call the "Oops" stage when it needs to +} + +abstract class NavigationOverlayHost { + void displayOopsDialog(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 diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 14b0277..4f97a96 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -10,6 +10,7 @@ class NotificationService { static final _localNotificationsPlugin = FlutterLocalNotificationsPlugin(); static bool _listeningForFcmUpdates = false; static bool _listeningForForegroundMessages = false; + static bool _listeningForMessageOpened = false; static String? _registrationToken; static Function(String)? _tokenChangeCallback; diff --git a/lib/theride_api.dart b/lib/theride_api.dart index 545446d..eac07a8 100644 --- a/lib/theride_api.dart +++ b/lib/theride_api.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'dart:math' as Math; +import 'package:bluebus/utils/geometry.dart'; import 'package:http/http.dart' as http; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'constants.dart'; @@ -8,27 +8,6 @@ import 'models/bus.dart'; import 'models/bus_route_line.dart'; import 'services/route_color_service.dart'; -// Function to calculate rotation angle between two geographical points -// (used for bus stop icon orientation) -double pointRotation(double lat1, double lon1, double lat2, double lon2) { - const double degToRad = 0.017453292519943295; // π / 180 - const double radToDeg = 57.29577951308232; // 180 / π - - double dLat = lat2 - lat1; - double dLon = lon2 - lon1; - - // Scale longitude by cos(lat) to correct for east-west distance - double x = dLon * (Math.cos(lat1 * degToRad)); - double y = dLat; - - double angle = Math.atan2(x, y) * radToDeg; - - // Normalize to [0, 360) - if (angle < 0) angle += 360; - - return angle; -} - class RideAPI { static const String baseUrl = BACKEND_URL; @@ -46,7 +25,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; @@ -62,26 +41,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))); } } @@ -103,7 +81,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; @@ -120,26 +98,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/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/lib/widgets/building_sheet.dart b/lib/widgets/building_sheet.dart index e316a21..f7b6848 100644 --- a/lib/widgets/building_sheet.dart +++ b/lib/widgets/building_sheet.dart @@ -24,8 +24,8 @@ void sendEmailWithSender(BuildContext context, String emailSubject, String email void showFallbackOptions(BuildContext context) { showMaizebusOKDialog( contextIn: context, - title: const Text("Email-Send failed"), - content: const Text("Unable to reach the email app on your device. You can still send us feedback by manually emailing contact@maizebus.com"), + title: "Email-Send failed", + content: "Unable to reach the email app on your device. You can still send us feedback by manually emailing contact@maizebus.com", ); } diff --git a/lib/widgets/bus_sheet.dart b/lib/widgets/bus_sheet.dart index 7249238..1e664d8 100644 --- a/lib/widgets/bus_sheet.dart +++ b/lib/widgets/bus_sheet.dart @@ -1,6 +1,8 @@ import 'package:bluebus/services/bus_info_service.dart'; import 'package:bluebus/services/bus_repository.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; +import 'package:bluebus/widgets/dialog.dart'; import '../constants.dart'; import '../models/bus.dart'; import '../services/route_color_service.dart'; @@ -40,18 +42,33 @@ class _BusSheetState extends State { @override void initState() { super.initState(); - futureBusStops = fetchNextBusStops(widget.busID); + if (currBus == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + + Navigator.of(context).pop(); + + showMaizebusOKDialog( + contextIn: context, + title: "Uh Oh!", + content: "Unable to fetch bus data. Please check your internet connection and try again.", + ); + }); + } else { + futureBusStops = fetchNextBusStops(widget.busID); + } } @override Widget build(BuildContext context) { // There was a really weird bug where _BusSheetState would get a busID that doesn't exist so currBus would be null. // This accounts for that. - if (currBus == null) return Text("Bus not found"); + // Update: Fixed the blank text "bus not found", should + + if (currBus == null) return Text("Bus Not Found"); final bus = currBus!; - debugPrint(" currBus is ${currBus?.routeId}"); return Container( decoration: BoxDecoration( color: getColor(context, ColorType.background), @@ -129,30 +146,7 @@ Widget michiganBusHeader(Bus bus, BuildContext context) { ), child: Row( children: [ - Container( // Bus circular icon - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: bus.routeColor, - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.routeId, - style: TextStyle( - color: RouteColorService.getContrastingColor( - bus.routeId, - ), - fontSize: 30, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.large(bus.routeId), SizedBox(width: 15), @@ -202,31 +196,7 @@ Widget theRideHeader(Bus bus, BuildContext context) { ), child: Row( children: [ - Container( // Bus circular icon - width: 78, - height: 55, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: bus.routeColor, - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.routeId, - style: TextStyle( - color: RouteColorService.getContrastingColor( - bus.routeId, - ), - fontSize: 30, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.large(bus.routeId), SizedBox(width: 15), diff --git a/lib/widgets/composite_map_widget.dart b/lib/widgets/composite_map_widget.dart new file mode 100644 index 0000000..7b9cf5f --- /dev/null +++ b/lib/widgets/composite_map_widget.dart @@ -0,0 +1,178 @@ +import 'dart:math'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bluebus/constants.dart'; +import 'package:bluebus/globals.dart'; +import 'package:bluebus/models/bus.dart'; +import 'package:bluebus/models/bus_route_line.dart'; +import 'package:bluebus/models/bus_stop.dart'; +import 'package:bluebus/models/journey.dart'; +import 'package:bluebus/services/map_image_service.dart'; +import 'package:bluebus/services/map_layers/journey_layer.dart'; +import 'package:bluebus/services/map_layers/live_buses_layer.dart'; +import 'package:bluebus/services/route_color_service.dart'; +import 'package:bluebus/widgets/route_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:haptic_feedback/haptic_feedback.dart'; +import 'package:widget_to_marker/widget_to_marker.dart'; + +// Define the CompositeMapLayer +abstract class CompositeMapLayer { + // Every CompositeMapLayer must have these four things + bool get isVisible; + Set get polylines; + Set get markers; + Function() get onUpdate; + void setOnUpdate(Function() fn); + void dispose() {} +} + +// TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff + +class CompositeMapWidget extends StatefulWidget { + final LatLng initialCenter; + final List mapLayers; + final Function(GoogleMapController) onMapCreated; + final ValueChanged? onCameraMove; + final VoidCallback? onCameraIdle; + + CompositeMapWidget({ + required this.initialCenter, + required this.mapLayers, + required this.onMapCreated, + this.onCameraMove, + this.onCameraIdle, + }); + + @override + State createState() { + return CompositeMapWidgetState(); + } +} + +class CompositeMapWidgetState extends State + with SingleTickerProviderStateMixin { + GoogleMapController? _mapController; + Set allMarkers = {}; + Set allPolylines = {}; + + void reloadMap() { + 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) { + layer.initWithTickerProvider(this); + } + }); + } + + @override + Widget build(BuildContext context) { + 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(); + + return RepaintBoundary( + child: GoogleMap( + compassEnabled: false, + myLocationEnabled: true, + mapToolbarEnabled: false, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + markers: allMarkers, + polylines: allPolylines, + cameraTargetBounds: CameraTargetBounds( + LatLngBounds( + southwest: LatLng( + 42.217530, + -83.84367266, + ), // Southern and Westernmost point + northeast: LatLng( + 42.328602, + -83.53892646, + ), // Northern and Easternmost point + ), + ), + minMaxZoomPreference: const MinMaxZoomPreference(10, 21), + // markers: curMarkers.union(widget.staticMarkers), + initialCameraPosition: CameraPosition( + target: widget.initialCenter, + zoom: 15.0, + ), + style: isDarkMode(context) ? _darkMapStyle : _lightMapStyle, + onMapCreated: (GoogleMapController controller) { + _mapController = controller; + widget.mapLayers.forEach((CompositeMapLayer layer) { + if (layer is JourneyLayer) { + layer.setMapController(controller); + } + }); + widget.onMapCreated(controller); + }, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, + ), + ); + } + + @override + void dispose() { + super.dispose(); + // widget.mapLayers.forEach((CompositeMapLayer l) { + // l.dispose(); + // }); + for (CompositeMapLayer l in widget.mapLayers) { + l.dispose(); + } + } +} + +// REFACTOR TO-DOS + +// [Done]: Modify each widget's onUpdate call so it only does anything if the widget is visible +// TODO: Talk to Backend team about getting the polyline data sent alongside the navigation request +// [Done]: Pass the MapController back to map_screen.dart to get features like moving the camera working +// [Done, I think]: Figure out why the bus markers aren't loading sometimes +// TODO: Go back to the normal map view when you swipe away the navigation screen +// Looks like pressing the Android back button after swiping away the nav screen works--does it still think the sheet is displayed? +// TODO: Talk with team to make nicer "Get on bus" and "Get off bus" icons in navigation +// POSSIBLE: Maybe work on getting Project Smoothbus to snap to routes if it's close? Engineering that will be pretty involved +// When a new position is received, it'll have to calculate the closest starting point on the line. To do that: +// 1. Find the closest polyline vertex to the bus +// 2. There'll be two possible line segments that include that vertex--Try projecting the bus onto both and pick which is closer +// Do that same process to calculate the bus's ending point on the line +// Then: +// 1. Calculate the total distance *along the line* the bus travels through +// 2. Divide this distance into ~100 segments (10 per second) and save them in an array somewhere +// 3. At each frame, move the bus to the next segment +// NOTE: Some routes "double back" on the same path, which will probably cause problems. We really need a way to distinguish which direction the polyline goes +// POSSIBLE: Make bus stop markers small if you're zoomed out far enough +// POSSIBLE OPTIMIZATION: Only run animation updates for buses that are visible in the viewport? diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 75a2f32..cf3cf27 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; /// maizebus style to all dialogs in the app. Future showMaizebusOKDialog({ required BuildContext contextIn, - Widget? title, - Widget? content, + required String title, + required String content, }) { return showDialog( context: contextIn, @@ -32,8 +32,8 @@ Future showMaizebusOKDialog({ actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), actionsAlignment: MainAxisAlignment.end, - title: title, - content: content, + title: Text(title), + content: Text(content), actions: [ SizedBox( width: double.infinity, diff --git a/lib/widgets/directions_sheet.dart b/lib/widgets/directions_sheet.dart index cae5597..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 877a935..5c22b28 100644 --- a/lib/widgets/favorites_sheet.dart +++ b/lib/widgets/favorites_sheet.dart @@ -52,7 +52,7 @@ class _FavoritesSheetState extends State { if (!mounted) return; // makes sure widget hasn't been closed while waiting for this final map = {}; for (final r in routes) { - for (final s in r.stops) { + for (final (_, s) in r.stops) { if (!map.containsKey(s.id)) map[s.id] = s.name; } } @@ -68,7 +68,7 @@ class _FavoritesSheetState extends State { if (!mounted) return; // makes sure widget hasn't been closed while waiting for this final map = {}; for (final r in routes) { - for (final s in r.stops) { + for (final (_, s) in r.stops) { if (!map.containsKey(s.id)) map[s.id] = s.name; } } @@ -160,8 +160,8 @@ class _FavoritesSheetState extends State { showMaizebusOKDialog( contextIn: context, - title: const Text("No Favorites"), - content: const Text("Hit the heart icon on a stop to add it to your favorites and see it here!"), + title: "No Favorites", + content: "Hit the heart icon on a stop to add it to your favorites and see it here!", ); }); diff --git a/lib/widgets/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/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/map_widget.dart b/lib/widgets/map_widget.dart index 1f52fe1..415f912 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -47,8 +47,8 @@ class MapWidget extends StatelessWidget { ), cameraTargetBounds: CameraTargetBounds( LatLngBounds( - southwest: LatLng(42.217530, -83.84367266), // Southern and Westernmost point - northeast: LatLng(42.328602, -83.53892646), // Northern and Easternmost point + southwest: LatLng(42.217530, -85.84367266), // Southern and Westernmost point + northeast: LatLng(43.328602, -83.53892646), // Northern and Easternmost point ) ), minMaxZoomPreference: const MinMaxZoomPreference(10, 21), @@ -239,8 +239,8 @@ class _AndroidMapState extends State myLocationButtonEnabled: false, cameraTargetBounds: CameraTargetBounds( LatLngBounds( - southwest: LatLng(42.217530, -83.84367266), // Southern and Westernmost point - northeast: LatLng(42.328602, -83.53892646), // Northern and Easternmost point + southwest: LatLng(42.217530, -85.84367266), // Southern and Westernmost point + northeast: LatLng(43.328602, -83.53892646), // Northern and Easternmost point ) ), minMaxZoomPreference: const MinMaxZoomPreference(10, 21), diff --git a/lib/widgets/mini_stop_sheet.dart b/lib/widgets/mini_stop_sheet.dart index 06e4a0f..6a64204 100644 --- a/lib/widgets/mini_stop_sheet.dart +++ b/lib/widgets/mini_stop_sheet.dart @@ -1,7 +1,7 @@ import 'package:bluebus/services/bus_info_service.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import '../constants.dart'; -import '../services/route_color_service.dart'; import '../models/bus_stop.dart'; import 'package:intl/intl.dart'; @@ -21,15 +21,6 @@ String format(String text) { return text[0].toUpperCase() + text.substring(1).toLowerCase(); } -/// lets you check if a bus is the ride (checks if id is numeric) -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - class MiniStopSheet extends StatefulWidget { final String stopID; final String stopName; @@ -49,7 +40,7 @@ class MiniStopSheet extends StatefulWidget { } class _MiniStopSheetState extends State { - late Future<(List, bool)> loadedStopData; + late Future> loadedStopData; @override void initState() { @@ -73,7 +64,7 @@ class _MiniStopSheetState extends State { List arrivingBuses = []; if (snapshot.hasData){ - arrivingBuses = snapshot.data!.$1; + arrivingBuses = snapshot.data!; } if (snapshot.hasData) { @@ -126,36 +117,7 @@ class _MiniStopSheetState extends State { children: [ Row( children: [ - Container( - width: isRide(bus.id) ? 45 : 40, - height: isRide(bus.id) ? 35 : 40, - decoration: isRide(bus.id) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(bus.id), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(bus.id), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - bus.id, - style: TextStyle( - color: RouteColorService.getContrastingColor(bus.id), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(bus.id), SizedBox(width: 15,), diff --git a/lib/widgets/navigation_overlay_widget.dart b/lib/widgets/navigation_overlay_widget.dart new file mode 100644 index 0000000..87944f6 --- /dev/null +++ b/lib/widgets/navigation_overlay_widget.dart @@ -0,0 +1,689 @@ + +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'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class NavigationOverlay extends StatefulWidget { + + final NavigationManager navigationManager; + + const NavigationOverlay({ + super.key, + required this.navigationManager + }); + + // TODO: add an "update callback register" command so that our NavigationManager can reach into the NavigationOverlayWidget and the map and tell them to update. + + @override + State createState() => _NavigationOverlayState(); + +} + +class _NavigationOverlayState extends State + implements NavigationOverlayHost { + + TimelineInfo timelineInfo = TimelineInfo(); + + void updateTimeline() { // Call this after all the stages are loaded (or stages change) + // debugPrint("***** Updating timeline!"); + timelineInfo = widget.navigationManager.getTimeline(); + // debugPrint("***** Timeline now has ${timelineSteps.length} things!"); + } + + @override + void initState() { + // debugPrint("HELLO YELLO WE ARE IN IN/ITSTATE"); + super.initState(); + updateTimeline(); + widget.navigationManager.registerOverlay(this); // does not set to null, see the navigation manager + } + + @override + void dispose() { + // sets to null + widget.navigationManager.unregisterOverlay(this); + super.dispose(); + } + + @override + void onNavigationUpdated() { + setState(() { + // called from the nav manager, updates stuff + updateTimeline(); + }); + } + + // the regular busOptions button + Widget busOptionButton( + Bus bus, + VoidCallback onTap + ) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: bus.routeColor, + child: Text(bus.routeId, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + bus.id, + style: const TextStyle(decoration: TextDecoration.underline, fontSize: 14), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: const Color(0xFFFFC94A), borderRadius: BorderRadius.circular(10)), + child: Text("1", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), // NOTE: Mock using number 1, need to double check with Bus class + ), + ], + ), + ), + ), + ); + } + + // making this reusable, bit unecessary but if you need a red button with text! + Widget missedBusButton(VoidCallback onTap, Text text) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + child: text, // using passed in text parameter + ), + ); // the beautiful pill of doom and despair + } + + // The code below should diplay the "which bus are you on" popup from UI Team + // Should currently show (#4) (Version of the design..) + @override + void displayOopsDialog(BuildContext context) { + showUndismissableMaizebusDialog( + contextIn: context, + title: Text("Which bus are you on?"), + content: Container( + child: Column( + spacing: 1.0, + children: [ // All of this data is currently placeholder + busOptionButton(Bus(id: "1234", + position: LatLng(12.1, 12.1), + routeId: "NES", + heading: 12.0, + fullness: "67%", + routeColor: Color.fromARGB(0, 9, 9, 239)), + () {}), + busOptionButton(Bus(id: "5678", + position: LatLng(12.1, 12.1), + routeId: "BB", + heading: 12.0, + fullness: "67%", + routeColor: Color.fromARGB(0, 9, 9, 239)), + () {}), + missedBusButton(() {}, Text("I missed the bus")) + ], + ) + ) + ); + } + + @override + Widget build(BuildContext context) { + // switch (widget.navigationManager.getCurrentStage()) { + // case NavOnBus(): + // // Do stuff + + // case NavWalking(): + // // TODO: Handle this case. + // throw UnimplementedError(); + // } + return Stack( + children: [ + Padding( + padding: EdgeInsetsGeometry.only(left: 10, right: 10, top: 70), + child: Column( // Core column for vertical layout + children: [ + MaterialButton( + color: Colors.blue.shade900, + child: Text("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: + Container( + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + decoration: BoxDecoration( + // color: getColor(context, ColorType.mapButtonPrimary), + color: Color.fromARGB(255, 187, 187, 187), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Column( + children: [ + Container( // The big white card at the top + + // margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(20), + + decoration: BoxDecoration( + color: getColor(context, ColorType.mapButtonPrimary), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Row(children: [ + Icon( + Icons.pool, + color: getColor(context, ColorType.mapButtonIcon), + size: 30, + ), + // Expanded( + + // child: + Padding( + padding: EdgeInsets.only(left: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: getColor(context, ColorType.mapButtonIcon)), + widget.navigationManager.getCurrentStage().getTitle() + ), + // Text( + // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + // widget.navigationManager.getCurrentStage().getSubtitle() + // ), + ] + ) + ), + + // ) + ] + ) + ), + Container( // The smaller bottom protrusion from the big white card + + padding: EdgeInsets.only(top: 10, bottom: 10, left: 20, right: 20), + + + child: Row( + children: [ + Icon( + Icons.pool, + size: 20, + color: getColor(context, ColorType.mapButtonIcon) + ), + SizedBox.square(dimension: 10,), + Text( + "Then turn left", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: getColor(context, ColorType.mapButtonIcon) + ) + ) + ], + ) + ) + ] + ) + ) + + ), + SizedBox.square(dimension: 10.0,), + Container( + + margin: EdgeInsets.fromLTRB(0, 10, 0, 0), + padding: EdgeInsets.all(16), + + decoration: BoxDecoration( + color: getColor(context, ColorType.mapButtonPrimary), + boxShadow: [ + BoxShadow( + color: getColor( + context, + ColorType.mapButtonShadow, + ), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + borderRadius: + BorderRadius.circular(25), + ), + child: Column( + children: [ + RouteIcon.small("BB"), + Text( + "3 min", + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + ), + Text( + "arrival", + style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + ) + // Padding( + // padding: EdgeInsetsGeometry.only(left: 8), + // child: Text( + // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.mapButtonIcon)), + // "Bus arriving in 218 mins" + // ), + // ), + // MaterialButton( + // minWidth: 50, + // onPressed: () { + // setState(() { + // widget.navigationManager.previousStage(); + // updateTimeline(); + // }); + // }, + // child: Icon(Icons.arrow_back, color: Colors.white), + // ), + // MaterialButton( + // minWidth: 50, + // onPressed: () { + // setState(() { + // widget.navigationManager.nextStage(); + // updateTimeline(); + // }); + // }, + // child: Icon(Icons.arrow_forward, color: Colors.white) + // ) + + + + ], + ) + ), + ], + ), + + + // Expanded(child: SizedBox.expand()), + // SizedBox.expand(), + // const Spacer(), + + // VVVVVV This is the bottom bar--temporarily commenting it out to repurpose it as a DraggableScrollableSheet + + + + + // ) + + // Padding( + // padding: EdgeInsetsGeometry.only(left: 8), + // child: Text( + // // style: TextStyle(fontSize: 16, color: getColor(context, ColorType.primary)), + // "I'm told your bus is coming" + // ), + // ) + + // ], + // ) + // ), + ] + ), + ), + + // TODO: Add a scrim that fades in when you drag up on the progress bar so that the background is darkened behind the DraggableScrollableSheet + + + 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 */ ] + ), + 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: 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") + ], + ) + ) + ] + // ) + ), + + SizedBox.square(dimension: 20.0,), + + // TODO: Filter by user location!! Only show the future steps(?) + // TODO: Also show stage titles in this list + + + Column( + + 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: [ + + Row( + children: [ + Padding(padding: EdgeInsets.only(left: 20)), + Container( // Gray background behind colorful line segment + width: 30, + height: 50, + decoration: (index != 0) ? BoxDecoration( + color: getColor(context, ColorType.navigationStepsGray) + ) : null, + child: Container( + decoration: BoxDecoration( + color: stage.getColor(), + borderRadius: BorderRadius.only( + topLeft: shouldRoundTopCorners ? Radius.circular(20) : Radius.circular(0), + topRight: shouldRoundTopCorners ? Radius.circular(20) : Radius.circular(0), + ) + ), + ), + ), + + Padding(padding: EdgeInsets.only(left: 20)), + Text( + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold + ), + stage.getTitle() + ), + // Spacer(), + Container(width: 40), + // Text(stage.g()) + ] + ), + + ...stage.getSteps().asMap().entries.map((entry) { + int sub_index = entry.key; + NavigationStageStep step = entry.value; + // NEXT STEPS TODO: Get 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(), + ) + + // ) + ] + ) + ); + } + ), + ] + ); + } + +} + +// QUESTION: Should navigation_manager + + + +// FUTURE TODO: Add "Connection lost"/"GPS not very accurate" banners to alert the user of those things +// Some sort of live rotating compass that points to the end of the segment (i.e. if you're walking it replaces the icon and rotates) \ No newline at end of file diff --git a/lib/widgets/navigation_widget.dart b/lib/widgets/navigation_widget.dart new file mode 100644 index 0000000..c2529be --- /dev/null +++ b/lib/widgets/navigation_widget.dart @@ -0,0 +1,30 @@ +import 'package:bluebus/services/navigation/navigation_manager.dart'; +import 'package:flutter/material.dart'; + +// TODO: Extend the MapController back to map_screen.dart so it can move the camera and stuff + +class NavigationWidget extends StatefulWidget { + final NavigationManager navigationManager; + + NavigationWidget({required this.navigationManager}); + + @override + State createState() { + return NavigationWidgetState(); + } +} + +class NavigationWidgetState extends State + with SingleTickerProviderStateMixin { + // NEXT STEPS TODO: Add a very simple navigation tracking UI to show the current stage + + @override + initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Text("Hello"); // TODO: Return some widget stuff + } +} diff --git a/lib/widgets/reminder_widgets.dart b/lib/widgets/reminder_widgets.dart index 04bd895..dcb1948 100644 --- a/lib/widgets/reminder_widgets.dart +++ b/lib/widgets/reminder_widgets.dart @@ -246,7 +246,7 @@ class ReminderWidget extends StatelessWidget { child: Row( spacing: 10, children: [ - RouteIcon.medium(rtid, type: RouteIconType.normal), + RouteIcon.medium(rtid), Expanded( child: Text( stopName, diff --git a/lib/widgets/route_icon.dart b/lib/widgets/route_icon.dart index 573dd38..4ea78d8 100644 --- a/lib/widgets/route_icon.dart +++ b/lib/widgets/route_icon.dart @@ -6,93 +6,98 @@ bool isRide(String? s) { if (s != null && int.tryParse(s) != null) { // busID is numeric, so it's a ride bus return true; - } + } return false; } -enum RouteIconType { normal, outlined, normalWithWhiteBorder } - class RouteIcon extends StatelessWidget { const RouteIcon({ super.key, required this.rtid, - required this.size, + required this.width, + required this.height, required this.fontSize, - this.type = RouteIconType.normal, }); + /// Match aspect ratio of medium but with a custom size + /// The other set sizes have different aspect ratios! factory RouteIcon.sized( String rtid, - int size, { + double size, { Key? key, - RouteIconType type = RouteIconType.normal, }) { return RouteIcon( key: key, rtid: rtid, - size: size, + width: isRide(rtid) ? size * 1.125 : size, + height: isRide(rtid) ? size * 0.875 : size, fontSize: (size / 2).floor(), - type: type, ); } factory RouteIcon.small( String rtid, { Key? key, - RouteIconType type = RouteIconType.normal, }) { - return RouteIcon.sized(rtid, 35, key: key, type: type); + return RouteIcon( + rtid: rtid, + key: key, + width: isRide(rtid) ? 40 : 35, + height: isRide(rtid) ? 30 : 35, + fontSize: 17, + ); } - factory RouteIcon.medium(String rtid, {Key? key, RouteIconType type = RouteIconType.normal}) { - return RouteIcon.sized(rtid, 40, key: key, type: type); + factory RouteIcon.smallWithLargerFont( + String rtid, { + Key? key, + }) { + return RouteIcon( + rtid: rtid, + key: key, + width: isRide(rtid) ? 40 : 35, + height: isRide(rtid) ? 30 : 35, + fontSize: 18, + ); } - factory RouteIcon.large(String rtid, {Key? key, RouteIconType type = RouteIconType.normal}) { - return RouteIcon.sized(rtid, 60, key: key, type: type); + factory RouteIcon.medium( + String rtid, { + Key? key, + }) { + return RouteIcon.sized(rtid, 40, key: key); // 45 x 35 for theride + } + + factory RouteIcon.large( + String rtid, { + Key? key, + }) { + return RouteIcon( + rtid: rtid, + width: isRide(rtid) ? 78 : 60, + height: isRide(rtid) ? 55 : 60, + fontSize: 30, + key: key, + ); } final String rtid; - final int size; + final double width, height; final int fontSize; - final RouteIconType type; @override Widget build(BuildContext context) { - final sizeWithBorder = switch (type) { - RouteIconType.normalWithWhiteBorder => size + 2, - _ => size - }.toDouble(); - final bgColor = switch (type) { - RouteIconType.outlined => null, - _ => RouteColorService.getRouteColor(rtid), - }; - final fgColor = switch (type) { - RouteIconType.outlined => RouteColorService.getRouteColor(rtid), - _ => RouteColorService.getContrastingColor(rtid), - }; - final border = switch (type) { - RouteIconType.normal => null, - RouteIconType.outlined => Border.all(color: fgColor, width: 2.0), - // Its a weight 2 centered border in the figma but occlusion makes it look like a weight 1 outside border - RouteIconType.normalWithWhiteBorder => Border.all(color: Color(0xFFFFFFFF), width: 1.0), - }; + final bgColor = RouteColorService.getRouteColor(rtid); + final fgColor = RouteColorService.getContrastingColor(rtid); - return Container( // 45, 35 - width: isRide(rtid)? sizeWithBorder * 1.125 : sizeWithBorder, - height: isRide(rtid)? sizeWithBorder * 0.875 : sizeWithBorder, - decoration: isRide(rtid)? - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(sizeWithBorder/2), - color: bgColor, - border: border, - ) - : BoxDecoration( - shape: BoxShape.circle, - color: bgColor, - border: border, - ), + return Container( + width: width, + height: height, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: bgColor, + borderRadius: BorderRadius.circular(9999), + ), alignment: Alignment.center, child: MediaQuery( data: MediaQuery.of( diff --git a/lib/widgets/route_selector_modal.dart b/lib/widgets/route_selector_modal.dart index 2065de4..dce7da4 100644 --- a/lib/widgets/route_selector_modal.dart +++ b/lib/widgets/route_selector_modal.dart @@ -4,10 +4,10 @@ import 'package:bluebus/globals.dart'; import 'package:bluebus/innerShadow.dart'; import 'package:bluebus/widgets/custom_sliding_segmented_control.dart'; import 'package:bluebus/widgets/dialog.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../services/route_color_service.dart'; import '../constants.dart'; // Selecting routes @@ -50,6 +50,8 @@ class _RouteSelectorModalState extends State { michiganRoutes = []; rideRoutes = []; + // debugPrint("******* widget.availableRoutes is ${widget.availableRoutes.length}"); + // Loop through the source once and sort for (var route in widget.availableRoutes) { if (route['id'] != null && int.tryParse(route['id']!) != null) { @@ -375,29 +377,7 @@ class _RouteSelectorModalState extends State { Expanded( child: ListTile( contentPadding: EdgeInsets.only(left: 10, right: 0), - leading: Container( - width: 35, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(route['id']!), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - route['id']!, - style: TextStyle( - color: RouteColorService.getContrastingColor(route['id']!), - fontSize: 17, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + leading: RouteIcon.small(route['id']!), title: Text( route['name'] ?? route['id']!, style: TextStyle( @@ -519,30 +499,7 @@ class _RouteSelectorModalState extends State { child: ListTile( contentPadding: EdgeInsets.only(left: 8, right: 0), minTileHeight: 40, - leading: Container( - width: 40, - height: 30, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(15), - color: RouteColorService.getRouteColor(route['id']!), - ), - alignment: Alignment.center, - child: MediaQuery( - // media query prevents text scaling - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - route['id']!, - style: TextStyle( - color: RouteColorService.getContrastingColor(route['id']!), - fontSize: 17, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + leading: RouteIcon.small(route['id']!), title: Text( route['name'] ?? route['id']!, style: TextStyle( @@ -637,8 +594,8 @@ class _RouteSelectorModalState extends State { onPressed: () { showMaizebusOKDialog( contextIn: context, - title: const Text("Route Selector"), - content: const Text("Tap a route to show it on the map. Drag and drop to reorder routes. Long press to select only that route"), + title: "Route Selector", + content: "Tap a route to show it on the map. Drag and drop to reorder routes. Long press to select only that route", ); }, style: IconButton.styleFrom( diff --git a/lib/widgets/search_sheet_main.dart b/lib/widgets/search_sheet_main.dart index 777b944..7a71158 100644 --- a/lib/widgets/search_sheet_main.dart +++ b/lib/widgets/search_sheet_main.dart @@ -85,7 +85,7 @@ class LocationSearchBar extends HookWidget { final stopList = jsonDecode(response.body) as List; return stopList.map((stop) { - final name = stop['name'] as String; + final name = normalizeStopName(stop['name'] as String); final aliases = [ name.split(' ').map((w) => w.isNotEmpty ? w[0] : '').join(), ]; diff --git a/lib/widgets/stop_sheet.dart b/lib/widgets/stop_sheet.dart index a8ef262..5e331cd 100644 --- a/lib/widgets/stop_sheet.dart +++ b/lib/widgets/stop_sheet.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:bluebus/globals.dart'; import 'package:bluebus/providers/bus_provider.dart'; import 'package:bluebus/services/bus_info_service.dart'; import 'package:bluebus/services/incoming_bus_reminder_service.dart'; import 'package:bluebus/widgets/dialog.dart'; import 'package:bluebus/widgets/refresh_button.dart'; +import 'package:bluebus/widgets/route_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import '../constants.dart'; @@ -12,17 +14,10 @@ import '../models/bus_stop.dart'; import 'package:intl/intl.dart'; import 'upcoming_stops_widget.dart'; -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - class StopSheet extends StatefulWidget { final String stopID; final String stopName; + final bool isFavorite; final Future Function(String, String) onFavorite; final Future Function(String, String) onUnFavorite; final void Function() onGetDirections; @@ -33,6 +28,7 @@ class StopSheet extends StatefulWidget { Key? key, required this.stopID, required this.stopName, + required this.isFavorite, required this.onFavorite, required this.onUnFavorite, required this.onGetDirections, @@ -88,36 +84,7 @@ class _ExpandableStopWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 9), child: Row( children: [ - Container( // Circular icon on the left (with the bus code, e.g. "NW") - width: isRide(widget.busId) ? 45 : 40, - height: isRide(widget.busId) ? 35 : 40, - decoration: isRide(widget.busId) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(widget.busId), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(widget.busId), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - widget.busId, - style: TextStyle( - color: RouteColorService.getContrastingColor(widget.busId), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(widget.busId), SizedBox(width: 15), @@ -250,10 +217,12 @@ class ExpandableStopWidget extends StatefulWidget { required this.busProvider, }); } - -class _StopSheetState extends State { - late Future<(List, bool)> loadedStopData; - bool? _isFavorited; + +class _StopSheetState extends State with WidgetsBindingObserver { + late Future> loadedStopData; + late bool _isFavorite; + Timer? _refreshTimer; + bool _isInBackground = false; // for select bus stops with images late bool imageBusStop; @@ -262,7 +231,9 @@ class _StopSheetState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); loadedStopData = fetchStopData(widget.stopID); + _isFavorite = widget.isFavorite; imageBusStop = (widget.stopID == "C250") || (widget.stopID == "N406") || @@ -292,14 +263,63 @@ class _StopSheetState extends State { if (widget.stopID == "N553") { imagePath = "assets/PierpontNorthwood.jpg"; } + + // Start auto-refresh every 30 seconds + _startRefreshTimer(); } - void _refreshData() { - setState(() { - loadedStopData = fetchStopData(widget.stopID); + void _startRefreshTimer() { + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + if (!_isInBackground) { + _refreshData(); + } }); } + void _stopRefreshTimer() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + _isInBackground = true; + _stopRefreshTimer(); + break; + case AppLifecycleState.resumed: + _isInBackground = false; + // Refresh immediately when app comes to foreground + _refreshData(); + _startRefreshTimer(); + break; + case AppLifecycleState.detached: + _stopRefreshTimer(); + break; + case AppLifecycleState.hidden: + // Handle hidden state if needed + break; + } + } + + void _refreshData() { + if (!_isInBackground) { + setState(() { + loadedStopData = fetchStopData(widget.stopID); + }); + } + } + + @override + void dispose() { + _stopRefreshTimer(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override Widget build(BuildContext context) { return Stack( @@ -320,13 +340,10 @@ class _StopSheetState extends State { List arrivingBuses = []; if (snapshot.hasData) { - arrivingBuses = snapshot.data!.$1; + arrivingBuses = snapshot.data!; arrivingBuses.sort( (lhs, rhs) => (int.tryParse(lhs.prediction) ?? 0).compareTo(int.tryParse(rhs.prediction) ?? 0) ); - if (_isFavorited == null) { - _isFavorited = snapshot.data!.$2; - } } double initialSize = 0.9; @@ -711,11 +728,8 @@ class _StopSheetState extends State { ElevatedButton( onPressed: () { - // Read the current state - final bool currentStatus = _isFavorited ?? false; - // Call the appropriate function - if (currentStatus){ + if (_isFavorite){ widget.onUnFavorite(widget.stopID, widget.stopName); } else { widget.onFavorite(widget.stopID, widget.stopName); @@ -723,7 +737,7 @@ class _StopSheetState extends State { // Update the UI immediately setState(() { - _isFavorited = !currentStatus; + _isFavorite = !_isFavorite; }); }, style: ElevatedButton.styleFrom( @@ -737,8 +751,8 @@ class _StopSheetState extends State { elevation: 0 ), child: Icon( - (_isFavorited ?? false)? Icons.favorite : Icons.favorite_border, - color: (_isFavorited ?? false)? Colors.red : getColor(context, ColorType.secondaryButtonText), + (_isFavorite ?? false)? Icons.favorite : Icons.favorite_border, + color: (_isFavorite ?? false)? Colors.red : getColor(context, ColorType.secondaryButtonText), size: 20, ), ), @@ -874,8 +888,8 @@ class _ReminderFormState extends State { showMaizebusOKDialog( contextIn: context, - title: Text("Failed to load reminders"), - content: Text("Make sure you have the notification permission enabled in settings. If this error is persistent, please send us feedback through the feedback form in the settings page"), + title: "Failed to load reminders", + content: "Make sure you have the notification permission enabled in settings. If this error is persistent, please send us feedback through the feedback form in the settings page", ); }); return SizedBox.shrink(); @@ -950,36 +964,7 @@ class _ReminderFormState extends State { height: 10, width: 60, ), - Container( - width: isRide(rtid) ? 45 : 40, - height: isRide(rtid) ? 35 : 40, - decoration: isRide(rtid) ? - // ride icon - BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(20), - color: RouteColorService.getRouteColor(rtid), - ) : - // michigan icon - BoxDecoration( - shape: BoxShape.circle, - color: RouteColorService.getRouteColor(rtid), - ), - alignment: Alignment.center, - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.0)), - child: Text( - rtid, - style: TextStyle( - color: RouteColorService.getContrastingColor(rtid), - fontSize: 20, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ), - ), + RouteIcon.medium(rtid), Checkbox( value: activeRtids.contains(rtid) != rtidsToChange.contains(rtid), diff --git a/lib/widgets/upcoming_stops_widget.dart b/lib/widgets/upcoming_stops_widget.dart index e8ce6df..55bbd47 100644 --- a/lib/widgets/upcoming_stops_widget.dart +++ b/lib/widgets/upcoming_stops_widget.dart @@ -147,14 +147,6 @@ String futureTime(String minutesInFuture) { return DateFormat('h:mm a').format(futureTime); } -bool isRide(String? s) { - if (s != null && int.tryParse(s) != null) { - // busID is numeric, so it's a ride bus - return true; - } - return false; -} - // TODO: Make KEY_STOPS an API call! const Color UPCOMING_STOP_COLOR = Color.fromARGB(255, 85, 119, 130); @@ -736,7 +728,9 @@ class _UpcomingStopsWidgetState extends State { child: Container( width: double.infinity, height: widget.isExpanded ? null : 0, - child: (rowElements.length > 0) + child: (!widget.isExpanded) + ? const SizedBox() + : (rowElements.length > 0) ? Column( children: [ ...rowElements, @@ -809,53 +803,3 @@ class UpcomingStopsWidget extends StatefulWidget { required this.childIfNoUpcomingStopsFound, }); } - -Widget rideIcon(Color color, String id){ - return Container( // Bus circular icon - width: 50, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: color, - ), - alignment: Alignment.center, - child: Text( - id, - style: TextStyle( - color: RouteColorService.getContrastingColor( - id, - ), - fontSize: 18, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ); -} - -Widget michiganBusIcon(Color color, String id){ - return Container( // Bus circular icon - width: 35, - height: 35, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: BorderRadius.circular(39), // should be 27.5 (55 divided by 2) but 39 works too - color: color, - ), - alignment: Alignment.center, - child: Text( - id, - style: TextStyle( - color: RouteColorService.getContrastingColor( - id, - ), - fontSize: 18, - fontWeight: FontWeight.w900, - letterSpacing: -1, - ), - textAlign: TextAlign.center, - ), - ); -} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ce7db45..329297e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 @@ -30,6 +30,9 @@ dependencies: firebase_messaging: ^16.1.1 flutter_staggered_animations: ^1.1.1 youtube_player_flutter: ^9.1.3 + screen_corner_radius: ^3.0.0 + widget_to_marker: ^1.0.6 + vector_math: ^2.2.0 dev_dependencies: flutter_test: