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:
parent
cd5491e6ec
commit
8fdb25f15e
12 changed files with 361 additions and 113 deletions
|
@ -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>();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
28
src/client/lib/src/services/api_client.dart
Normal file
28
src/client/lib/src/services/api_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
115
src/client/lib/src/view_models/home_markers.dart
Normal file
115
src/client/lib/src/view_models/home_markers.dart
Normal 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();
|
||||
}
|
||||
}
|
60
src/client/lib/src/widgets/lat_long_editor.dart
Normal file
60
src/client/lib/src/widgets/lat_long_editor.dart
Normal 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",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export "src/view_models/view_model.dart";
|
||||
|
||||
export "src/view_models/home_markers.dart";
|
||||
export "src/view_models/home.dart";
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue