Fix merge conflicts from main to shared_integration
This commit is contained in:
commit
e1f5b4967e
30 changed files with 2536 additions and 2366 deletions
|
@ -16,13 +16,19 @@ class HomePage extends ReactiveWidget<HomeModel> {
|
|||
body: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
width: 325,
|
||||
child: Sidebar(model),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: model.markerState == MarkerState.override,
|
||||
onChanged: model.overrideMarkers,
|
||||
title: const Text("Show stops list"),
|
||||
subtitle: const Text("To select a start or end stop, use the buttons below"),
|
||||
),
|
||||
LatLongEditor(
|
||||
latitudeController: model.startLatitudeController,
|
||||
longitudeController: model.startLongitudeController,
|
||||
|
@ -65,7 +71,7 @@ class HomePage extends ReactiveWidget<HomeModel> {
|
|||
markers: model.markers,
|
||||
onTap: model.onMapTapped,
|
||||
polylines: {
|
||||
for (final (index, route) in model.routes.indexed) Polyline(
|
||||
for (final (index, route) in model.paths.indexed) Polyline(
|
||||
polylineId: PolylineId(index.toString()),
|
||||
color: routeColors[index],
|
||||
points: route,
|
||||
|
|
|
@ -17,32 +17,9 @@ class ApiService extends Service {
|
|||
|
||||
Uri get _base => usingDocker
|
||||
? Uri(path: "api/")
|
||||
: Uri(scheme: "http", host: "localhost", port: 80);
|
||||
: Uri(scheme: "http", host: "localhost", port: 8001);
|
||||
|
||||
Future<List<Trip>?> getTrips() => _client.getJsonList(
|
||||
_base.resolve("trips"),
|
||||
Trip.fromJson,
|
||||
key: "path",
|
||||
);
|
||||
|
||||
Future<Path?> getPath({
|
||||
required Coordinates start,
|
||||
required Coordinates end,
|
||||
}) => _client.getJsonList(
|
||||
_base.resolve("path").replace(
|
||||
queryParameters: {
|
||||
"start_lat": start.lat.toString(),
|
||||
"start_lon": start.long.toString(),
|
||||
"end_lat": end.lat.toString(),
|
||||
"end_lon": end.long.toString(),
|
||||
"time": DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
),
|
||||
key: "path",
|
||||
PathStep.fromJson,
|
||||
);
|
||||
|
||||
Future<String?> getPath2({
|
||||
Future<String?> getPath({
|
||||
required Coordinates start,
|
||||
required Coordinates end,
|
||||
}) async {
|
||||
|
@ -64,8 +41,13 @@ class ApiService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
Future<List<Route>?> getRoutes() => _client.getJsonList(
|
||||
_base.resolve("/routes"),
|
||||
Route.fromJson,
|
||||
);
|
||||
|
||||
Future<List<Stop>?> getStops() => _client.getJsonList(
|
||||
_base.resolve("stops"),
|
||||
_base.resolve("/stops"),
|
||||
Stop.fromJson,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ class HomeModel extends ViewModel with HomeMarkers {
|
|||
|
||||
bool isSearching = false;
|
||||
bool isGoogleReady = false;
|
||||
List<List<LatLng>> routes = [];
|
||||
List<List<LatLng>> paths = [];
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
|
@ -39,14 +39,15 @@ class HomeModel extends ViewModel with HomeMarkers {
|
|||
void onMapTapped(LatLng coordinates) {
|
||||
if (!shouldShowMarkers) return;
|
||||
final marker = Marker(
|
||||
markerId: markerId,
|
||||
markerId: markerId ?? const MarkerId("Tapped"),
|
||||
position: coordinates,
|
||||
icon: markerIcon,
|
||||
icon: markerIcon ?? BitmapDescriptor.defaultMarker,
|
||||
infoWindow: InfoWindow(title: "$startOrEnd here"),
|
||||
);
|
||||
switch (markerState!) {
|
||||
case MarkerState.start: updateStart(coordinates, marker);
|
||||
case MarkerState.end: updateEnd(coordinates, marker);
|
||||
case MarkerState.override: return;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,17 +73,11 @@ class HomeModel extends ViewModel with HomeMarkers {
|
|||
if (end.lat == null || end.long == null) return;
|
||||
isSearching = true;
|
||||
isLoading = true;
|
||||
final result = await services.api.getPath2(start: start as Coordinates, end: end as Coordinates);
|
||||
final result = await services.api.getPath(start: start as Coordinates, end: end as Coordinates);
|
||||
pathText = result ?? "An error occurred";
|
||||
// path = result;
|
||||
isLoading = false;
|
||||
if (result == null) return;
|
||||
isSearching = false;
|
||||
// routes = [
|
||||
// for (final step in result) [
|
||||
// ...decodePolyline(step.trip.polyline),
|
||||
// ],
|
||||
// ];
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,20 +5,27 @@ import "package:google_maps_flutter/google_maps_flutter.dart";
|
|||
|
||||
enum MarkerState {
|
||||
start,
|
||||
end;
|
||||
end,
|
||||
override;
|
||||
}
|
||||
|
||||
mixin HomeMarkers on ChangeNotifier {
|
||||
MarkerState? markerState;
|
||||
bool get shouldShowMarkers => markerState != null;
|
||||
|
||||
List<Stop>? stops;
|
||||
// List<Stop>? stops;
|
||||
Map<StopID, Stop> stops = {};
|
||||
Map<RouteID, Route> routes = {};
|
||||
Marker? _startMarker;
|
||||
Marker? _endMarker;
|
||||
|
||||
Iterable<Stop> getStopsForRoute(RouteID routeID) => routes[routeID]!.stops
|
||||
.map((stopID) => stops[stopID]!);
|
||||
|
||||
Set<Marker> get _filteredMarkers => {
|
||||
for (final stop in stops ?? <Stop>[])
|
||||
if (stop.routes.any(routesToShow.contains))
|
||||
// for (final stop in stops ?? <Stop>[])
|
||||
for (final routeID in routesToShow)
|
||||
for (final stop in getStopsForRoute(routeID))
|
||||
Marker(
|
||||
markerId: MarkerId(stop.name),
|
||||
position: stop.coordinates.toLatLng(),
|
||||
|
@ -36,55 +43,67 @@ mixin HomeMarkers on ChangeNotifier {
|
|||
if (_endMarker != null) _endMarker!,
|
||||
};
|
||||
|
||||
Set<String> bcRouteNames = {};
|
||||
Set<String> occtRouteNames = {};
|
||||
Set<String> routesToShow = {};
|
||||
Map<String, int> stopCounts = {};
|
||||
Iterable<(String, List<String>)> get providers => [
|
||||
("OCCT", occtRouteNames.toList()..sort()),
|
||||
("BC Transit", bcRouteNames.toList()..sort(compareNums)),
|
||||
List<Route> bcRouteNames = [];
|
||||
List<Route> occtRouteNames = [];
|
||||
Set<RouteID> routesToShow = {};
|
||||
Iterable<(String, List<Route>)> get providers => [
|
||||
("OCCT", occtRouteNames),
|
||||
("BC Transit", bcRouteNames),
|
||||
];
|
||||
|
||||
int parseBcNumber(String routeName) {
|
||||
int _parseBcNumber(String routeName) {
|
||||
// eg, "53)" --> 53
|
||||
final first = routeName.split(" ").first;
|
||||
final withoutParen = first.substring(0, first.length - 1);
|
||||
return int.parse(withoutParen);
|
||||
}
|
||||
|
||||
int compareNums(String a, String b) =>
|
||||
parseBcNumber(a).compareTo(parseBcNumber(b));
|
||||
int compareBcRoutes(Route a, Route b) =>
|
||||
_parseBcNumber(a.shortName).compareTo(_parseBcNumber(b.shortName));
|
||||
|
||||
MarkerId get markerId => switch (markerState!) {
|
||||
int compareOcctRoutes(Route a, Route b) =>
|
||||
a.shortName.compareTo(b.shortName);
|
||||
|
||||
MarkerId? get markerId => switch (markerState!) {
|
||||
MarkerState.start => const MarkerId("start"),
|
||||
MarkerState.end => const MarkerId("end"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
BitmapDescriptor get markerIcon => switch (markerState!) {
|
||||
BitmapDescriptor? get markerIcon => switch (markerState!) {
|
||||
MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
|
||||
MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String get startOrEnd => switch (markerState!) {
|
||||
String? get startOrEnd => switch (markerState!) {
|
||||
MarkerState.start => "Start",
|
||||
MarkerState.end => "End",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
Future<void> updateMarkers() async {
|
||||
stops = await services.api.getStops();
|
||||
if (stops == null) return;
|
||||
for (final stop in stops!) {
|
||||
for (final route in stop.routes) {
|
||||
stopCounts[route] ??= 0;
|
||||
stopCounts[route] = stopCounts[route]! + 1;
|
||||
}
|
||||
final namesList = switch (stop.provider) {
|
||||
final stopsResponse = await services.api.getStops();
|
||||
if (stopsResponse == null) return;
|
||||
stops = {
|
||||
for (final stop in stopsResponse)
|
||||
stop.id: stop,
|
||||
};
|
||||
final routesResponse = await services.api.getRoutes();
|
||||
if (routesResponse == null) return;
|
||||
routes = {
|
||||
for (final route in routesResponse)
|
||||
route.id: route,
|
||||
};
|
||||
for (final route in routesResponse) {
|
||||
final namesList = switch (route.provider) {
|
||||
Provider.occt => occtRouteNames,
|
||||
Provider.bc => bcRouteNames,
|
||||
};
|
||||
namesList.addAll(stop.routes);
|
||||
namesList.add(route);
|
||||
}
|
||||
notifyListeners();
|
||||
occtRouteNames.sort(compareOcctRoutes);
|
||||
bcRouteNames.sort(compareBcRoutes);
|
||||
}
|
||||
|
||||
void showMarkers(MarkerState newState) {
|
||||
|
@ -100,20 +119,28 @@ mixin HomeMarkers on ChangeNotifier {
|
|||
void onMarkerTapped(Stop stop) {
|
||||
final coordinates = stop.coordinates.toLatLng();
|
||||
final marker = Marker(
|
||||
markerId: markerId,
|
||||
markerId: markerId ?? MarkerId(stop.name),
|
||||
position: coordinates,
|
||||
infoWindow: InfoWindow(
|
||||
title: "$startOrEnd at ${stop.name}",
|
||||
snippet: stop.description,
|
||||
),
|
||||
icon: markerIcon,
|
||||
icon: markerIcon ?? BitmapDescriptor.defaultMarker,
|
||||
);
|
||||
switch (markerState!) {
|
||||
case MarkerState.start: updateStart(coordinates, marker);
|
||||
case MarkerState.end: updateEnd(coordinates, marker);
|
||||
case MarkerState.override: return;
|
||||
}
|
||||
}
|
||||
|
||||
// Flutter widget
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
void overrideMarkers(bool value) {
|
||||
markerState = value ? MarkerState.override : null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
void updateStart(LatLng coordinates, Marker marker) {
|
||||
_startMarker = marker;
|
||||
|
@ -128,11 +155,11 @@ mixin HomeMarkers on ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
void showRoute(String route, {required bool shouldShow}) {
|
||||
void showRoute(Route route, {required bool shouldShow}) {
|
||||
if (shouldShow) {
|
||||
routesToShow.add(route);
|
||||
routesToShow.add(route.id);
|
||||
} else {
|
||||
routesToShow.remove(route);
|
||||
routesToShow.remove(route.id);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
|
|||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
if (model.markerState != null && model.markerState != MarkerState.override) ...[
|
||||
Text(
|
||||
"Select routes",
|
||||
maxLines: 1,
|
||||
|
@ -25,6 +26,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
|
|||
style: context.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
if (model.shouldShowMarkers) TabBar(
|
||||
tabs: [
|
||||
|
@ -33,7 +35,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
|
|||
],
|
||||
),
|
||||
if (!model.shouldShowMarkers && model.pathText != null)
|
||||
Center(child: Text(model.pathText!, style: context.textTheme.bodySmall)),
|
||||
SingleChildScrollView(child: Center(child: Text(model.pathText!, style: context.textTheme.bodySmall))),
|
||||
if (!model.shouldShowMarkers && model.pathText == null)
|
||||
const Center(child: Text("Choose start or end location")),
|
||||
if (model.shouldShowMarkers) Expanded(
|
||||
|
@ -43,12 +45,12 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
|
|||
ListView(
|
||||
children: [
|
||||
for (final route in routesList) CheckboxListTile(
|
||||
title: Text(route, maxLines: 1),
|
||||
title: Text(route.fullName, maxLines: 1),
|
||||
subtitle: Text(
|
||||
"${model.stopCounts[route] ?? 0} stops",
|
||||
"${route.stops.length} stops",
|
||||
maxLines: 1,
|
||||
),
|
||||
value: model.routesToShow.contains(route),
|
||||
value: model.routesToShow.contains(route.id),
|
||||
onChanged: (value) => model.showRoute(route, shouldShow: value!),
|
||||
),
|
||||
],
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2
src/shared/.gitignore
vendored
2
src/shared/.gitignore
vendored
|
@ -4,3 +4,5 @@
|
|||
pubspec.lock
|
||||
client-dir
|
||||
server-data
|
||||
path.log
|
||||
notes.md
|
||||
|
|
|
@ -39,4 +39,4 @@ WORKDIR /client/shared
|
|||
|
||||
EXPOSE 80/tcp
|
||||
|
||||
CMD ["dart", "bin/path.dart"]
|
||||
CMD ["dart", "bin/server.dart"]
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import "package:shared/generator.dart";
|
||||
|
||||
void main() async {
|
||||
void main(List<String> args) async {
|
||||
if (args.contains("-n")) { // dry-run
|
||||
await Generator.stops.parse();
|
||||
await Generator.routes.parse();
|
||||
} else {
|
||||
await Generator.stops.generate();
|
||||
await Generator.routes.generate();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,147 +0,0 @@
|
|||
// User-facing script
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import "package:a_star/a_star.dart";
|
||||
import "package:shared/generator.dart";
|
||||
import "package:shared/graph.dart";
|
||||
|
||||
import "package:shelf_router/shelf_router.dart";
|
||||
import "package:shelf/shelf.dart";
|
||||
import "package:shelf/shelf_io.dart" as io;
|
||||
|
||||
// const startLat = 42.0924949645996;
|
||||
// const startLong = -75.9538421630859;
|
||||
// const endLat = 42.0869369506836;
|
||||
// const endLong = -75.965934753418;
|
||||
|
||||
const startLat = 42.092083;
|
||||
const startLong = -75.952271;
|
||||
const endLat = 42.080822;
|
||||
const endLong = -75.912529;
|
||||
|
||||
const numStops = 5;
|
||||
|
||||
void log(String message) {
|
||||
// print(message);
|
||||
}
|
||||
|
||||
Iterable<Stop> findStopsNear(Coordinates location) {
|
||||
log("Finding stops near $location...");
|
||||
final stopDistances = [
|
||||
for (final stop in StopState.stops.values)
|
||||
(stop, stop.coordinates.distanceTo(location)),
|
||||
];
|
||||
stopDistances.sort((a, b) => a.$2.compareTo(b.$2));
|
||||
return stopDistances.take(numStops).map((pair) => pair.$1);
|
||||
}
|
||||
|
||||
void printStops(Iterable<Stop> stops) {
|
||||
log("Found stops:");
|
||||
for (final stop in stops) {
|
||||
log("- ${stop.routes}, ${stop.name}");
|
||||
}
|
||||
}
|
||||
|
||||
Set<RouteID> findRoutes(Iterable<Stop> stops) => {
|
||||
for (final stop in stops)
|
||||
...stop.routeIDs,
|
||||
};
|
||||
|
||||
Stop findStopWithRoute(Iterable<Stop> stops, RouteID route) =>
|
||||
stops.firstWhere((stop) => stop.routeIDs.contains(route));
|
||||
|
||||
Future<void> init() async {
|
||||
print("Initializing...");
|
||||
final stops = await Generator.stops.parse();
|
||||
final routes = await Generator.routes.parse();
|
||||
StopState.init(routes, stops);
|
||||
}
|
||||
|
||||
final headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
};
|
||||
|
||||
Future<Response> handleRequest(Request request) async {
|
||||
final startLat = double.tryParse(request.url.queryParameters["start_lat"] ?? "");
|
||||
final startLong = double.tryParse(request.url.queryParameters["start_lon"] ?? "");
|
||||
final endLat = double.tryParse(request.url.queryParameters["end_lat"] ?? "");
|
||||
final endLong = double.tryParse(request.url.queryParameters["end_lon"] ?? "");
|
||||
if (startLat == null || startLong == null || endLat == null || endLong == null) {
|
||||
return Response.badRequest(body: "Could not parse coordinates", headers: headers);
|
||||
}
|
||||
|
||||
final start = (lat: startLat, long: startLong);
|
||||
final end = (lat: endLat, long: endLong);
|
||||
|
||||
// 1) Find [numStops] nearest stops by the given locations
|
||||
log("Finding nearest stops...");
|
||||
final startStops = findStopsNear(start);
|
||||
printStops(startStops);
|
||||
final endStops = findStopsNear(end);
|
||||
printStops(endStops);
|
||||
|
||||
// 2) Find all routes by those stops
|
||||
log("Finding intersecting routes");
|
||||
final startRoutes = findRoutes(startStops);
|
||||
final endRoutes = findRoutes(endStops);
|
||||
log("Start routes: $startRoutes");
|
||||
log("End routes: $endRoutes");
|
||||
|
||||
// 3) Check for a direct route without transfers
|
||||
final routesInCommon = startRoutes.intersection(endRoutes);
|
||||
if (routesInCommon.isNotEmpty) {
|
||||
final route = routesInCommon.first;
|
||||
final startStop = findStopWithRoute(startStops, route);
|
||||
final endStop = findStopWithRoute(endStops, route);
|
||||
return Response.ok("\nBoard the $route at ${startStop.name} and get off at ${endStop.name}", headers: headers);
|
||||
}
|
||||
|
||||
// 4) Use A* to find a route with transfers
|
||||
log("Using A*");
|
||||
final paths = <Iterable<StopState>>[];
|
||||
for (final startStop in startStops) {
|
||||
for (final startRoute in startStop.routeIDs) {
|
||||
for (final endStop in endStops) {
|
||||
final state = StopState(stopID: startStop.id, goalID: endStop.id, routeID: startRoute, depth: 0);
|
||||
log("Finding a route using ${state.hash()}");
|
||||
final result = aStar(state);
|
||||
if (result == null) continue;
|
||||
paths.add(result.reconstructPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.isEmpty) {
|
||||
return Response.ok("No routes found", headers: headers);
|
||||
}
|
||||
|
||||
final minPath = paths.min((path) => path.length);
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln("Found ${paths.length} paths, here's the best one with only ${minPath.length} steps:");
|
||||
StopState? prevStep;
|
||||
var index = 0;
|
||||
for (final step in minPath) {
|
||||
index++;
|
||||
final stop = StopState.stops[step.stopID]!;
|
||||
final route = StopState.routes[step.routeID]!;
|
||||
if (prevStep == null) {
|
||||
buffer.writeln("$index. Board the ${route.provider.humanName} ${route.fullName} bus\n at ${stop.name}");
|
||||
} else if (index == minPath.length) {
|
||||
buffer.writeln("$index. Get off at ${stop.name}");
|
||||
} else if (prevStep.routeID != step.routeID) {
|
||||
buffer.writeln("$index. Transfer to the ${route.provider.humanName} ${route.fullName}");
|
||||
} else {
|
||||
buffer.writeln("$index. Go to ${stop.name}");
|
||||
}
|
||||
prevStep = step;
|
||||
}
|
||||
return Response.ok(buffer.toString(), headers: headers);
|
||||
}
|
||||
|
||||
void main() async {
|
||||
await init();
|
||||
final router = Router();
|
||||
router.get("/path", handleRequest);
|
||||
await io.serve(router.call, "0.0.0.0", 8001);
|
||||
print("Listening on 0.0.0.0:8001");
|
||||
}
|
25
src/shared/bin/server.dart
Normal file
25
src/shared/bin/server.dart
Normal file
|
@ -0,0 +1,25 @@
|
|||
// User-facing script
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import "package:shared/generator.dart";
|
||||
import "package:shared/graph.dart";
|
||||
import "package:shared/server.dart" as handlers;
|
||||
|
||||
import "package:shelf_router/shelf_router.dart";
|
||||
import "package:shelf/shelf_io.dart" as io;
|
||||
|
||||
Future<void> init() async {
|
||||
print("Initializing...");
|
||||
StopState.stops = await Generator.stops.parse();
|
||||
StopState.routes = await Generator.routes.parse();
|
||||
}
|
||||
|
||||
void main() async {
|
||||
await init();
|
||||
final router = Router();
|
||||
router.get("/path", handlers.getPath);
|
||||
router.get("/stops", handlers.getStops);
|
||||
router.get("/routes", handlers.getRoutes);
|
||||
await io.serve(router.call, "0.0.0.0", 8001);
|
||||
print("Listening on 0.0.0.0:8001");
|
||||
}
|
18
src/shared/bin/test.dart
Normal file
18
src/shared/bin/test.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
// User-facing script
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import "package:shared/generator.dart";
|
||||
|
||||
void main(List<String> args) async {
|
||||
final provider = args.first;
|
||||
final routeName = args.last;
|
||||
final routes = await Generator.routes.parse();
|
||||
final stops = await Generator.stops.parse();
|
||||
final routeID = RouteID(Provider.fromJson(provider), routeName);
|
||||
final route = routes[routeID]!;
|
||||
print("Here are the stops for $route:");
|
||||
for (final stopID in route.stops) {
|
||||
final stop = stops[stopID]!;
|
||||
print("- $stop");
|
||||
}
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
export "src/graph/state.dart";
|
||||
export "src/graph/algorithm.dart";
|
||||
|
|
2
src/shared/lib/server.dart
Normal file
2
src/shared/lib/server.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
export "src/server/misc.dart";
|
||||
export "src/server/path.dart";
|
|
@ -8,4 +8,7 @@ enum Provider {
|
|||
|
||||
final String id;
|
||||
final String humanName;
|
||||
|
||||
@override
|
||||
String toString() => humanName;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ class Route with Encodable {
|
|||
fullName = json["name"],
|
||||
stops = [
|
||||
for (final stopID in (json["stops"] as List).cast<int>())
|
||||
StopID(stopID.toString()),
|
||||
StopID(Provider.occt, stopID.toString()),
|
||||
];
|
||||
|
||||
Route.fromBcCsv(CsvRow csv) :
|
||||
|
@ -44,6 +44,19 @@ class Route with Encodable {
|
|||
fullName = "${csv[2]}) ${csv[3]}",
|
||||
stops = [];
|
||||
|
||||
Route.fromJson(Json json) :
|
||||
id = RouteID.fromJson(json["id"]),
|
||||
provider = Provider.fromJson(json["provider"]),
|
||||
fullName = json["full_name"],
|
||||
shortName = json["short_name"],
|
||||
stops = [
|
||||
for (final stopID in json["stops"])
|
||||
StopID.fromJson(stopID),
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() => "$provider $fullName";
|
||||
|
||||
@override
|
||||
Json toJson() => {
|
||||
"id": id,
|
||||
|
|
|
@ -2,7 +2,8 @@ import "utils.dart";
|
|||
import "route.dart";
|
||||
import "provider.dart";
|
||||
|
||||
extension type StopID(String id) {
|
||||
extension type StopID._(String id) {
|
||||
StopID(Provider provider, Object value) : id = "${provider.id}_$value";
|
||||
StopID.fromJson(dynamic value) : id = value.toString();
|
||||
}
|
||||
|
||||
|
@ -24,7 +25,7 @@ class Stop with Encodable {
|
|||
}) : routes = {}, routeIDs = {};
|
||||
|
||||
Stop.fromJson(Json json) :
|
||||
id = StopID(json["id"]),
|
||||
id = StopID.fromJson(json["id"]),
|
||||
name = json["name"],
|
||||
description = json["description"],
|
||||
coordinates = (lat: json["latitude"], long: json["longitude"]),
|
||||
|
@ -36,7 +37,7 @@ class Stop with Encodable {
|
|||
};
|
||||
|
||||
Stop.fromOcctJson(Json json) :
|
||||
id = StopID.fromJson(json["id"]),
|
||||
id = StopID(Provider.occt, json["id"]),
|
||||
name = json["name"],
|
||||
description = null,
|
||||
coordinates = (lat: json["lat"], long: json["lng"]),
|
||||
|
@ -44,6 +45,9 @@ class Stop with Encodable {
|
|||
routes = <String>{},
|
||||
routeIDs = <RouteID>{};
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id.id,
|
||||
|
|
|
@ -35,16 +35,27 @@ extension ListUtils<E> on List<E> {
|
|||
yield (i, this[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
E max(int Function(E) count) => reduce((a, b) {
|
||||
extension IterableUtils<E> on Iterable<E> {
|
||||
E max(num Function(E) count) => reduce((a, b) {
|
||||
final numA = count(a);
|
||||
final numB = count(b);
|
||||
return numA > numB ? a : b;
|
||||
});
|
||||
|
||||
E min(int Function(E) count) => reduce((a, b) {
|
||||
E min(num Function(E) count) => reduce((a, b) {
|
||||
final numA = count(a);
|
||||
final numB = count(b);
|
||||
return numA < numB ? a : b;
|
||||
});
|
||||
|
||||
double sum(double Function(E) transform) => fold(
|
||||
0,
|
||||
(total, element) => total + transform(element),
|
||||
);
|
||||
}
|
||||
|
||||
void log(String message) {
|
||||
// print(message);
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@ import "dart:io";
|
|||
|
||||
import "package:shared/generator.dart";
|
||||
|
||||
class Generator<T extends Encodable> extends Parser<T> {
|
||||
final Parser<T> bc;
|
||||
final Parser<T> occt;
|
||||
class Generator<ID, T extends Encodable> extends Parser<ID, T> {
|
||||
final Parser<ID, T> bc;
|
||||
final Parser<ID, T> occt;
|
||||
final File outputFile;
|
||||
|
||||
Generator({
|
||||
|
@ -15,14 +15,15 @@ class Generator<T extends Encodable> extends Parser<T> {
|
|||
});
|
||||
|
||||
@override
|
||||
Future<List<T>> parse() async => [
|
||||
Future<Map<ID, T>> parse() async => {
|
||||
...await bc.parse(),
|
||||
...await occt.parse(),
|
||||
];
|
||||
};
|
||||
|
||||
Future<void> generate() async {
|
||||
final allData = await parse();
|
||||
final result = [
|
||||
for (final data in await parse())
|
||||
for (final data in allData.values)
|
||||
data.toJson(),
|
||||
];
|
||||
const encoder = JsonEncoder.withIndent(" ");
|
||||
|
|
|
@ -5,7 +5,7 @@ import "stops_bc.dart";
|
|||
|
||||
typedef InOutTrips = (TripID, TripID);
|
||||
|
||||
class BcRoutesParser extends Parser<Route> {
|
||||
class BcRoutesParser extends Parser<RouteID, Route> {
|
||||
final routesFile = File(bcDataDir / "routes.txt");
|
||||
final tripsFile = File(bcDataDir / "trips.txt");
|
||||
|
||||
|
@ -31,7 +31,7 @@ class BcRoutesParser extends Parser<Route> {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Iterable<Route>> parse() async {
|
||||
Future<Map<RouteID, Route>> parse() async {
|
||||
final routes = await getRoutes();
|
||||
final trips = await stopParser.getTrips();
|
||||
final tripsForRoutes = await getTripForRoutes(trips);
|
||||
|
@ -39,8 +39,9 @@ class BcRoutesParser extends Parser<Route> {
|
|||
for (final (routeID, route) in routes.records) {
|
||||
final longestTrip = tripsForRoutes[routeID]!;
|
||||
final stops = trips[longestTrip]!;
|
||||
log("Chose $longestTrip as the longest trip for $routeID");
|
||||
route.stops.addAll(stops);
|
||||
}
|
||||
return routes.values;
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@ import "dart:io";
|
|||
|
||||
import "utils.dart";
|
||||
|
||||
class OcctRoutesParser extends Parser<Route> {
|
||||
class OcctRoutesParser extends Parser<RouteID, Route> {
|
||||
final routesFile = File(occtDataDir / "routes.json");
|
||||
|
||||
@override
|
||||
Future<List<Route>> parse() async => [
|
||||
Future<Map<RouteID, Route>> parse() async => {
|
||||
for (final routeJson in await readJson(routesFile))
|
||||
Route.fromOcctJson(routeJson),
|
||||
];
|
||||
RouteID(Provider.occt, routeJson["id"]): Route.fromOcctJson(routeJson),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import "package:csv/csv.dart";
|
|||
|
||||
import "utils.dart";
|
||||
|
||||
class BcStopParser extends Parser<Stop> {
|
||||
class BcStopParser extends Parser<StopID, Stop> {
|
||||
static final tripsFile = File(bcDataDir / "stop_times.txt");
|
||||
static final routesFile = File(bcDataDir / "trips.txt");
|
||||
static final stopsFile = File(bcDataDir / "stops.txt");
|
||||
|
@ -15,7 +15,7 @@ class BcStopParser extends Parser<Stop> {
|
|||
Future<Map<TripID, List<StopID>>> getTrips() async {
|
||||
final result = <TripID, List<StopID>>{};
|
||||
for (final row in await readCsv(tripsFile)) {
|
||||
result.addToList(TripID(row[0]), StopID(row[3]));
|
||||
result.addToList(TripID(row[0]), StopID(Provider.bc, row[3]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ class BcStopParser extends Parser<Stop> {
|
|||
Future<Map<StopID, Stop>> getStops() async {
|
||||
final result = <StopID, Stop>{};
|
||||
for (final row in await readCsv(stopsFile)) {
|
||||
final stopID = StopID(row[0]);
|
||||
final stopID = StopID(Provider.bc, row[0]);
|
||||
final name = row[2];
|
||||
final description = row[3];
|
||||
final latitude = double.parse(row[4]);
|
||||
|
@ -69,12 +69,12 @@ class BcStopParser extends Parser<Stop> {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Iterable<Stop>> parse() async {
|
||||
Future<Map<StopID, Stop>> parse() async {
|
||||
final trips = await getTrips();
|
||||
final routes = await getRoutes();
|
||||
final stops = await getStops();
|
||||
final routeNames = await getRouteNames();
|
||||
findRoutesForStops(stops: stops.values, trips: trips, routes: routes, routeNames: routeNames);
|
||||
return stops.values;
|
||||
return stops;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import "dart:io";
|
|||
|
||||
import "utils.dart";
|
||||
|
||||
class OcctStopParser extends Parser<Stop> {
|
||||
class OcctStopParser extends Parser<StopID, Stop> {
|
||||
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_stops&token=TESTING
|
||||
static final stopsFile = File(occtDataDir / "stops.json");
|
||||
|
||||
|
@ -12,7 +12,7 @@ class OcctStopParser extends Parser<Stop> {
|
|||
Future<Map<StopID, Stop>> getStops() async {
|
||||
final result = <StopID, Stop>{};
|
||||
for (final stopJson in await readJson(stopsFile)) {
|
||||
final id = StopID.fromJson(stopJson["id"]);
|
||||
final id = StopID(Provider.occt, stopJson["id"]);
|
||||
result[id] ??= Stop.fromOcctJson(stopJson);
|
||||
}
|
||||
return result;
|
||||
|
@ -21,7 +21,7 @@ class OcctStopParser extends Parser<Stop> {
|
|||
Future<Map<StopID, List<RouteID>>> getRoutes() async {
|
||||
final result = <StopID, List<RouteID>>{};
|
||||
for (final json in await readJson(stopsFile)) {
|
||||
final stopID = StopID.fromJson(json["id"]);
|
||||
final stopID = StopID(Provider.occt, json["id"]);
|
||||
final routeID = RouteID(Provider.occt, json["rid"]);
|
||||
result.addToList(stopID, routeID);
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ class OcctStopParser extends Parser<Stop> {
|
|||
};
|
||||
|
||||
@override
|
||||
Future<Iterable<Stop>> parse() async {
|
||||
Future<Map<StopID, Stop>> parse() async {
|
||||
final stops = await getStops();
|
||||
final routes = await getRoutes();
|
||||
final routeNames = await getRouteNames();
|
||||
|
@ -46,6 +46,6 @@ class OcctStopParser extends Parser<Stop> {
|
|||
stop.addRoute(routeID, routeName);
|
||||
}
|
||||
}
|
||||
return stops.values;
|
||||
return stops;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,8 +27,8 @@ extension MapSetUtils<K, V> on Map<K, Set<V>> {
|
|||
}
|
||||
}
|
||||
|
||||
abstract class Parser<T> {
|
||||
Future<Iterable<T>> parse();
|
||||
abstract class Parser<ID, T> {
|
||||
Future<Map<ID, T>> parse();
|
||||
}
|
||||
|
||||
final csvConverter = CsvCodec(shouldParseNumbers: false).decoder;
|
||||
|
|
60
src/shared/lib/src/graph/algorithm.dart
Normal file
60
src/shared/lib/src/graph/algorithm.dart
Normal file
|
@ -0,0 +1,60 @@
|
|||
import "package:a_star/a_star.dart";
|
||||
import "package:shared/data.dart";
|
||||
|
||||
import "state.dart";
|
||||
|
||||
void printStops(Iterable<Stop> stops) {
|
||||
log("Found stops:");
|
||||
for (final stop in stops) {
|
||||
log("- ${stop.routes}, ${stop.name}");
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<StopState>? findPath(Coordinates start, Coordinates end) {
|
||||
// 1) Find [numStops] nearest stops by the given locations
|
||||
log("Finding nearest stops...");
|
||||
final startStops = StopState.findStopsNear(start);
|
||||
printStops(startStops);
|
||||
final endStops = StopState.findStopsNear(end);
|
||||
printStops(endStops);
|
||||
|
||||
// 2) Use A* to find a route with transfers
|
||||
log("Using A*");
|
||||
final paths = <Iterable<StopState>>[];
|
||||
for (final startStop in startStops) {
|
||||
for (final startRouteID in startStop.routeIDs) {
|
||||
for (final endStop in endStops) {
|
||||
final state = StopState.start(
|
||||
stopID: startStop.id,
|
||||
goalID: endStop.id,
|
||||
routeID: startRouteID,
|
||||
startPoint: start,
|
||||
endPoint: end,
|
||||
);
|
||||
log("Finding a route using ${state.hash()}");
|
||||
final result = aStar(state, limit: 10000);
|
||||
if (result == null) continue;
|
||||
paths.add(result.reconstructPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.isEmpty) return null;
|
||||
return paths.min(getTotalDistance);
|
||||
}
|
||||
|
||||
double getTotalDistance(Iterable<StopState> path) =>
|
||||
path.sum((step) => step.distanceWalked);
|
||||
|
||||
void explainPath(Iterable<StopState> path, void Function(String) printer) {
|
||||
var index = 0;
|
||||
for (final step in path) {
|
||||
index++;
|
||||
final stop = StopState.stops[step.stopID]!;
|
||||
if (index == path.length) {
|
||||
printer("$index. Get off at ${stop.name}");
|
||||
} else {
|
||||
printer("$index. ${step.explanation}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,33 +1,81 @@
|
|||
import "package:a_star/a_star.dart";
|
||||
import "package:shared/data.dart";
|
||||
|
||||
class StopState extends AStarState<StopState> {
|
||||
enum SearchMethod {
|
||||
start,
|
||||
bus,
|
||||
transfer,
|
||||
walk;
|
||||
|
||||
factory SearchMethod.fromJson(String json) => values.byName(json);
|
||||
}
|
||||
|
||||
class StopState extends AStarState<StopState> with Encodable {
|
||||
static late final Map<RouteID, Route> routes;
|
||||
static late final Map<StopID, Stop> stops;
|
||||
|
||||
static void init(Iterable<Route> allRoutes, Iterable<Stop> allStops) {
|
||||
routes = {
|
||||
for (final route in allRoutes)
|
||||
route.id: route,
|
||||
};
|
||||
stops = {
|
||||
for (final stop in allStops)
|
||||
stop.id: stop,
|
||||
};
|
||||
static Iterable<Stop> findStopsNear(Coordinates location) {
|
||||
const numStops = 5;
|
||||
log("Finding stops near $location...");
|
||||
final stopDistances = [
|
||||
for (final stop in stops.values)
|
||||
(stop, stop.coordinates.distanceTo(location)),
|
||||
];
|
||||
stopDistances.sort((a, b) => a.$2.compareTo(b.$2));
|
||||
return stopDistances.take(numStops).map((pair) => pair.$1);
|
||||
}
|
||||
|
||||
final StopID stopID;
|
||||
final StopID goalID;
|
||||
final RouteID routeID;
|
||||
final double distanceWalked;
|
||||
final SearchMethod method;
|
||||
StopState({
|
||||
required this.stopID,
|
||||
required this.goalID,
|
||||
required this.routeID,
|
||||
required this.distanceWalked,
|
||||
required this.method,
|
||||
required super.depth,
|
||||
});
|
||||
|
||||
factory StopState.start({
|
||||
required StopID stopID,
|
||||
required StopID goalID,
|
||||
required RouteID routeID,
|
||||
required Coordinates startPoint,
|
||||
required Coordinates endPoint,
|
||||
}) {
|
||||
final stop = stops[stopID]!;
|
||||
final goal = stops[goalID]!;
|
||||
final distanceToStart = startPoint.distanceTo(stop.coordinates);
|
||||
final distanceToEnd = endPoint.distanceTo(goal.coordinates);
|
||||
return StopState(
|
||||
stopID: stopID,
|
||||
goalID: goalID,
|
||||
routeID: routeID,
|
||||
distanceWalked: distanceToStart + distanceToEnd,
|
||||
depth: 0,
|
||||
method: SearchMethod.start,
|
||||
);
|
||||
}
|
||||
|
||||
factory StopState.fromJson(Json json) => StopState(
|
||||
stopID: StopID.fromJson(json["stop_id"]),
|
||||
routeID: RouteID.fromJson(json["route_id"]),
|
||||
method: SearchMethod.fromJson(json["method"]),
|
||||
depth: 0,
|
||||
distanceWalked: 0,
|
||||
goalID: StopID.fromJson("N/A"),
|
||||
);
|
||||
|
||||
@override
|
||||
String hash() => "$stopID-$goalID-$routeID";
|
||||
String hash() {
|
||||
final stop = stops[stopID];
|
||||
final goal = stops[goalID];
|
||||
final route = routes[routeID];
|
||||
return "${method.name}-$stop-$goal-$route";
|
||||
}
|
||||
|
||||
@override
|
||||
bool isGoal() => stopID == goalID;
|
||||
|
@ -44,30 +92,86 @@ class StopState extends AStarState<StopState> {
|
|||
final route = routes[routeID]!;
|
||||
final stop = stops[stopID]!;
|
||||
final stopIndex = route.stops.indexOf(stopID);
|
||||
if (stopIndex == -1) return [];
|
||||
final result = <StopState>[];
|
||||
|
||||
// 1): Go forward one stop on the same route
|
||||
final nextIndex = stopIndex + 1;
|
||||
if (nextIndex < route.stops.length) {
|
||||
final nextStop = route.stops[nextIndex];
|
||||
final state = StopState(stopID: nextStop, goalID: goalID, routeID: routeID, depth: depth + 1);
|
||||
final nextStopID = route.stops[nextIndex];
|
||||
final state = StopState(
|
||||
stopID: nextStopID,
|
||||
goalID: goalID,
|
||||
routeID: routeID,
|
||||
depth: depth + 1,
|
||||
distanceWalked: distanceWalked,
|
||||
method: SearchMethod.bus,
|
||||
);
|
||||
result.add(state);
|
||||
}
|
||||
|
||||
// 2) Go back one stop on the same route
|
||||
final prevIndex = stopIndex - 1;
|
||||
if (prevIndex > 0) {
|
||||
final prevStop = route.stops[prevIndex];
|
||||
final state = StopState(stopID: prevStop, goalID: goalID, routeID: routeID, depth: depth + 1);
|
||||
final prevStopID = route.stops[prevIndex];
|
||||
final state = StopState(
|
||||
stopID: prevStopID,
|
||||
goalID: goalID,
|
||||
routeID: routeID,
|
||||
depth: depth + 1,
|
||||
distanceWalked: distanceWalked,
|
||||
method: SearchMethod.bus,
|
||||
);
|
||||
result.add(state);
|
||||
}
|
||||
|
||||
// 3) Make any available transfers
|
||||
for (final otherRoute in stop.routeIDs.difference({routeID})) {
|
||||
final state = StopState(stopID: stopID, goalID: goalID, routeID: otherRoute, depth: depth + 1);
|
||||
for (final otherRouteID in stop.routeIDs.difference({routeID})) {
|
||||
final state = StopState(
|
||||
stopID: stopID,
|
||||
goalID: goalID,
|
||||
routeID: otherRouteID,
|
||||
depth: depth + 2,
|
||||
distanceWalked: distanceWalked,
|
||||
method: SearchMethod.transfer,
|
||||
);
|
||||
result.add(state);
|
||||
}
|
||||
|
||||
// 4) Walk to a new stop
|
||||
for (final otherStop in findStopsNear(stop.coordinates)) {
|
||||
final distanceToOther = stop.coordinates.distanceTo(otherStop.coordinates);
|
||||
for (final otherRouteID in otherStop.routeIDs) {
|
||||
final state = StopState(
|
||||
stopID: otherStop.id,
|
||||
goalID: goalID,
|
||||
routeID: otherRouteID,
|
||||
depth: depth + 3,
|
||||
distanceWalked: distanceWalked + distanceToOther,
|
||||
method: SearchMethod.walk,
|
||||
);
|
||||
result.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
String get explanation {
|
||||
final route = routes[routeID]!;
|
||||
final stop = stops[stopID]!;
|
||||
return switch (method) {
|
||||
SearchMethod.bus => "Go one stop to $stop",
|
||||
SearchMethod.start => "Walk to $stop\n and board the $route line",
|
||||
SearchMethod.transfer => "Transfer to the $route line",
|
||||
SearchMethod.walk => "Walk to $stop\n and board the $route line"
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Json toJson() => {
|
||||
"stop_id": stopID,
|
||||
"route_id": routeID,
|
||||
"method": method.name,
|
||||
};
|
||||
}
|
||||
|
|
16
src/shared/lib/src/server/misc.dart
Normal file
16
src/shared/lib/src/server/misc.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
import "package:shared/graph.dart";
|
||||
import "package:shelf/shelf.dart";
|
||||
|
||||
import "utils.dart";
|
||||
|
||||
Response getStops(Request _) {
|
||||
final stops = StopState.stops.values;
|
||||
final jsonBody = encodeJsonList(stops);
|
||||
return Response.ok(jsonBody, headers: corsHeaders);
|
||||
}
|
||||
|
||||
Response getRoutes(Request _) {
|
||||
final routes = StopState.routes.values.where((route) => !route.fullName.startsWith(")"));
|
||||
final jsonBody = encodeJsonList(routes);
|
||||
return Response.ok(jsonBody, headers: corsHeaders);
|
||||
}
|
24
src/shared/lib/src/server/path.dart
Normal file
24
src/shared/lib/src/server/path.dart
Normal file
|
@ -0,0 +1,24 @@
|
|||
import "package:shared/graph.dart";
|
||||
import "package:shelf/shelf.dart";
|
||||
|
||||
import "utils.dart";
|
||||
|
||||
Response getPath(Request request) {
|
||||
final startLat = double.tryParse(request.url.queryParameters["start_lat"] ?? "");
|
||||
final startLong = double.tryParse(request.url.queryParameters["start_lon"] ?? "");
|
||||
final endLat = double.tryParse(request.url.queryParameters["end_lat"] ?? "");
|
||||
final endLong = double.tryParse(request.url.queryParameters["end_lon"] ?? "");
|
||||
if (startLat == null || startLong == null || endLat == null || endLong == null) {
|
||||
return Response.badRequest(body: "Could not parse coordinates", headers: corsHeaders);
|
||||
}
|
||||
|
||||
final start = (lat: startLat, long: startLong);
|
||||
final end = (lat: endLat, long: endLong);
|
||||
final path = findPath(start, end);
|
||||
if (path == null) {
|
||||
return Response.ok("No routes found", headers: corsHeaders);
|
||||
}
|
||||
final buffer = StringBuffer();
|
||||
explainPath(path, buffer.writeln);
|
||||
return Response.ok(buffer.toString(), headers: corsHeaders);
|
||||
}
|
15
src/shared/lib/src/server/utils.dart
Normal file
15
src/shared/lib/src/server/utils.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:shared/generator.dart";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
};
|
||||
|
||||
String encodeJsonList<T extends Encodable>(Iterable<T> data) {
|
||||
final jsonList = [
|
||||
for (final object in data)
|
||||
object.toJson(),
|
||||
];
|
||||
return jsonEncode(jsonList);
|
||||
}
|
Loading…
Reference in a new issue