Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 98 additions & 30 deletions lib/screens/map_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
// Route specific bus icons
final Map<String, BitmapDescriptor> _routeBusIcons = {};

/// Used when [Bus.heading] is in the west quadrant (see [_isHeadingWest]).
BitmapDescriptor? _busWestPlaceholderIcon;

// Memoization caches
final Map<String, Polyline> _routePolylines = {};
final Map<String, Map<String, Marker>> _routeStopMarkers = {}; // maps from route to a map of stopID to marker
Expand Down Expand Up @@ -409,6 +412,77 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
);
}

/// Heading in degrees clockwise from north; west ≈ 225°–315° (270° = due west).
static bool _isHeadingWest(double headingDeg) {
var h = headingDeg % 360.0;
if (h < 0) h += 360.0;
return h >= 225.0 && h <= 315.0;
}

BitmapDescriptor _baseBusIconForRoute(Bus bus, Color routeColor) {
if (_routeBusIcons.containsKey(bus.routeId)) {
return _routeBusIcons[bus.routeId]!;
}
if (_busIcon != null) return _busIcon!;
return BitmapDescriptor.defaultMarkerWithHue(_colorToHue(routeColor));
}

/// Route bus art, or west placeholder when heading is west and placeholder is loaded.
BitmapDescriptor _busMarkerIconResolved(Bus bus, Color routeColor) {
final base = _baseBusIconForRoute(bus, routeColor);
if (_isHeadingWest(bus.heading) && _busWestPlaceholderIcon != null) {
return _busWestPlaceholderIcon!;
}
return base;
}

/// Optional [assets/bus_marker_placeholder.png]; otherwise a generated orange marker.
Future<void> _loadBusWestPlaceholderIcon() async {
try {
final data = await rootBundle.load('assets/bus_marker_placeholder.png');
final codec = await ui.instantiateImageCodec(
data.buffer.asUint8List(),
targetWidth: 125,
targetHeight: 125,
);
final frame = await codec.getNextFrame();
final png = await frame.image.toByteData(format: ui.ImageByteFormat.png);
if (png != null) {
_busWestPlaceholderIcon = BitmapDescriptor.fromBytes(
png.buffer.asUint8List(),
);
return;
}
} catch (_) {
// Missing or invalid asset — use generated placeholder below.
}
_busWestPlaceholderIcon = await _generatedWestPlaceholderBusIcon();
}

Future<BitmapDescriptor> _generatedWestPlaceholderBusIcon() async {
const size = 125.0;
final recorder = ui.PictureRecorder();
final canvas = Canvas(
recorder,
Rect.fromLTWH(0, 0, size, size),
);
final fill = Paint()..color = const Color(0xFFE65100);
final rrect = RRect.fromRectAndRadius(
const Rect.fromLTWH(8, 8, 109, 109),
const Radius.circular(20),
);
canvas.drawRRect(rrect, fill);
final stroke = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 5;
canvas.drawRRect(rrect, stroke);
final picture = recorder.endRecording();
final image = await picture.toImage(125, 125);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
}

Future<void> _loadCustomMarkers() async {
try {
// Load stop icons
Expand All @@ -427,6 +501,8 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
_getOn = await resizeImage(await rootBundle.load('assets/getOn.png'));
_getOff = await resizeImage(await rootBundle.load('assets/getOff.png'));

await _loadBusWestPlaceholderIcon();

// Load route specific bus icons
await _loadRouteSpecificBusIcons();

Expand Down Expand Up @@ -978,34 +1054,21 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
}

void _updateDisplayedBuses(List<Bus> 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),
);
}
final busIcon = _busMarkerIconResolved(bus, routeColor);

return Marker(
flat: true,
markerId: MarkerId('bus_${bus.id}'),
consumeTapEvents: true,
position: bus.position,
icon: busIcon!,
icon: busIcon,
rotation: bus.heading,
anchor: const Offset(0.5, 0.5), // Center the icon on the position
onTap: () {
Expand All @@ -1026,24 +1089,15 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
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),
);
}
final busIcon = _busMarkerIconResolved(bus, routeColor);

_displayedJourneyBusMarkers.add(
Marker(
flat: true,
markerId: MarkerId('journey_bus_${bus.id}'),
consumeTapEvents: true,
position: bus.position,
icon: busIcon!,
icon: busIcon,
rotation: bus.heading,
anchor: const Offset(0.5, 0.5),
onTap: () => _showBusSheet(bus.id),
Expand Down Expand Up @@ -1163,10 +1217,7 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
Marker _createBusMarker(Bus bus) {
final routeColor =
bus.routeColor ?? RouteColorService.getRouteColor(bus.routeId);
final icon =
_routeBusIcons[bus.routeId] ??
_busIcon ??
BitmapDescriptor.defaultMarkerWithHue(_colorToHue(routeColor));
final icon = _busMarkerIconResolved(bus, routeColor);
return Marker(
flat: true,
markerId: MarkerId('bus_${bus.id}'),
Expand Down Expand Up @@ -1901,6 +1952,23 @@ class _MaizeBusCoreState extends State<MaizeBusCore> {
);
}
},
onSelectBuilding: (Location location) {
_showBuildingSheet(location);
},
onBuildingGetDirections: (Location location) {
Map<String, double>? start;
Map<String, double>? end = {
'lat': location.latlng!.latitude,
'lon': location.latlng!.longitude,
};
_showDirectionsSheet(
start,
end,
"Current Location",
location.name,
false,
);
},
onUnfavorite: (stpid) {
// update in memory and marker icons immediately
setState(() {
Expand Down
105 changes: 105 additions & 0 deletions lib/util/favorite_building_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'dart:convert';

import 'package:google_maps_flutter/google_maps_flutter.dart';

import '../constants.dart';

const String kFavoriteBuildingsPrefsKey = 'favorite_buildings';
// Function to get the storage id for a building
String favoriteBuildingStorageId(Location b) {
final ll = b.latlng;
if (ll != null) {
return 'geo:${ll.latitude.toStringAsFixed(6)},${ll.longitude.toStringAsFixed(6)}';
}
return 'name:${b.name}';
}

// Function to encode a building into a string
String encodeFavoriteBuilding(Location b) {
final ll = b.latlng;
return jsonEncode({
'id': favoriteBuildingStorageId(b),
'name': b.name,
'abbrev': b.abbrev,
if (ll != null) 'lat': ll.latitude,
if (ll != null) 'lon': ll.longitude,
});
}

//Checks if the stored string is a valid favorite building entry, if not return null.
String? favoriteBuildingEntryId(String stored) {
try {
final m = jsonDecode(stored) as Map<String, dynamic>;
return m['id'] as String?;
} catch (_) {
if (stored.startsWith('geo:') || stored.startsWith('name:')) {
return stored;
}
return null;
}
}

class FavoriteBuildingEntry {
final String raw;
final String id;
final String name;
final String abbrev;
final double? lat;
final double? lon;

const FavoriteBuildingEntry({
required this.raw,
required this.id,
required this.name,
required this.abbrev,
required this.lat,
required this.lon,
});
// Function to convert a building to a location
Location? toLocation() {
if (lat == null || lon == null) return null;
return Location(
name,
abbrev,
const [],
false,
latlng: LatLng(lat!, lon!),
);
}
}
// Function to decode a building from a string
FavoriteBuildingEntry? decodeFavoriteBuildingEntry(String stored) {
try {
final m = jsonDecode(stored) as Map<String, dynamic>;
final id = m['id'] as String?;
if (id == null) return null;
return FavoriteBuildingEntry(
raw: stored,
id: id,
name: m['name'] as String? ?? 'Building',
abbrev: m['abbrev'] as String? ?? '',
lat: (m['lat'] as num?)?.toDouble(),
lon: (m['lon'] as num?)?.toDouble(),
);
} catch (_) {
return _decodeLegacyFavoriteBuildingEntry(stored);
}
}
//if the json fails since its not a json then this function cehcks is this old geo: format pulls the lat and lon so that the UI can display.
FavoriteBuildingEntry? _decodeLegacyFavoriteBuildingEntry(String stored) {
if (!stored.startsWith('geo:')) return null;
final rest = stored.substring(4);
final comma = rest.indexOf(',');
if (comma <= 0 || comma >= rest.length - 1) return null;
final lat = double.tryParse(rest.substring(0, comma));
final lon = double.tryParse(rest.substring(comma + 1));
if (lat == null || lon == null) return null;
return FavoriteBuildingEntry(
raw: stored,
id: stored,
name: 'Saved building',
abbrev: '',
lat: lat,
lon: lon,
);
}
Loading