Allow users to pick stops on the map and sidebar (#10)

- download all stops on startup, sort by route
- refactored lat/long textboxes into custom widget
- added a button to show the stops list
- sidebar to filter which routes can show
- clicking on a stop or the map adds that marker to the start/end
This commit is contained in:
Levi Lesches 2025-05-02 00:09:00 -04:00 committed by GitHub
parent cd5491e6ec
commit 8fdb25f15e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 361 additions and 113 deletions

View file

@ -1,13 +1,20 @@
import "trip.dart";
import "utils.dart";
extension type StopID(String id) { }
class Stop {
final double latitude;
final double longitude;
final List<TripID> trips;
final StopID id;
final String name;
final String description;
final Coordinates coordinates;
final String provider;
final List<String> routes;
Stop.fromJson(Json json) :
latitude = json["latitude"],
longitude = json["longitude"],
trips = json["trips"];
id = StopID(json["id"]),
name = json["name"],
description = json["description"],
coordinates = (lat: json["latitude"], long: json["longitude"]),
provider = json["provider"],
routes = (json["routes"] as List).cast<String>();
}

View file

@ -3,7 +3,6 @@ import "utils.dart";
import "package:flutter/material.dart" show TimeOfDay;
extension type TripID(String id) { }
extension type StopID(String id) { }
class Trip {
final List<TripStop> stops;

View file

@ -1,3 +1,5 @@
import "package:google_maps_flutter/google_maps_flutter.dart";
/// A JSON object
typedef Json = Map<String, dynamic>;
@ -28,3 +30,7 @@ extension ListUtils<E> on List<E> {
}
}
}
extension CoordinatesUtils on Coordinates {
LatLng toLatLng() => LatLng(lat, long);
}

View file

@ -13,60 +13,68 @@ class HomePage extends ReactiveWidget<HomeModel> {
@override
Widget build(BuildContext context, HomeModel model) => Scaffold(
appBar: AppBar(title: const Text("Counter")),
body: Column(
body: Row(
children: [
SizedBox(
width: 300,
child: Row(
AnimatedContainer(
duration: Durations.short4,
width: model.shouldShowMarkers ? 250 : 0,
child: Card(
clipBehavior: Clip.hardEdge,
elevation: 8,
child: Column(
children: [
Text(
"Select routes",
maxLines: 1,
style: context.textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
"Or click anywhere on the map",
maxLines: 1,
style: context.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const Divider(),
Expanded(
child: TextField(
controller: model.startLatitudeController,
decoration: const InputDecoration(
hintText: "Start latitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: model.startLongitudeController,
decoration: const InputDecoration(
hintText: "Start longitude",
border: OutlineInputBorder(),
),
child: ListView(
children: [
for (final route in model.routeNames) ListTile(
title: Text(route, maxLines: 1),
subtitle: const Text("BC Transit", maxLines: 1),
// Can't use CheckboxListTile, since we must remove the
// checkboxes manually to prevent layour errors
trailing: !model.shouldShowMarkers ? null : Checkbox(
value: model.routesToShow.contains(route),
onChanged: (value) => model.showRoute(route, shouldShow: value!),
),
),
],
),
),
],
),
),
),
Expanded(
child: Column(
children: [
LatLongEditor(
latitudeController: model.startLatitudeController,
longitudeController: model.startLongitudeController,
label: "Start at",
isShowingMarkers: model.markerState == MarkerState.start,
showMarkers: () => model.showMarkers(MarkerState.start),
hideMarkers: model.hideMarkers,
),
const SizedBox(height: 12),
SizedBox(
width: 300,
child: Row(
children: [
Expanded(
child: TextField(
controller: model.endLatitudeController,
decoration: const InputDecoration(
hintText: "End latitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: model.endLongitudeController,
decoration: const InputDecoration(
hintText: "End Longitude",
border: OutlineInputBorder(),
),
),
),
],
),
LatLongEditor(
latitudeController: model.endLatitudeController,
longitudeController: model.endLongitudeController,
label: "End at",
isShowingMarkers: model.markerState == MarkerState.end,
showMarkers: () => model.showMarkers(MarkerState.end),
hideMarkers: model.hideMarkers,
),
const SizedBox(height: 24),
FilledButton(onPressed: model.search, child: const Text("Search for a path")),
@ -85,10 +93,13 @@ class HomePage extends ReactiveWidget<HomeModel> {
Expanded(
child: model.isGoogleReady
? GoogleMap(
onMapCreated: (controller) => model.mapController = controller,
initialCameraPosition: const CameraPosition(
target: LatLng(42.10125081757972, -75.94181323552698),
zoom: 14,
),
markers: model.markers,
onTap: model.onMapTapped,
polylines: {
for (final (index, route) in model.routes.indexed) Polyline(
polylineId: PolylineId(index.toString()),
@ -101,6 +112,9 @@ class HomePage extends ReactiveWidget<HomeModel> {
),
],
),
),
],
),
);
}

View file

@ -1,15 +1,13 @@
import "dart:convert";
import "package:flutter/foundation.dart" show debugPrint, kDebugMode;
import "package:http/http.dart" as http;
import "package:client/data.dart";
import "api_client.dart";
import "service.dart";
class ApiService extends Service {
static const usingDocker = !kDebugMode;
final client = http.Client();
final _client = ApiClient();
@override
Future<void> init() async {
@ -20,31 +18,16 @@ class ApiService extends Service {
? Uri(path: "api/")
: Uri(scheme: "http", host: "localhost", port: 80);
Future<List<T>?> _getAll<T>(Uri uri, FromJson<T> fromJson) async {
try {
final response = await client.get(uri);
if (response.statusCode != 200) return null;
final json = jsonDecode(response.body)["path"] as List;
return [
for (final obj in json.cast<Json>())
fromJson(obj),
];
} catch (error, stackTrace) {
// ignore: avoid_print
print("Error: $error\n$stackTrace");
return null;
}
}
Future<List<Trip>?> getTrips() => _getAll(
Future<List<Trip>?> getTrips() => _client.getJsonList(
_base.resolve("trips"),
Trip.fromJson,
key: "path",
);
Future<Path?> getPath({
required Coordinates start,
required Coordinates end,
}) => _getAll(
}) => _client.getJsonList(
_base.resolve("path").replace(
queryParameters: {
"start_lat": start.lat.toString(),
@ -54,8 +37,12 @@ class ApiService extends Service {
"time": DateTime.now().millisecondsSinceEpoch.toString(),
},
),
key: "path",
PathStep.fromJson,
);
Future<List<Stop>?> getStops() => _client.getJsonList(
_base.resolve("stops"),
Stop.fromJson,
);
}

View file

@ -0,0 +1,28 @@
import "dart:convert";
import "package:client/data.dart";
import "package:http/http.dart" as http;
class ApiClient {
final client = http.Client();
Future<List<T>?> getJsonList<T>(Uri uri, FromJson<T> fromJson, {String? key}) async {
try {
final response = await client.get(uri);
if (response.statusCode != 200) return null;
// No key: Assume the entire body is a list
// With key: Assume the body is a map with a list at the key
final listOfJson = key == null
? (jsonDecode(response.body) as List).cast<Json>()
: ((jsonDecode(response.body) as Json)[key] as List).cast<Json>();
return [
for (final json in listOfJson)
fromJson(json),
];
} catch (error, stackTrace) {
// ignore: avoid_print
print("Error: $error\n$stackTrace");
return null;
}
}
}

View file

@ -1,21 +1,23 @@
import "dart:async";
import "package:client/data.dart";
import "package:client/services.dart";
import "package:flutter/widgets.dart" hide Path;
import "package:google_maps_flutter/google_maps_flutter.dart";
import "home_markers.dart";
import "view_model.dart";
typedef LatLng2 = (double lat, double lng);
/// The view model for the home page.
class HomeModel extends ViewModel {
class HomeModel extends ViewModel with HomeMarkers {
final startLatitudeController = TextEditingController();
final startLongitudeController = TextEditingController();
final endLatitudeController = TextEditingController();
final endLongitudeController = TextEditingController();
Path? path;
GoogleMapController? mapController;
double? get startLatitude => double.tryParse(startLatitudeController.text);
double? get startLongitude => double.tryParse(startLongitudeController.text);
@ -24,15 +26,43 @@ class HomeModel extends ViewModel {
bool isSearching = false;
bool isGoogleReady = false;
List<List<LatLng>> routes = [];
@override
Future<void> init() async {
await Future<void>.delayed(const Duration(seconds: 2));
isGoogleReady = true;
notifyListeners();
await updateMarkers();
}
List<List<LatLng>> routes = [];
void onMapTapped(LatLng coordinates) {
if (!shouldShowMarkers) return;
final marker = Marker(
markerId: markerId,
position: coordinates,
icon: markerIcon,
infoWindow: InfoWindow(title: "$startOrEnd here"),
);
switch (markerState!) {
case MarkerState.start: updateStart(coordinates, marker);
case MarkerState.end: updateEnd(coordinates, marker);
}
}
@override
void updateStart(LatLng coordinates, Marker marker) {
startLatitudeController.text = coordinates.latitude.toString();
startLongitudeController.text = coordinates.longitude.toString();
super.updateStart(coordinates, marker);
}
@override
void updateEnd(LatLng coordinates, Marker marker) {
endLatitudeController.text = coordinates.latitude.toString();
endLongitudeController.text = coordinates.longitude.toString();
super.updateEnd(coordinates, marker);
}
Future<void> search() async {
final start = (lat: startLatitude, long: startLongitude);

View file

@ -0,0 +1,115 @@
import "package:client/data.dart";
import "package:client/services.dart";
import "package:flutter/foundation.dart";
import "package:google_maps_flutter/google_maps_flutter.dart";
enum MarkerState {
start,
end;
}
mixin HomeMarkers on ChangeNotifier {
MarkerState? markerState;
bool get shouldShowMarkers => markerState != null;
List<Stop>? stops;
Marker? _startMarker;
Marker? _endMarker;
Set<Marker> get _filteredMarkers => {
for (final stop in stops ?? <Stop>[])
if (stop.routes.any(routesToShow.contains))
Marker(
markerId: MarkerId(stop.name),
position: stop.coordinates.toLatLng(),
onTap: () => onMarkerTapped(stop),
consumeTapEvents: true,
infoWindow: InfoWindow(
title: stop.name,
snippet: "${stop.description}\n\nRoutes: ${stop.routes.join("\n")}",
),
),
};
Set<Marker> get markers => shouldShowMarkers ? _filteredMarkers : {
if (_startMarker != null) _startMarker!,
if (_endMarker != null) _endMarker!,
};
Set<String> routeNames = {};
Set<String> routesToShow = {};
MarkerId get markerId => switch (markerState!) {
MarkerState.start => const MarkerId("start"),
MarkerState.end => const MarkerId("end"),
};
BitmapDescriptor get markerIcon => switch (markerState!) {
MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
};
String get startOrEnd => switch (markerState!) {
MarkerState.start => "Start",
MarkerState.end => "End",
};
Future<void> updateMarkers() async {
stops = await services.api.getStops();
if (stops == null) return;
for (final stop in stops!) {
routeNames.addAll(stop.routes);
}
notifyListeners();
}
void showMarkers(MarkerState newState) {
markerState = newState;
notifyListeners();
}
void hideMarkers() {
markerState = null;
notifyListeners();
}
void onMarkerTapped(Stop stop) {
final coordinates = stop.coordinates.toLatLng();
final marker = Marker(
markerId: markerId,
position: coordinates,
infoWindow: InfoWindow(
title: "$startOrEnd at ${stop.name}",
snippet: stop.description,
),
icon: markerIcon,
);
switch (markerState!) {
case MarkerState.start: updateStart(coordinates, marker);
case MarkerState.end: updateEnd(coordinates, marker);
}
}
@mustCallSuper
void updateStart(LatLng coordinates, Marker marker) {
_startMarker = marker;
markerState = null;
notifyListeners();
}
@mustCallSuper
void updateEnd(LatLng coordinates, Marker marker) {
_endMarker = marker;
markerState = null;
notifyListeners();
}
void showRoute(String route, {required bool shouldShow}) {
if (shouldShow) {
routesToShow.add(route);
} else {
routesToShow.remove(route);
}
notifyListeners();
}
}

View file

@ -0,0 +1,60 @@
import "package:client/widgets.dart";
import "package:flutter/material.dart";
class LatLongEditor extends StatelessWidget {
final TextEditingController latitudeController;
final TextEditingController longitudeController;
final String label;
final VoidCallback showMarkers;
final VoidCallback hideMarkers;
final bool isShowingMarkers;
const LatLongEditor({
required this.latitudeController,
required this.longitudeController,
required this.label,
required this.isShowingMarkers,
required this.showMarkers,
required this.hideMarkers,
super.key,
});
@override
Widget build(BuildContext context) => SizedBox(
width: 400,
child: Row(
children: [
Text(label, style: context.textTheme.titleSmall),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: latitudeController,
decoration: const InputDecoration(
hintText: "Latitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: longitudeController,
decoration: const InputDecoration(
hintText: "Longitude",
border: OutlineInputBorder(),
),
),
),
if (isShowingMarkers) IconButton(
icon: const Icon(Icons.location_on),
onPressed: hideMarkers,
tooltip: "Hide stops on map",
) else IconButton(
icon: const Icon(Icons.location_off),
onPressed: showMarkers,
tooltip: "Show stops on map",
),
],
),
);
}

View file

@ -1,3 +1,4 @@
export "src/view_models/view_model.dart";
export "src/view_models/home_markers.dart";
export "src/view_models/home.dart";

View file

@ -2,7 +2,8 @@ import "package:flutter/material.dart";
export "package:go_router/go_router.dart";
export "src/widgets/generic/reactive_widget.dart";
export "src/widgets/lat_long_editor.dart";
export "src/widgets/reactive_widget.dart";
/// Helpful methods on [BuildContext].
extension ContextUtils on BuildContext {