Compare commits
13 commits
route_find
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
9558d02f39 | ||
|
94fda78bd9 | ||
8950751c82 | |||
|
e8de6475c7 | ||
|
d0387c7aa2 | ||
|
5e24c8ad30 | ||
|
575c4f9a16 | ||
|
c878d08c23 | ||
|
8fdb25f15e | ||
|
cd5491e6ec | ||
|
c7922ebef0 | ||
|
abc8f103a7 | ||
|
bec21a40b6 |
68 changed files with 12142 additions and 4933 deletions
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
|
@ -17,6 +17,7 @@
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"args": ["-d", "chrome", "--dart-define-from-file=.env"],
|
"args": ["-d", "chrome", "--dart-define-from-file=.env"],
|
||||||
|
"program": "lib/main.dart",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
41
LICENSE
Normal file
41
LICENSE
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
Copyright (c) 2025 Spencer Powell & Levi Lesches
|
||||||
|
|
||||||
|
At time of writing this software is not licensed under a permissive license.
|
||||||
|
If you are Professor Steven Moore of Binghamton University you may access the code as it exists on May 5th 2025 under an MIT license for the purposes of grading.
|
||||||
|
A copy of the MIT license can be found at https://choosealicense.com/licenses/mit/ or at the bottom of this file.
|
||||||
|
|
||||||
|
If you are anyone else you ARE NOT granted permission to read, use, modify, copy, merge, publich, distribute, sublicense or sell the software or any other rights to this software.
|
||||||
|
|
||||||
|
Versions of this software written/published after May 5th 2025 may fall under a different license.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
Example of MIT License THIS IS NOT THE LICENSE THIS SOFTWARE FALLS UNDER barring relicensing.
|
||||||
|
|
||||||
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
55
README.md
55
README.md
|
@ -1,47 +1,60 @@
|
||||||
[](https://classroom.github.com/a/vfKrPwQS)
|
[](https://classroom.github.com/a/vfKrPwQS)
|
||||||
# << Project Title >>
|
|
||||||
|
# Binghamton Better Bus (BBB) v2
|
||||||
## CS 445 Final Project
|
## CS 445 Final Project
|
||||||
### Spring, 2025
|
### Spring, 2025
|
||||||
|
|
||||||
### Team: << team name >>
|
### Team: Team 4
|
||||||
|
|
||||||
- Spencer Powell
|
- Spencer Powell
|
||||||
- Levi Lesches
|
- Levi Lesches
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
<<One paragraph of project description goes here>>
|
|
||||||
|
This project is a website which allows for picking a source and destination and getting the ideal bus route between the 2 given static information.
|
||||||
|
|
||||||
### Roadmap
|
### Roadmap
|
||||||
<<
|
|
||||||
A list of features, function or non-functional, you would like to add in the future if you had time, i.e. Phase 2 stuff
|
A list of features, function or non-functional, you would like to add in the future if you had time, i.e. Phase 2 stuff
|
||||||
- [ ] Add Changelog
|
|
||||||
- [ ] Add back to top links
|
- [ ] Make use of time/direction information available in static scheduling information
|
||||||
- [ ] Add Additional Templates w/ Examples
|
- [ ] Place our hardware on OCCT buses to remove ETA Spot dependence/legal question mark
|
||||||
- [ ] Add "components" document to easily copy & paste sections of the readme
|
- [ ] Design/build hardware
|
||||||
>>
|
- Keyword reminder for mobile data: IoT data plan and/or M2M
|
||||||
|
- [ ] Negotiate placing hardware on OCCT buses
|
||||||
|
- [ ] Chat with BCT about getting their live bus info
|
||||||
|
- [ ] Use machine learning to predict bus locations based on current position, current time of day and current route,
|
||||||
|
|
||||||
## SRS
|
## SRS
|
||||||
|
|
||||||
[doc](https://docs.google.com/document/d/1kSWMxsK0NakhvHRQnNx0wRlL6wDgYGO-tA7JB_4qldE/edit?usp=sharing)
|
[doc](https://docs.google.com/document/d/1kSWMxsK0NakhvHRQnNx0wRlL6wDgYGO-tA7JB_4qldE/edit?usp=sharing)
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
* [Docker](https://www.docker.com/)
|
* [Docker](https://www.docker.com/)
|
||||||
* <<any additional software. Be specific about versions.>>
|
* Docker Compose
|
||||||
|
* [Just](https://github.com/casey/just)
|
||||||
|
|
||||||
### Installing
|
### Installing
|
||||||
<<
|
|
||||||
A step by step series of examples that tell you how to get a development env running
|
cd into the `src` directory and run `just setup` then run `docker-compose build`.
|
||||||
Clearly outline each step and repeat until the environment is set up.
|
|
||||||
End with an example of getting some output from the system, such as a menu or prompt
|
To run just run `docker-compose up` in the `src` directory, the site will be up on `localhost:8080`
|
||||||
>>
|
|
||||||
|
|
||||||
## Built With
|
## Built With
|
||||||
<< list all frameworks and modules used here >>
|
|
||||||
* [requests](https://docs.python-requests.org/en/latest/user/quickstart/#make-a-request) - request for humans
|
* [Deno](https://deno.com/)
|
||||||
|
- [Acorn](https://oakserver.org/acorn)
|
||||||
|
* [Dart](https://dart.dev/)
|
||||||
|
* [Flutter](https://flutter.dev/)
|
||||||
|
- Google maps
|
||||||
|
* [Caddy](https://caddyserver.com/)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
<< Add a [license](https://choosealicense.com/) >>
|
|
||||||
|
DIY license written out which grants MIT rights to professor moore for the version of this submitted for grading and no rights to anyone else.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
* Hat tip to anyone whose code was used
|
|
||||||
* Inspiration
|
* Claude and chatGPT were used for the creation of scripts for certain rote data conversion tasks
|
||||||
* etc
|
* This project would not exist with the data it has if not for prior instances of attempts to make this idea by US, of particular note is the usage of GTFS data which was only learned of in a prior attempt with Lucy Loerker
|
||||||
|
|
BIN
demo.mp4
Normal file
BIN
demo.mp4
Normal file
Binary file not shown.
16
etc/presentation2_review.md
Normal file
16
etc/presentation2_review.md
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Topic: Deno
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
- WHat is deno
|
||||||
|
- Deno imporvements over node
|
||||||
|
- typescript
|
||||||
|
- security
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
- migration to deno
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
Good Job
|
|
@ -5,25 +5,18 @@
|
||||||
|
|
||||||
## Requirements Attempted in this Sprint
|
## Requirements Attempted in this Sprint
|
||||||
|
|
||||||
- Merge client sevrer to one branch, and integration
|
- Routing is based on start and stop location
|
||||||
- Docker Services working
|
|
||||||
|
|
||||||
## Requirements complete
|
## Requirements complete
|
||||||
|
|
||||||
- Merge client sevrer to one branch, and integration
|
- Routing is based on start and stop location
|
||||||
- Docker Services working
|
|
||||||
|
|
||||||
## Requirements incomplete
|
## Requirements incomplete
|
||||||
|
|
||||||
|
|
||||||
## Requirement Flex Remaining
|
## Requirement Flex Remaining
|
||||||
|
|
||||||
<< 3 / 5 >>
|
<< 3 / 5 >>
|
||||||
|
|
||||||
## Requirements Attempted in next Sprint
|
|
||||||
|
|
||||||
- Routing is based on start and stop location
|
|
||||||
|
|
||||||
# Milestone Status
|
# Milestone Status
|
||||||
|
|
||||||
Pass
|
Pass
|
||||||
|
|
71
sprints/sprint7/sprint7.md
Normal file
71
sprints/sprint7/sprint7.md
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# Sprint Meeting Notes
|
||||||
|
|
||||||
|
*note: replace anything surrounded by << >> and **remove** the << >>*
|
||||||
|
|
||||||
|
**Attended**: << record the team members in attendance (virtual counts as long as they are participating) >>
|
||||||
|
|
||||||
|
**DATE**: << meeting date >>
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Sprint << num >> Review
|
||||||
|
|
||||||
|
### SRS Sections Updated
|
||||||
|
|
||||||
|
<< List any SRS sections that were updated in the last sprint >>
|
||||||
|
|
||||||
|
### User Story
|
||||||
|
|
||||||
|
<< Corresponding User Stories completed in this sprint >>
|
||||||
|
|
||||||
|
### Sprint Requirements Attempted
|
||||||
|
|
||||||
|
<< The corresponding SRS requirement that the team completed in the last sprint >>
|
||||||
|
|
||||||
|
### Completed Requirements
|
||||||
|
|
||||||
|
<< The work that's been completed in this sprint >>
|
||||||
|
|
||||||
|
### Incomplete Requirements
|
||||||
|
|
||||||
|
<< The work that has not been completed in this sprint. Be VERY detailed and specific regarding what isn't working and what needs to be completed >>
|
||||||
|
|
||||||
|
### The summary of the entire project
|
||||||
|
|
||||||
|
<< A general overview of the entire project >>
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Sprint << num >> Planning
|
||||||
|
|
||||||
|
## Requirements Flex
|
||||||
|
|
||||||
|
<< # >>/5 requirement flexes remaining
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
<< Any requirements from the previous sprint that are using a technical flex >>
|
||||||
|
|
||||||
|
### Requirement Target
|
||||||
|
|
||||||
|
<< The corresponding SRS requirement that our team will be complete in next sprint >>
|
||||||
|
|
||||||
|
### User Stories
|
||||||
|
|
||||||
|
<< Corresponding User Stories >>
|
||||||
|
|
||||||
|
### Planning
|
||||||
|
|
||||||
|
<< Our team's detailed plan to complete the sprint >>
|
||||||
|
|
||||||
|
### Action Items
|
||||||
|
|
||||||
|
<< A list of things that need to happen in our for our team to complete the sprint >>
|
||||||
|
|
||||||
|
### Issues and Risks
|
||||||
|
|
||||||
|
<< A list of potential obstacles that could keep us from completing the sprint and what's being done about them >>
|
||||||
|
|
||||||
|
### Team Work Assignments
|
||||||
|
|
||||||
|
<< A list of each team member and their works assignments for this sprint >>
|
2
src/.gitignore
vendored
2
src/.gitignore
vendored
|
@ -1 +1,3 @@
|
||||||
*.env
|
*.env
|
||||||
|
pubspec.lock
|
||||||
|
.dart_tool
|
||||||
|
|
13
src/Justfile
Normal file
13
src/Justfile
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
setup:
|
||||||
|
# this is a hack that is done due to a lack of communication
|
||||||
|
# towards the end of this project
|
||||||
|
# a slightly better hack could be done which avoids this but I
|
||||||
|
# don't wanna write things to work that way
|
||||||
|
cp -r client shared/client-dir
|
||||||
|
cp -r shared client/shared-dir
|
||||||
|
cp -r server/data shared/server-data
|
||||||
|
cp pubspec.yaml client/root-pubspec.yaml
|
||||||
|
cp pubspec.yaml shared/root-pubspec.yaml
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf client/root-pubspec.yaml shared/root-pubspec.yaml shared/client-dir client/shared-dir shared/server-data
|
3
src/client/.gitignore
vendored
3
src/client/.gitignore
vendored
|
@ -45,3 +45,6 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
shared-dir
|
||||||
|
root-pubspec.yaml
|
||||||
|
|
|
@ -20,11 +20,15 @@ RUN flutter upgrade
|
||||||
|
|
||||||
RUN flutter doctor
|
RUN flutter doctor
|
||||||
|
|
||||||
COPY ./ /client/
|
COPY root-pubspec.yaml /client/pubspec.yaml
|
||||||
|
COPY ./shared-dir /client/shared
|
||||||
|
COPY ./ /client/client
|
||||||
|
|
||||||
|
WORKDIR /client/client
|
||||||
|
|
||||||
RUN flutter build web --dart-define-from-file=.env
|
RUN flutter build web --dart-define-from-file=.env
|
||||||
|
|
||||||
WORKDIR /client/build/web
|
WORKDIR /client/client/build/web
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
EXPOSE 80/tcp
|
||||||
|
|
||||||
|
|
|
@ -1,149 +0,0 @@
|
||||||
import "dart:convert";
|
|
||||||
import "dart:io";
|
|
||||||
import "package:csv/csv.dart";
|
|
||||||
|
|
||||||
/// Utils on [Map].
|
|
||||||
extension MapUtils<K, V> on Map<K, V> {
|
|
||||||
/// Gets all the keys and values as 2-element records.
|
|
||||||
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
extension on Directory {
|
|
||||||
String operator /(String child) => "$path/$child";
|
|
||||||
}
|
|
||||||
|
|
||||||
final serverDir = Directory("../server");
|
|
||||||
final dataDir = Directory(serverDir / "data/BCT");
|
|
||||||
final tripsFile = File(dataDir / "stop_times.txt");
|
|
||||||
final routesFile = File(dataDir / "trips.txt");
|
|
||||||
final stopsFile = File(dataDir / "stops.txt");
|
|
||||||
final routeNamesFile = File(dataDir / "routes.txt");
|
|
||||||
final outputFile = File(serverDir / "GET_STOPS.json");
|
|
||||||
|
|
||||||
extension type TripID(String id) { }
|
|
||||||
extension type StopID(String id) { }
|
|
||||||
extension type RouteID(String id) { }
|
|
||||||
|
|
||||||
class Stop {
|
|
||||||
final StopID id;
|
|
||||||
final double latitude;
|
|
||||||
final double longitude;
|
|
||||||
final String name;
|
|
||||||
final String description;
|
|
||||||
final String provider;
|
|
||||||
|
|
||||||
final Set<String> routes = {};
|
|
||||||
|
|
||||||
Stop({
|
|
||||||
required this.id,
|
|
||||||
required this.latitude,
|
|
||||||
required this.longitude,
|
|
||||||
required this.name,
|
|
||||||
required this.description,
|
|
||||||
required this.provider,
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
"id": id.id,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"latitude": latitude,
|
|
||||||
"longitude": longitude,
|
|
||||||
"provider": provider,
|
|
||||||
"routes": routes.toList(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
final converter = CsvCodec(shouldParseNumbers: false).decoder;
|
|
||||||
|
|
||||||
Future<Map<TripID, Set<StopID>>> getTrips() async {
|
|
||||||
final contents = await tripsFile.readAsString();
|
|
||||||
final csv = converter.convert(contents);
|
|
||||||
final result = <TripID, Set<StopID>>{};
|
|
||||||
for (final row in csv.skip(1)) {
|
|
||||||
final tripId = TripID(row[0]);
|
|
||||||
final stopId = StopID(row[3]);
|
|
||||||
result[tripId] ??= <StopID>{};
|
|
||||||
result[tripId]!.add(stopId);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<TripID, RouteID>> getRoutes() async {
|
|
||||||
final contents = await routesFile.readAsString();
|
|
||||||
final csv = converter.convert(contents);
|
|
||||||
final result = <TripID, RouteID>{};
|
|
||||||
for (final row in csv.skip(1)) {
|
|
||||||
final routeID = RouteID(row[0]);
|
|
||||||
final tripID = TripID(row[2]);
|
|
||||||
result[tripID] = routeID;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<RouteID, String>> getRouteNames() async {
|
|
||||||
final contents = await routeNamesFile.readAsString();
|
|
||||||
final csv = converter.convert(contents);
|
|
||||||
return {
|
|
||||||
for (final row in csv.skip(1))
|
|
||||||
RouteID(row[0]): row[3],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<StopID, Stop>> getStops() async {
|
|
||||||
final contents = await stopsFile.readAsString();
|
|
||||||
final csv = converter.convert(contents);
|
|
||||||
final result = <StopID, Stop>{};
|
|
||||||
for (final row in csv.skip(1)) {
|
|
||||||
final stopID = StopID(row[0]);
|
|
||||||
final name = row[2];
|
|
||||||
final description = row[3];
|
|
||||||
final latitude = double.parse(row[4]);
|
|
||||||
final longitude = double.parse(row[5]);
|
|
||||||
final stop = Stop(
|
|
||||||
id: stopID,
|
|
||||||
name: name,
|
|
||||||
description: description,
|
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
provider: "BC Transit",
|
|
||||||
);
|
|
||||||
result[stopID] = stop;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void findRoutesForStops({
|
|
||||||
required Iterable<Stop> stops,
|
|
||||||
required Map<TripID, Set<StopID>> trips,
|
|
||||||
required Map<TripID, RouteID> routes,
|
|
||||||
required Map<RouteID, String> routeNames,
|
|
||||||
}) {
|
|
||||||
for (final stop in stops) {
|
|
||||||
for (final (tripID, trip) in trips.records) {
|
|
||||||
if (!trip.contains(stop.id)) continue;
|
|
||||||
final routeID = routes[tripID]!;
|
|
||||||
final routeName = routeNames[routeID]!;
|
|
||||||
stop.routes.add(routeName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> outputJson(Iterable<Stop> stops) async {
|
|
||||||
final result = [
|
|
||||||
for (final stop in stops)
|
|
||||||
stop.toJson(),
|
|
||||||
];
|
|
||||||
const encoder = JsonEncoder.withIndent(" ");
|
|
||||||
final contents = encoder.convert(result);
|
|
||||||
await outputFile.writeAsString(contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() 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);
|
|
||||||
await outputJson(stops.values);
|
|
||||||
}
|
|
|
@ -2,4 +2,5 @@ export "src/data/utils.dart";
|
||||||
export "src/data/trip.dart";
|
export "src/data/trip.dart";
|
||||||
export "src/data/path.dart";
|
export "src/data/path.dart";
|
||||||
export "src/data/polyline.dart";
|
export "src/data/polyline.dart";
|
||||||
export "src/data/stop.dart";
|
|
||||||
|
export "package:shared/data.dart";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import "trip.dart";
|
import "trip.dart";
|
||||||
import "utils.dart";
|
import "package:shared/data.dart";
|
||||||
|
|
||||||
class PathStep {
|
class PathStep {
|
||||||
final PathTrip trip;
|
final PathTrip trip;
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import "trip.dart";
|
|
||||||
import "utils.dart";
|
|
||||||
|
|
||||||
class Stop {
|
|
||||||
final double latitude;
|
|
||||||
final double longitude;
|
|
||||||
final List<TripID> trips;
|
|
||||||
|
|
||||||
Stop.fromJson(Json json) :
|
|
||||||
latitude = json["latitude"],
|
|
||||||
longitude = json["longitude"],
|
|
||||||
trips = json["trips"];
|
|
||||||
}
|
|
|
@ -1,9 +1,8 @@
|
||||||
import "utils.dart";
|
import "package:shared/data.dart";
|
||||||
|
|
||||||
import "package:flutter/material.dart" show TimeOfDay;
|
import "package:flutter/material.dart" show TimeOfDay;
|
||||||
|
|
||||||
extension type TripID(String id) { }
|
extension type TripID(String id) { }
|
||||||
extension type StopID(String id) { }
|
|
||||||
|
|
||||||
class Trip {
|
class Trip {
|
||||||
final List<TripStop> stops;
|
final List<TripStop> stops;
|
||||||
|
|
|
@ -1,30 +1,7 @@
|
||||||
/// A JSON object
|
import "package:google_maps_flutter/google_maps_flutter.dart";
|
||||||
typedef Json = Map<String, dynamic>;
|
|
||||||
|
|
||||||
typedef FromJson<T> = T Function(Json);
|
import "package:shared/data.dart";
|
||||||
|
|
||||||
typedef Coordinates = ({double lat, double long});
|
extension CoordinatesUtils on Coordinates {
|
||||||
|
LatLng toLatLng() => LatLng(lat, long);
|
||||||
/// Utils on [Map].
|
|
||||||
extension MapUtils<K, V> on Map<K, V> {
|
|
||||||
/// Gets all the keys and values as 2-element records.
|
|
||||||
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zips two lists, like Python
|
|
||||||
Iterable<(E1, E2)> zip<E1, E2>(List<E1> list1, List<E2> list2) sync* {
|
|
||||||
if (list1.length != list2.length) throw ArgumentError("Trying to zip lists of different lengths");
|
|
||||||
for (var index = 0; index < list1.length; index++) {
|
|
||||||
yield (list1[index], list2[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extensions on lists
|
|
||||||
extension ListUtils<E> on List<E> {
|
|
||||||
/// Iterates over a pair of indexes and elements, like Python
|
|
||||||
Iterable<(int, E)> get enumerate sync* {
|
|
||||||
for (var i = 0; i < length; i++) {
|
|
||||||
yield (i, this[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,91 +13,76 @@ class HomePage extends ReactiveWidget<HomeModel> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, HomeModel model) => Scaffold(
|
Widget build(BuildContext context, HomeModel model) => Scaffold(
|
||||||
appBar: AppBar(title: const Text("Counter")),
|
appBar: AppBar(title: const Text("Counter")),
|
||||||
body: Column(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 300,
|
width: 325,
|
||||||
child: Row(
|
child: Sidebar(model),
|
||||||
children: [
|
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FilledButton(onPressed: model.search, child: const Text("Search for a path")),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (model.isLoading)
|
|
||||||
const LinearProgressIndicator()
|
|
||||||
else if (model.isSearching && model.path == null)
|
|
||||||
const Text("Could not connect to API")
|
|
||||||
else
|
|
||||||
for (final step in model.path ?? <PathStep>[]) Text(
|
|
||||||
"Get on at ${step.enter.lat}, ${step.enter.long}\n"
|
|
||||||
"Get off at ${step.exit.lat}, ${step.exit.long}",
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: model.isGoogleReady
|
child: Card(
|
||||||
? GoogleMap(
|
child: Column(
|
||||||
initialCameraPosition: const CameraPosition(
|
children: [
|
||||||
target: LatLng(42.10125081757972, -75.94181323552698),
|
SwitchListTile(
|
||||||
zoom: 14,
|
value: model.markerState == MarkerState.override,
|
||||||
),
|
onChanged: model.overrideMarkers,
|
||||||
polylines: {
|
title: const Text("Show stops list"),
|
||||||
for (final (index, route) in model.routes.indexed) Polyline(
|
subtitle: const Text("To select a start or end stop, use the buttons below"),
|
||||||
polylineId: PolylineId(index.toString()),
|
|
||||||
color: routeColors[index],
|
|
||||||
points: route,
|
|
||||||
),
|
),
|
||||||
},
|
LatLongEditor(
|
||||||
)
|
latitudeController: model.startLatitudeController,
|
||||||
: const Center(child: CircularProgressIndicator()),
|
longitudeController: model.startLongitudeController,
|
||||||
|
label: "Start at",
|
||||||
|
isShowingMarkers: model.markerState == MarkerState.start,
|
||||||
|
showMarkers: () => model.showMarkers(MarkerState.start),
|
||||||
|
hideMarkers: model.hideMarkers,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
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")),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (model.isLoading)
|
||||||
|
const LinearProgressIndicator()
|
||||||
|
else if (model.isSearching && model.path == null)
|
||||||
|
const Text("Could not connect to API")
|
||||||
|
else
|
||||||
|
for (final step in model.path ?? <PathStep>[]) Text(
|
||||||
|
"Get on at ${step.enter.lat}, ${step.enter.long}\n"
|
||||||
|
"Get off at ${step.exit.lat}, ${step.exit.long}",
|
||||||
|
),
|
||||||
|
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.paths.indexed) Polyline(
|
||||||
|
polylineId: PolylineId(index.toString()),
|
||||||
|
color: routeColors[index],
|
||||||
|
points: route,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,58 +1,53 @@
|
||||||
import "dart:convert";
|
import "package:flutter/foundation.dart" show debugPrint, kDebugMode;
|
||||||
|
|
||||||
import "package:http/http.dart" as http;
|
|
||||||
|
|
||||||
import "package:client/data.dart";
|
import "package:client/data.dart";
|
||||||
|
import "package:http/http.dart" as http;
|
||||||
|
|
||||||
|
import "api_client.dart";
|
||||||
import "service.dart";
|
import "service.dart";
|
||||||
|
|
||||||
class ApiService extends Service {
|
class ApiService extends Service {
|
||||||
static const bool usingDocker = true;
|
static const usingDocker = !kDebugMode;
|
||||||
final client = http.Client();
|
final _client = ApiClient();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async { }
|
Future<void> init() async {
|
||||||
|
debugPrint("Running with Docker? $usingDocker");
|
||||||
|
}
|
||||||
|
|
||||||
Uri get _base => usingDocker
|
Uri get _base => usingDocker
|
||||||
? Uri(path: "api/")
|
? Uri(path: "api/")
|
||||||
: Uri(scheme: "http", host: "localhost", port: 80);
|
: Uri(scheme: "http", host: "localhost", port: 8001);
|
||||||
|
|
||||||
Future<List<T>?> _getAll<T>(Uri uri, FromJson<T> fromJson) async {
|
Future<String?> getPath({
|
||||||
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(
|
|
||||||
_base.resolve("trips"),
|
|
||||||
Trip.fromJson,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<Path?> getPath({
|
|
||||||
required Coordinates start,
|
required Coordinates start,
|
||||||
required Coordinates end,
|
required Coordinates end,
|
||||||
}) => _getAll(
|
}) async {
|
||||||
_base.resolve("path").replace(
|
final uri = Uri.parse("/tmp-api/path").replace(
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
"start_lat": start.lat.toString(),
|
"start_lat": start.lat.toString(),
|
||||||
"start_lon": start.long.toString(),
|
"start_lon": start.long.toString(),
|
||||||
"end_lat": end.lat.toString(),
|
"end_lat": end.lat.toString(),
|
||||||
"end_lon": end.long.toString(),
|
"end_lon": end.long.toString(),
|
||||||
"time": DateTime.now().millisecondsSinceEpoch.toString(),
|
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
PathStep.fromJson,
|
try {
|
||||||
|
final response = await http.get(uri);
|
||||||
|
return response.body;
|
||||||
|
// Any error should show null
|
||||||
|
// ignore: avoid_catches_without_on_clauses
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Route>?> getRoutes() => _client.getJsonList(
|
||||||
|
_base.resolve("/routes"),
|
||||||
|
Route.fromJson,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<Stop>?> getStops() => _client.getJsonList(
|
||||||
|
_base.resolve("/stops"),
|
||||||
|
Stop.fromJson,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
30
src/client/lib/src/services/api_client.dart
Normal file
30
src/client/lib/src/services/api_client.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import "dart:convert";
|
||||||
|
|
||||||
|
import "package:client/data.dart";
|
||||||
|
import "package:flutter/foundation.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),
|
||||||
|
];
|
||||||
|
// Want to catch all errors for the UI
|
||||||
|
// ignore: avoid_catches_without_on_clauses
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint("Error: $error\n$stackTrace");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,23 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
import "package:client/data.dart";
|
import "package:client/data.dart";
|
||||||
import "package:client/services.dart";
|
import "package:client/services.dart";
|
||||||
import "package:flutter/widgets.dart" hide Path;
|
import "package:flutter/widgets.dart" hide Path;
|
||||||
|
|
||||||
import "package:google_maps_flutter/google_maps_flutter.dart";
|
import "package:google_maps_flutter/google_maps_flutter.dart";
|
||||||
|
|
||||||
|
import "home_markers.dart";
|
||||||
import "view_model.dart";
|
import "view_model.dart";
|
||||||
|
|
||||||
typedef LatLng2 = (double lat, double lng);
|
|
||||||
|
|
||||||
/// The view model for the home page.
|
/// The view model for the home page.
|
||||||
class HomeModel extends ViewModel {
|
class HomeModel extends ViewModel with HomeMarkers {
|
||||||
final startLatitudeController = TextEditingController();
|
final startLatitudeController = TextEditingController();
|
||||||
final startLongitudeController = TextEditingController();
|
final startLongitudeController = TextEditingController();
|
||||||
final endLatitudeController = TextEditingController();
|
final endLatitudeController = TextEditingController();
|
||||||
final endLongitudeController = TextEditingController();
|
final endLongitudeController = TextEditingController();
|
||||||
|
|
||||||
Path? path;
|
Path? path;
|
||||||
|
GoogleMapController? mapController;
|
||||||
|
|
||||||
double? get startLatitude => double.tryParse(startLatitudeController.text);
|
double? get startLatitude => double.tryParse(startLatitudeController.text);
|
||||||
double? get startLongitude => double.tryParse(startLongitudeController.text);
|
double? get startLongitude => double.tryParse(startLongitudeController.text);
|
||||||
|
@ -24,16 +26,46 @@ class HomeModel extends ViewModel {
|
||||||
|
|
||||||
bool isSearching = false;
|
bool isSearching = false;
|
||||||
bool isGoogleReady = false;
|
bool isGoogleReady = false;
|
||||||
|
List<List<LatLng>> paths = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
isGoogleReady = true;
|
isGoogleReady = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await updateMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<List<LatLng>> routes = [];
|
void onMapTapped(LatLng coordinates) {
|
||||||
|
if (!shouldShowMarkers) return;
|
||||||
|
final marker = Marker(
|
||||||
|
markerId: markerId ?? const MarkerId("Tapped"),
|
||||||
|
position: coordinates,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? pathText;
|
||||||
Future<void> search() async {
|
Future<void> search() async {
|
||||||
final start = (lat: startLatitude, long: startLongitude);
|
final start = (lat: startLatitude, long: startLongitude);
|
||||||
final end = (lat: endLatitude, long: endLongitude);
|
final end = (lat: endLatitude, long: endLongitude);
|
||||||
|
@ -42,15 +74,10 @@ class HomeModel extends ViewModel {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
final result = await services.api.getPath(start: start as Coordinates, end: end as Coordinates);
|
final result = await services.api.getPath(start: start as Coordinates, end: end as Coordinates);
|
||||||
path = result;
|
pathText = result ?? "An error occurred";
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
routes = [
|
|
||||||
for (final step in result) [
|
|
||||||
...decodePolyline(step.trip.polyline),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
166
src/client/lib/src/view_models/home_markers.dart
Normal file
166
src/client/lib/src/view_models/home_markers.dart
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
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,
|
||||||
|
override;
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin HomeMarkers on ChangeNotifier {
|
||||||
|
MarkerState? markerState;
|
||||||
|
bool get shouldShowMarkers => markerState != null;
|
||||||
|
|
||||||
|
// 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>[])
|
||||||
|
for (final routeID in routesToShow)
|
||||||
|
for (final stop in getStopsForRoute(routeID))
|
||||||
|
Marker(
|
||||||
|
markerId: MarkerId(stop.name),
|
||||||
|
position: stop.coordinates.toLatLng(),
|
||||||
|
onTap: () => onMarkerTapped(stop),
|
||||||
|
consumeTapEvents: true,
|
||||||
|
infoWindow: InfoWindow(
|
||||||
|
title: stop.name,
|
||||||
|
snippet: stop.summary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Set<Marker> get markers => shouldShowMarkers ? _filteredMarkers : {
|
||||||
|
if (_startMarker != null) _startMarker!,
|
||||||
|
if (_endMarker != null) _endMarker!,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Route> bcRouteNames = [];
|
||||||
|
List<Route> occtRouteNames = [];
|
||||||
|
Set<RouteID> routesToShow = {};
|
||||||
|
Iterable<(String, List<Route>)> get providers => [
|
||||||
|
("OCCT", occtRouteNames),
|
||||||
|
("BC Transit", bcRouteNames),
|
||||||
|
];
|
||||||
|
|
||||||
|
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 compareBcRoutes(Route a, Route b) =>
|
||||||
|
_parseBcNumber(a.shortName).compareTo(_parseBcNumber(b.shortName));
|
||||||
|
|
||||||
|
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!) {
|
||||||
|
MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
|
||||||
|
MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
String? get startOrEnd => switch (markerState!) {
|
||||||
|
MarkerState.start => "Start",
|
||||||
|
MarkerState.end => "End",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> updateMarkers() async {
|
||||||
|
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.add(route);
|
||||||
|
}
|
||||||
|
occtRouteNames.sort(compareOcctRoutes);
|
||||||
|
bcRouteNames.sort(compareBcRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? MarkerId(stop.name),
|
||||||
|
position: coordinates,
|
||||||
|
infoWindow: InfoWindow(
|
||||||
|
title: "$startOrEnd at ${stop.name}",
|
||||||
|
snippet: stop.description,
|
||||||
|
),
|
||||||
|
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;
|
||||||
|
markerState = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@mustCallSuper
|
||||||
|
void updateEnd(LatLng coordinates, Marker marker) {
|
||||||
|
_endMarker = marker;
|
||||||
|
markerState = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void showRoute(Route route, {required bool shouldShow}) {
|
||||||
|
if (shouldShow) {
|
||||||
|
routesToShow.add(route.id);
|
||||||
|
} else {
|
||||||
|
routesToShow.remove(route.id);
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isShowingMarkers) TextButton(
|
||||||
|
onPressed: hideMarkers,
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
) else TextButton.icon(
|
||||||
|
icon: const Icon(Icons.location_on),
|
||||||
|
onPressed: showMarkers,
|
||||||
|
label: const Text("Pick on map"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
65
src/client/lib/src/widgets/sidebar.dart
Normal file
65
src/client/lib/src/widgets/sidebar.dart
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import "package:client/view_models.dart";
|
||||||
|
import "package:client/widgets.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class Sidebar extends ReusableReactiveWidget<HomeModel> {
|
||||||
|
const Sidebar(super.model);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, HomeModel model) => DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Card(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (model.markerState != null && model.markerState != MarkerState.override) ...[
|
||||||
|
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 SizedBox(height: 8),
|
||||||
|
if (model.shouldShowMarkers) TabBar(
|
||||||
|
tabs: [
|
||||||
|
for (final (provider, _) in model.providers)
|
||||||
|
Tab(text: provider),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!model.shouldShowMarkers && model.pathText != null)
|
||||||
|
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(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
for (final (_, routesList) in model.providers)
|
||||||
|
ListView(
|
||||||
|
children: [
|
||||||
|
for (final route in routesList) CheckboxListTile(
|
||||||
|
title: Text(route.fullName, maxLines: 1),
|
||||||
|
subtitle: Text(
|
||||||
|
"${route.stops.length} stops",
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
value: model.routesToShow.contains(route.id),
|
||||||
|
onChanged: (value) => model.showRoute(route, shouldShow: value!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
export "src/view_models/view_model.dart";
|
export "src/view_models/view_model.dart";
|
||||||
|
|
||||||
|
export "src/view_models/home_markers.dart";
|
||||||
export "src/view_models/home.dart";
|
export "src/view_models/home.dart";
|
||||||
|
|
|
@ -2,7 +2,9 @@ import "package:flutter/material.dart";
|
||||||
|
|
||||||
export "package:go_router/go_router.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";
|
||||||
|
export "src/widgets/sidebar.dart";
|
||||||
|
|
||||||
/// Helpful methods on [BuildContext].
|
/// Helpful methods on [BuildContext].
|
||||||
extension ContextUtils on BuildContext {
|
extension ContextUtils on BuildContext {
|
||||||
|
|
|
@ -1,426 +0,0 @@
|
||||||
# Generated by pub
|
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
|
||||||
packages:
|
|
||||||
args:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: args
|
|
||||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.7.0"
|
|
||||||
async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: async
|
|
||||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.13.0"
|
|
||||||
boolean_selector:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: boolean_selector
|
|
||||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.2"
|
|
||||||
build_cli_annotations:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: build_cli_annotations
|
|
||||||
sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.0"
|
|
||||||
characters:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: characters
|
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.0"
|
|
||||||
clock:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: clock
|
|
||||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.2"
|
|
||||||
collection:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: collection
|
|
||||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.19.1"
|
|
||||||
convert:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: convert
|
|
||||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.2"
|
|
||||||
csslib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: csslib
|
|
||||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
csv:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: csv
|
|
||||||
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.0.0"
|
|
||||||
dhttpd:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: dhttpd
|
|
||||||
sha256: "2e24765d7569b8e0a02a441e3cf96f09cca69dfecba646e7e9f6b3ab45a2f3fe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.0"
|
|
||||||
fake_async:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: fake_async
|
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.3"
|
|
||||||
fixnum:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: fixnum
|
|
||||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_plugin_android_lifecycle:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_plugin_android_lifecycle
|
|
||||||
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.27"
|
|
||||||
flutter_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_web_plugins:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
go_router:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: go_router
|
|
||||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "14.8.1"
|
|
||||||
google_maps:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_maps
|
|
||||||
sha256: "4d6e199c561ca06792c964fa24b2bac7197bf4b401c2e1d23e345e5f9939f531"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "8.1.1"
|
|
||||||
google_maps_flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_maps_flutter
|
|
||||||
sha256: b42ff7f3875a5eedbe388d883100561b85c62beed1c39ad66dd60537c75bb424
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.12.0"
|
|
||||||
google_maps_flutter_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_maps_flutter_android
|
|
||||||
sha256: "0ede4ae8326335c0c007c8c7a8c9737449263123385e2bdf49f3e71103b2dc2e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.16.0"
|
|
||||||
google_maps_flutter_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_maps_flutter_ios
|
|
||||||
sha256: ef72c822930ce69515cb91c10cd88cfb8b26296f765808a43cbc9a10eaffacfe
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.15.0"
|
|
||||||
google_maps_flutter_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_maps_flutter_platform_interface
|
|
||||||
sha256: "970c8f766c02909c7be282dea923c971f83a88adaf07f8871d0aacebc3b07bb2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.11.1"
|
|
||||||
google_maps_flutter_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_maps_flutter_web
|
|
||||||
sha256: a45786ea6691cc7cdbe2cf3ce2c2daf4f82a885745666b4a36baada3a4e12897
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.5.12"
|
|
||||||
html:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: html
|
|
||||||
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.5"
|
|
||||||
http:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: http
|
|
||||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
http_parser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http_parser
|
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.2"
|
|
||||||
leak_tracker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker
|
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "10.0.9"
|
|
||||||
leak_tracker_flutter_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_flutter_testing
|
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.9"
|
|
||||||
leak_tracker_testing:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: leak_tracker_testing
|
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.1"
|
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: matcher
|
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.12.17"
|
|
||||||
material_color_utilities:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: material_color_utilities
|
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.11.1"
|
|
||||||
meta:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: meta
|
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.16.0"
|
|
||||||
mime:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: mime
|
|
||||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.0"
|
|
||||||
path:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: path
|
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.9.1"
|
|
||||||
plugin_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: plugin_platform_interface
|
|
||||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.8"
|
|
||||||
polyline_tools:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: polyline_tools
|
|
||||||
sha256: "8c523335bb8d16fb0c7835916ed94d97d9a063a4386e9193bd6e8fe233591df9"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.2"
|
|
||||||
sanitize_html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sanitize_html
|
|
||||||
sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.0"
|
|
||||||
shelf:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shelf
|
|
||||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.2"
|
|
||||||
shelf_static:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shelf_static
|
|
||||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.3"
|
|
||||||
sky_engine:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
source_span:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: source_span
|
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.10.1"
|
|
||||||
stack_trace:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stack_trace
|
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.12.1"
|
|
||||||
stream_channel:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stream_channel
|
|
||||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.4"
|
|
||||||
stream_transform:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: stream_transform
|
|
||||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.1"
|
|
||||||
string_scanner:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: string_scanner
|
|
||||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.1"
|
|
||||||
term_glyph:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: term_glyph
|
|
||||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.2"
|
|
||||||
test_api:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: test_api
|
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.4"
|
|
||||||
typed_data:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: typed_data
|
|
||||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.0"
|
|
||||||
vector_math:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vector_math
|
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.4"
|
|
||||||
very_good_analysis:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: very_good_analysis
|
|
||||||
sha256: "1fb637c0022034b1f19ea2acb42a3603cbd8314a470646a59a2fb01f5f3a8629"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.0.0"
|
|
||||||
vm_service:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vm_service
|
|
||||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "15.0.0"
|
|
||||||
web:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: web
|
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
sdks:
|
|
||||||
dart: ">=3.7.0-0 <4.0.0"
|
|
||||||
flutter: ">=3.27.0"
|
|
|
@ -6,6 +6,7 @@ version: 0.1.0+1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.0
|
sdk: ^3.5.0
|
||||||
|
|
||||||
|
resolution: workspace
|
||||||
dependencies:
|
dependencies:
|
||||||
fixnum: ^1.1.1
|
fixnum: ^1.1.1
|
||||||
csv: ^6.0.0
|
csv: ^6.0.0
|
||||||
|
@ -17,12 +18,13 @@ dependencies:
|
||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
polyline_tools: ^0.0.2
|
polyline_tools: ^0.0.2
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
|
shared:
|
||||||
|
path: ../shared
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
dhttpd: ^4.1.0
|
dhttpd: ^4.1.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
very_good_analysis: ^6.0.0
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:client/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -6,12 +6,17 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
- client
|
- client
|
||||||
|
- hack
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build: ./client
|
build: ./client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
hack:
|
||||||
|
build: ./shared
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build: ./server
|
build: ./server
|
||||||
environment:
|
environment:
|
||||||
|
@ -19,30 +24,30 @@ services:
|
||||||
#ports:
|
#ports:
|
||||||
# - "127.0.0.1:8080:80"
|
# - "127.0.0.1:8080:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
# depends_on:
|
||||||
neo4j:
|
# neo4j:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
neo4j:
|
#neo4j:
|
||||||
image: neo4j:latest
|
# image: neo4j:latest
|
||||||
volumes:
|
# volumes:
|
||||||
- neo4jconfig:/config
|
# - neo4jconfig:/config
|
||||||
- neo4jdata:/data
|
# - neo4jdata:/data
|
||||||
- neo4jplugins:/plugins
|
# - neo4jplugins:/plugins
|
||||||
environment:
|
# environment:
|
||||||
# neo4j isn't exposed to the internet so having the password checked into version control doesn't matter
|
# # neo4j isn't exposed to the internet so having the password checked into version control doesn't matter
|
||||||
- NEO4J_AUTH=neo4j/your_password
|
# - NEO4J_AUTH=neo4j/your_password
|
||||||
- NEO4J_PLUGINS=["graph-data-science"]
|
# ports:
|
||||||
ports:
|
# # useful for dev
|
||||||
# useful for dev
|
# - "127.0.0.1:7474:7474"
|
||||||
- "127.0.0.1:7474:7474"
|
# - "127.0.0.1:7687:7687"
|
||||||
- "127.0.0.1:7687:7687"
|
# restart: unless-stopped
|
||||||
restart: unless-stopped
|
# healthcheck:
|
||||||
healthcheck:
|
# test: wget http://localhost:7474 || exit 1
|
||||||
test: wget http://localhost:7474 || exit 1
|
# interval: 1s
|
||||||
interval: 1s
|
# timeout: 10s
|
||||||
timeout: 10s
|
# retries: 20
|
||||||
retries: 20
|
# start_period: 3s
|
||||||
start_period: 3s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
neo4jconfig:
|
neo4jconfig:
|
||||||
|
|
10
src/pubspec.yaml
Normal file
10
src/pubspec.yaml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
name: cs445
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.7.0
|
||||||
|
|
||||||
|
workspace:
|
||||||
|
- shared
|
||||||
|
- client
|
||||||
|
dev_dependencies:
|
||||||
|
very_good_analysis: ^7.0.0
|
|
@ -3,4 +3,7 @@
|
||||||
handle_path /api/* {
|
handle_path /api/* {
|
||||||
reverse_proxy server:80
|
reverse_proxy server:80
|
||||||
}
|
}
|
||||||
|
handle_path /tmp-api/* {
|
||||||
|
reverse_proxy hack:8001
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
1454
src/server/GET_routes.json
Normal file
1454
src/server/GET_routes.json
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,4 @@
|
||||||
[
|
[
|
||||||
"https://binghamtonupublic.etaspot.net/service.php?service=get_routes&token=TESTING",
|
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Westside Outbound",
|
"name": "Westside Outbound",
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -20,13 +20,6 @@ interface GTFSStop {
|
||||||
stop_url?: string;
|
stop_url?: string;
|
||||||
[key: string]: string | undefined; // Allow for additional fields
|
[key: string]: string | undefined; // Allow for additional fields
|
||||||
}
|
}
|
||||||
interface GTFSStopTime {
|
|
||||||
trip_id: string;
|
|
||||||
arrival_time: string;
|
|
||||||
departure_time: string;
|
|
||||||
stop_id: string;
|
|
||||||
stop_sequence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up a Neo4j graph database with location nodes from JSON and GTFS stop data
|
* Sets up a Neo4j graph database with location nodes from JSON and GTFS stop data
|
||||||
|
@ -38,24 +31,19 @@ export async function graph_setup(
|
||||||
driver: Neo4j.Driver,
|
driver: Neo4j.Driver,
|
||||||
OCCT_stops_json: string = "./data/OCCT/stops.json",
|
OCCT_stops_json: string = "./data/OCCT/stops.json",
|
||||||
BCT_GTFS_stops_txt: string = "./data/BCT/stops.txt",
|
BCT_GTFS_stops_txt: string = "./data/BCT/stops.txt",
|
||||||
BCT_GTFS_stops_times_txt: string = "./data/BCT/stop_times.txt",
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const session = driver.session();
|
const session = driver.session();
|
||||||
|
|
||||||
const jsonData = await Deno.readTextFile(OCCT_stops_json);
|
const jsonData = await Deno.readTextFile(OCCT_stops_json);
|
||||||
const locationNodes: LocationNode[] = JSON.parse(jsonData);
|
const locationNodes: LocationNode[] = JSON.parse(jsonData);
|
||||||
|
|
||||||
await stops_json_node_import(session, locationNodes, { provider: "OCCT" });
|
await stops_json_node_import(session, locationNodes);
|
||||||
|
|
||||||
const BCTStopsData = await Deno.readTextFile(BCT_GTFS_stops_txt);
|
const BCTStopsData = await Deno.readTextFile(BCT_GTFS_stops_txt);
|
||||||
|
|
||||||
const BCT_stops = await parse_gtfs_stops(BCTStopsData);
|
const BCT_stops = await parse_gtfs_stops(BCTStopsData);
|
||||||
|
|
||||||
await stops_gtfs_node_import(session, BCT_stops, { provider: "BCT" });
|
await stops_gtfs_node_import(session, BCT_stops);
|
||||||
|
|
||||||
const BCTStopTimesData = await Deno.readTextFile(BCT_GTFS_stops_times_txt);
|
|
||||||
const BCT_stop_times = await parse_gtfs_stop_times(BCTStopTimesData);
|
|
||||||
await gtfs_edge_import(session, BCT_stop_times);
|
|
||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
}
|
}
|
||||||
|
@ -63,9 +51,7 @@ export async function graph_setup(
|
||||||
async function stops_json_node_import(
|
async function stops_json_node_import(
|
||||||
session: Neo4j.Session,
|
session: Neo4j.Session,
|
||||||
stops: LocationNode[],
|
stops: LocationNode[],
|
||||||
options: { provider: string },
|
|
||||||
) {
|
) {
|
||||||
const { provider } = options;
|
|
||||||
for (const node of stops) {
|
for (const node of stops) {
|
||||||
await session.run(
|
await session.run(
|
||||||
`
|
`
|
||||||
|
@ -74,19 +60,18 @@ async function stops_json_node_import(
|
||||||
n.originalId = $originalId,
|
n.originalId = $originalId,
|
||||||
n.latitude = $lat,
|
n.latitude = $lat,
|
||||||
n.longitude = $lng,
|
n.longitude = $lng,
|
||||||
n.source = $provider
|
n.source = 'OCCT'
|
||||||
ON MATCH SET
|
ON MATCH SET
|
||||||
n.originalId = $originalId,
|
n.originalId = $originalId,
|
||||||
n.latitude = $lat,
|
n.latitude = $lat,
|
||||||
n.longitude = $lng,
|
n.longitude = $lng,
|
||||||
n.source = $provider
|
n.source = 'OCCT'
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
id: `${provider}_${node.id}`,
|
id: `OCCT_${node.id}`,
|
||||||
originalId: node.id,
|
originalId: node.id,
|
||||||
lat: node.lat,
|
lat: node.lat,
|
||||||
lng: node.lng,
|
lng: node.lng,
|
||||||
provider,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -95,8 +80,6 @@ async function stops_json_node_import(
|
||||||
async function stops_gtfs_node_import(
|
async function stops_gtfs_node_import(
|
||||||
session: Neo4j.Session,
|
session: Neo4j.Session,
|
||||||
stops: GTFSStop[],
|
stops: GTFSStop[],
|
||||||
// default provider is to avoid breaking upstream
|
|
||||||
options: { provider: string },
|
|
||||||
) {
|
) {
|
||||||
// Add GTFS stops to Neo4j
|
// Add GTFS stops to Neo4j
|
||||||
for (const stop of stops) {
|
for (const stop of stops) {
|
||||||
|
@ -106,7 +89,7 @@ async function stops_gtfs_node_import(
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { provider } = options;
|
|
||||||
// Use MERGE to update existing nodes or create new ones
|
// Use MERGE to update existing nodes or create new ones
|
||||||
await session.run(
|
await session.run(
|
||||||
`
|
`
|
||||||
|
@ -115,20 +98,23 @@ async function stops_gtfs_node_import(
|
||||||
s.name = $name,
|
s.name = $name,
|
||||||
s.latitude = $lat,
|
s.latitude = $lat,
|
||||||
s.longitude = $lng,
|
s.longitude = $lng,
|
||||||
|
s.locationType = $locationType,
|
||||||
|
s.parentStation = $parentStation,
|
||||||
|
s.zoneId = $zoneId,
|
||||||
s.url = $url,
|
s.url = $url,
|
||||||
s.originalId = $originalId,
|
s.source = 'BCT'
|
||||||
s.source = $provider
|
|
||||||
ON MATCH SET
|
ON MATCH SET
|
||||||
s.name = $name,
|
s.name = $name,
|
||||||
s.latitude = $lat,
|
s.latitude = $lat,
|
||||||
s.longitude = $lng,
|
s.longitude = $lng,
|
||||||
|
s.locationType = $locationType,
|
||||||
|
s.parentStation = $parentStation,
|
||||||
|
s.zoneId = $zoneId,
|
||||||
s.url = $url,
|
s.url = $url,
|
||||||
s.originalId = $originalId,
|
s.source = 'BCT'
|
||||||
s.source = $provider
|
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
id: `${provider}_` + stop.stop_id,
|
id: "BCT_" + stop.stop_id,
|
||||||
originalId: stop.stop_id,
|
|
||||||
name: stop.stop_name,
|
name: stop.stop_name,
|
||||||
lat: parseFloat(stop.stop_lat),
|
lat: parseFloat(stop.stop_lat),
|
||||||
lng: parseFloat(stop.stop_lon),
|
lng: parseFloat(stop.stop_lon),
|
||||||
|
@ -136,7 +122,6 @@ async function stops_gtfs_node_import(
|
||||||
parentStation: stop.parent_station || null,
|
parentStation: stop.parent_station || null,
|
||||||
zoneId: stop.zone_id || null,
|
zoneId: stop.zone_id || null,
|
||||||
url: stop.stop_url || null,
|
url: stop.stop_url || null,
|
||||||
provider,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -183,69 +168,3 @@ async function parse_gtfs_stops(txt: string): Promise<GTFSStop[]> {
|
||||||
|
|
||||||
return validStops;
|
return validStops;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parse_gtfs_stop_times(
|
|
||||||
fileContents: string,
|
|
||||||
): Promise<GTFSStopTime[]> {
|
|
||||||
const records = await parseCSV(fileContents, {
|
|
||||||
skipFirstRow: true,
|
|
||||||
columns: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopTimes: GTFSStopTime[] = [];
|
|
||||||
|
|
||||||
for (const row of records as Record<string, string>[]) {
|
|
||||||
stopTimes.push({
|
|
||||||
trip_id: row.trip_id,
|
|
||||||
arrival_time: row.arrival_time,
|
|
||||||
departure_time: row.departure_time,
|
|
||||||
stop_id: row.stop_id,
|
|
||||||
stop_sequence: parseInt(row.stop_sequence, 10),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return stopTimes;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gtfs_edge_import(
|
|
||||||
session: neo4j.Session,
|
|
||||||
stop_times: GTFSStopTime[],
|
|
||||||
): Promise<void> {
|
|
||||||
const groupedByTrip = new Map<string, GTFSStopTime[]>();
|
|
||||||
|
|
||||||
// Group by trip_id
|
|
||||||
for (const stopTime of stop_times) {
|
|
||||||
if (!groupedByTrip.has(stopTime.trip_id)) {
|
|
||||||
groupedByTrip.set(stopTime.trip_id, []);
|
|
||||||
}
|
|
||||||
groupedByTrip.get(stopTime.trip_id)!.push(stopTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [tripId, stops] of groupedByTrip.entries()) {
|
|
||||||
// Sort by stop_sequence
|
|
||||||
stops.sort((a, b) => a.stop_sequence - b.stop_sequence);
|
|
||||||
|
|
||||||
for (let i = 0; i < stops.length - 1; i++) {
|
|
||||||
const from = stops[i];
|
|
||||||
const to = stops[i + 1];
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`
|
|
||||||
MATCH (from:TransportNode {originalId: $fromId}), (to:TransportNode {originalId: $toId})
|
|
||||||
MERGE (from)-[:DEPARTS_TO {
|
|
||||||
tripId: $tripId,
|
|
||||||
departureTime: $departureTime,
|
|
||||||
arrivalTime: $arrivalTime
|
|
||||||
}]->(to)
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
fromId: from.stop_id,
|
|
||||||
toId: to.stop_id,
|
|
||||||
tripId,
|
|
||||||
departureTime: from.departure_time,
|
|
||||||
arrivalTime: to.arrival_time,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,10 +7,11 @@ import { assert } from "@std/assert";
|
||||||
import neo4j from "https://deno.land/x/neo4j_driver_lite@5.28.1/mod.ts";
|
import neo4j from "https://deno.land/x/neo4j_driver_lite@5.28.1/mod.ts";
|
||||||
import { graph_setup } from "./graph.ts";
|
import { graph_setup } from "./graph.ts";
|
||||||
|
|
||||||
const usingDocker = true;
|
const usingDocker = !Deno.env.has("DISABLE_DOCKER");
|
||||||
const root_url: string = usingDocker
|
const root_url: string = usingDocker
|
||||||
? Deno.env.get("ROOT_URL") as string
|
? Deno.env.get("ROOT_URL") as string
|
||||||
: "http://localhost:8080/api";
|
: "http://localhost:8080/api";
|
||||||
|
console.log(`Running in docker configuration? ${usingDocker}`);
|
||||||
assert(root_url, "ROOT_URL env var not defined");
|
assert(root_url, "ROOT_URL env var not defined");
|
||||||
|
|
||||||
// bind values we're actually using
|
// bind values we're actually using
|
||||||
|
@ -23,13 +24,13 @@ const substitute_base_name = substitute_base_name_.bind(
|
||||||
const { hostname, port } = { hostname: "0.0.0.0", port: 80 };
|
const { hostname, port } = { hostname: "0.0.0.0", port: 80 };
|
||||||
|
|
||||||
const neo4jHost = usingDocker ? "neo4j" : "127.0.0.1"; // localhost does NOT work
|
const neo4jHost = usingDocker ? "neo4j" : "127.0.0.1"; // localhost does NOT work
|
||||||
const graph_driver = neo4j.driver(
|
//const graph_driver = neo4j.driver(
|
||||||
`neo4j://${neo4jHost}:7687`,
|
// `neo4j://${neo4jHost}:7687`,
|
||||||
neo4j.auth.basic("neo4j", "your_password"),
|
// neo4j.auth.basic("neo4j", "your_password"),
|
||||||
);
|
//);
|
||||||
console.log("initializing graph with static data");
|
//console.log("initializing graph with static data");
|
||||||
await graph_setup(graph_driver);
|
//await graph_setup(graph_driver);
|
||||||
console.log("graph initialization complete");
|
//console.log("graph initialization complete");
|
||||||
|
|
||||||
const db = new DatabaseSync(":memory:");
|
const db = new DatabaseSync(":memory:");
|
||||||
db_setup(db);
|
db_setup(db);
|
||||||
|
@ -77,7 +78,7 @@ router.get(
|
||||||
"/stops",
|
"/stops",
|
||||||
async (_ctx) => {
|
async (_ctx) => {
|
||||||
try {
|
try {
|
||||||
const bytes = await Deno.readFile(`bct_stops.json`);
|
const bytes = await Deno.readFile(`GET_STOPS.json`);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
json_mime_add(headers);
|
json_mime_add(headers);
|
||||||
cors_add(headers);
|
cors_add(headers);
|
||||||
|
|
5
src/server/server.bat
Normal file
5
src/server/server.bat
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@echo off
|
||||||
|
echo Start Neo4J Desktop
|
||||||
|
pause
|
||||||
|
set DISABLE_DOCKER=true
|
||||||
|
deno run -A index.ts
|
9
src/shared/.gitignore
vendored
Normal file
9
src/shared/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# https://dart.dev/guides/libraries/private-files
|
||||||
|
# Created by `dart pub`
|
||||||
|
.dart_tool/
|
||||||
|
pubspec.lock
|
||||||
|
client-dir
|
||||||
|
server-data
|
||||||
|
root-pubspec.yaml
|
||||||
|
path.log
|
||||||
|
notes.md
|
3
src/shared/CHANGELOG.md
Normal file
3
src/shared/CHANGELOG.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial version.
|
42
src/shared/Dockerfile
Normal file
42
src/shared/Dockerfile
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN mkdir /client
|
||||||
|
|
||||||
|
RUN apk add bash curl file git unzip which zip gcompat wget tar xz
|
||||||
|
|
||||||
|
#ENTRYPOINT bash
|
||||||
|
|
||||||
|
WORKDIR /client
|
||||||
|
|
||||||
|
RUN wget -O flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.29.0-stable.tar.xz
|
||||||
|
|
||||||
|
RUN tar xf flutter.tar.xz
|
||||||
|
|
||||||
|
ENV PATH="/client/flutter/bin/:${PATH}"
|
||||||
|
|
||||||
|
RUN git config --global --add safe.directory /client/flutter
|
||||||
|
|
||||||
|
RUN flutter upgrade
|
||||||
|
|
||||||
|
RUN flutter doctor
|
||||||
|
|
||||||
|
|
||||||
|
COPY root-pubspec.yaml /client/pubspec.yaml
|
||||||
|
|
||||||
|
COPY ./ /client/shared
|
||||||
|
|
||||||
|
RUN mkdir /client/client
|
||||||
|
|
||||||
|
COPY ./client-dir/* /client/client
|
||||||
|
|
||||||
|
RUN mkdir /client/server
|
||||||
|
|
||||||
|
COPY server-data /client/server/data
|
||||||
|
|
||||||
|
RUN dart pub get
|
||||||
|
|
||||||
|
WORKDIR /client/shared
|
||||||
|
|
||||||
|
EXPOSE 80/tcp
|
||||||
|
|
||||||
|
CMD ["dart", "bin/server.dart"]
|
2
src/shared/README.md
Normal file
2
src/shared/README.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
A sample command-line application with an entrypoint in `bin/`, library code
|
||||||
|
in `lib/`, and example unit test in `test/`.
|
11
src/shared/bin/data.dart
Normal file
11
src/shared/bin/data.dart
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import "package:shared/generator.dart";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
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");
|
||||||
|
}
|
||||||
|
}
|
4
src/shared/lib/data.dart
Normal file
4
src/shared/lib/data.dart
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export "src/data/provider.dart";
|
||||||
|
export "src/data/route.dart";
|
||||||
|
export "src/data/stop.dart";
|
||||||
|
export "src/data/utils.dart";
|
6
src/shared/lib/generator.dart
Normal file
6
src/shared/lib/generator.dart
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export "src/generator/generator.dart";
|
||||||
|
export "src/generator/routes_bc.dart";
|
||||||
|
export "src/generator/routes_occt.dart";
|
||||||
|
export "src/generator/stops_bc.dart";
|
||||||
|
export "src/generator/stops_occt.dart";
|
||||||
|
export "src/generator/utils.dart";
|
2
src/shared/lib/graph.dart
Normal file
2
src/shared/lib/graph.dart
Normal file
|
@ -0,0 +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";
|
14
src/shared/lib/src/data/provider.dart
Normal file
14
src/shared/lib/src/data/provider.dart
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
enum Provider {
|
||||||
|
bc("BCT", "BC Transit"),
|
||||||
|
occt("OCCT", "OCC Transport");
|
||||||
|
|
||||||
|
const Provider(this.id, this.humanName);
|
||||||
|
|
||||||
|
factory Provider.fromJson(String json) => values.firstWhere((value) => value.id == json);
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String humanName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => humanName;
|
||||||
|
}
|
68
src/shared/lib/src/data/route.dart
Normal file
68
src/shared/lib/src/data/route.dart
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import "provider.dart";
|
||||||
|
import "stop.dart";
|
||||||
|
import "utils.dart";
|
||||||
|
|
||||||
|
extension type RouteID._(String id) {
|
||||||
|
RouteID(Provider provider, Object json) :
|
||||||
|
id = "${provider.id}_$json";
|
||||||
|
|
||||||
|
RouteID.fromJson(dynamic json) : id = json.toString();
|
||||||
|
factory RouteID.fromBcCsv(CsvRow csv) => RouteID(Provider.bc, csv[0]);
|
||||||
|
|
||||||
|
RouteID withDirection(String direction) => RouteID._("${id}_$direction");
|
||||||
|
}
|
||||||
|
|
||||||
|
class Route with Encodable {
|
||||||
|
final RouteID id;
|
||||||
|
final Provider provider;
|
||||||
|
final String fullName;
|
||||||
|
final String shortName;
|
||||||
|
final List<StopID> stops;
|
||||||
|
|
||||||
|
const Route({
|
||||||
|
required this.id,
|
||||||
|
required this.provider,
|
||||||
|
required this.stops,
|
||||||
|
required this.fullName,
|
||||||
|
required this.shortName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Route.fromOcctJson(Json json) :
|
||||||
|
id = RouteID(Provider.occt, json["id"]),
|
||||||
|
provider = Provider.occt,
|
||||||
|
shortName = json["abbr"],
|
||||||
|
fullName = json["name"],
|
||||||
|
stops = [
|
||||||
|
for (final stopID in (json["stops"] as List).cast<int>())
|
||||||
|
StopID(Provider.occt, stopID.toString()),
|
||||||
|
];
|
||||||
|
|
||||||
|
Route.fromBcCsv(CsvRow csv) :
|
||||||
|
id = RouteID.fromBcCsv(csv),
|
||||||
|
provider = Provider.bc,
|
||||||
|
shortName = csv[2],
|
||||||
|
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,
|
||||||
|
"provider": provider.id,
|
||||||
|
"full_name": fullName,
|
||||||
|
"short_name": shortName,
|
||||||
|
"stops": stops,
|
||||||
|
};
|
||||||
|
}
|
79
src/shared/lib/src/data/stop.dart
Normal file
79
src/shared/lib/src/data/stop.dart
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import "utils.dart";
|
||||||
|
import "route.dart";
|
||||||
|
import "provider.dart";
|
||||||
|
|
||||||
|
extension type StopID._(String id) {
|
||||||
|
StopID(Provider provider, Object value) : id = "${provider.id}_$value";
|
||||||
|
StopID.fromJson(dynamic value) : id = value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
class Stop with Encodable {
|
||||||
|
final StopID id;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final Coordinates coordinates;
|
||||||
|
final Provider provider;
|
||||||
|
final Set<String> routes;
|
||||||
|
final Set<RouteID> routeIDs;
|
||||||
|
|
||||||
|
Stop({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.coordinates,
|
||||||
|
required this.provider,
|
||||||
|
}) : routes = {}, routeIDs = {};
|
||||||
|
|
||||||
|
Stop.fromJson(Json json) :
|
||||||
|
id = StopID.fromJson(json["id"]),
|
||||||
|
name = json["name"],
|
||||||
|
description = json["description"],
|
||||||
|
coordinates = (lat: json["latitude"], long: json["longitude"]),
|
||||||
|
provider = Provider.fromJson(json["provider"]),
|
||||||
|
routes = (json["routes"] as List).cast<String>().toSet(),
|
||||||
|
routeIDs = {
|
||||||
|
for (final routeID in (json["route_ids"] as List))
|
||||||
|
RouteID.fromJson(routeID),
|
||||||
|
};
|
||||||
|
|
||||||
|
Stop.fromOcctJson(Json json) :
|
||||||
|
id = StopID(Provider.occt, json["id"]),
|
||||||
|
name = json["name"],
|
||||||
|
description = null,
|
||||||
|
coordinates = (lat: json["lat"], long: json["lng"]),
|
||||||
|
provider = Provider.occt,
|
||||||
|
routes = <String>{},
|
||||||
|
routeIDs = <RouteID>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id.id,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"latitude": coordinates.lat,
|
||||||
|
"longitude": coordinates.long,
|
||||||
|
"provider": provider.id,
|
||||||
|
"routes": routes.toList(),
|
||||||
|
"route_ids": routeIDs.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
String get summary {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
if (description != null) {
|
||||||
|
buffer.writeln(description);
|
||||||
|
}
|
||||||
|
if (routes.isNotEmpty) {
|
||||||
|
buffer.writeln("Routes:");
|
||||||
|
routes.forEach(buffer.writeln);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addRoute(RouteID id, String name) {
|
||||||
|
routeIDs.add(id);
|
||||||
|
routes.add(name);
|
||||||
|
}
|
||||||
|
}
|
61
src/shared/lib/src/data/utils.dart
Normal file
61
src/shared/lib/src/data/utils.dart
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
|
typedef Json = Map<String, dynamic>;
|
||||||
|
typedef CsvRow = List<String>;
|
||||||
|
typedef FromJson<T> = T Function(Json);
|
||||||
|
typedef Coordinates = ({double lat, double long});
|
||||||
|
|
||||||
|
mixin Encodable {
|
||||||
|
Json toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CoordinateUtils on Coordinates {
|
||||||
|
double distanceTo(Coordinates other) => sqrt(pow(lat - other.lat, 2) + pow(long - other.long, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utils on [Map].
|
||||||
|
extension MapUtils<K, V> on Map<K, V> {
|
||||||
|
/// Gets all the keys and values as 2-element records.
|
||||||
|
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zips two lists, like Python
|
||||||
|
Iterable<(E1, E2)> zip<E1, E2>(List<E1> list1, List<E2> list2) sync* {
|
||||||
|
if (list1.length != list2.length) throw ArgumentError("Trying to zip lists of different lengths");
|
||||||
|
for (var index = 0; index < list1.length; index++) {
|
||||||
|
yield (list1[index], list2[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extensions on lists
|
||||||
|
extension ListUtils<E> on List<E> {
|
||||||
|
/// Iterates over a pair of indexes and elements, like Python
|
||||||
|
Iterable<(int, E)> get enumerate sync* {
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
yield (i, this[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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);
|
||||||
|
}
|
45
src/shared/lib/src/generator/generator.dart
Normal file
45
src/shared/lib/src/generator/generator.dart
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import "dart:convert";
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:shared/generator.dart";
|
||||||
|
|
||||||
|
class Generator<ID, T extends Encodable> extends Parser<ID, T> {
|
||||||
|
final Parser<ID, T> bc;
|
||||||
|
final Parser<ID, T> occt;
|
||||||
|
final File outputFile;
|
||||||
|
|
||||||
|
Generator({
|
||||||
|
required this.bc,
|
||||||
|
required this.occt,
|
||||||
|
required this.outputFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 allData.values)
|
||||||
|
data.toJson(),
|
||||||
|
];
|
||||||
|
const encoder = JsonEncoder.withIndent(" ");
|
||||||
|
final contents = encoder.convert(result);
|
||||||
|
await outputFile.writeAsString(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final stops = Generator(
|
||||||
|
bc: BcStopParser(),
|
||||||
|
occt: OcctStopParser(),
|
||||||
|
outputFile: File(serverDir / "GET_STOPS.json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final routes = Generator(
|
||||||
|
bc: BcRoutesParser(),
|
||||||
|
occt: OcctRoutesParser(),
|
||||||
|
outputFile: File(serverDir / "GET_routes.json"),
|
||||||
|
);
|
||||||
|
}
|
47
src/shared/lib/src/generator/routes_bc.dart
Normal file
47
src/shared/lib/src/generator/routes_bc.dart
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "utils.dart";
|
||||||
|
import "stops_bc.dart";
|
||||||
|
|
||||||
|
typedef InOutTrips = (TripID, TripID);
|
||||||
|
|
||||||
|
class BcRoutesParser extends Parser<RouteID, Route> {
|
||||||
|
final routesFile = File(bcDataDir / "routes.txt");
|
||||||
|
final tripsFile = File(bcDataDir / "trips.txt");
|
||||||
|
|
||||||
|
final stopParser = BcStopParser();
|
||||||
|
|
||||||
|
Future<Map<RouteID, Route>> getRoutes() async => {
|
||||||
|
for (final csv in await readCsv(routesFile))
|
||||||
|
RouteID.fromBcCsv(csv): Route.fromBcCsv(csv),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Map<RouteID, TripID>> getTripForRoutes(Map<TripID, List<StopID>> trips) async {
|
||||||
|
final tripsForRoutes = <RouteID, List<TripID>>{};
|
||||||
|
for (final row in await readCsv(tripsFile)) {
|
||||||
|
final routeID = RouteID(Provider.bc, row[0]);
|
||||||
|
final trip = TripID(row[2]);
|
||||||
|
// print(trip);
|
||||||
|
tripsForRoutes.addToList(routeID, trip);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final (routeID, tripsForRoute) in tripsForRoutes.records)
|
||||||
|
routeID: tripsForRoute.max((trip) => trips[trip]!.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<RouteID, Route>> parse() async {
|
||||||
|
final routes = await getRoutes();
|
||||||
|
final trips = await stopParser.getTrips();
|
||||||
|
final tripsForRoutes = await getTripForRoutes(trips);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
13
src/shared/lib/src/generator/routes_occt.dart
Normal file
13
src/shared/lib/src/generator/routes_occt.dart
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "utils.dart";
|
||||||
|
|
||||||
|
class OcctRoutesParser extends Parser<RouteID, Route> {
|
||||||
|
final routesFile = File(occtDataDir / "routes.json");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<RouteID, Route>> parse() async => {
|
||||||
|
for (final routeJson in await readJson(routesFile))
|
||||||
|
RouteID(Provider.occt, routeJson["id"]): Route.fromOcctJson(routeJson),
|
||||||
|
};
|
||||||
|
}
|
80
src/shared/lib/src/generator/stops_bc.dart
Normal file
80
src/shared/lib/src/generator/stops_bc.dart
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:csv/csv.dart";
|
||||||
|
|
||||||
|
import "utils.dart";
|
||||||
|
|
||||||
|
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");
|
||||||
|
static final routeNamesFile = File(bcDataDir / "routes.txt");
|
||||||
|
|
||||||
|
static final converter = CsvCodec(shouldParseNumbers: false).decoder;
|
||||||
|
|
||||||
|
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(Provider.bc, row[3]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<TripID, RouteID>> getRoutes() async => {
|
||||||
|
for (final row in await readCsv(routesFile))
|
||||||
|
TripID(row[2]): RouteID(Provider.bc, row[0]),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Map<RouteID, String>> getRouteNames() async => {
|
||||||
|
for (final row in await readCsv(routeNamesFile))
|
||||||
|
if (row[2].isNotEmpty)
|
||||||
|
RouteID(Provider.bc, row[0]): "${row[2]}) ${row[3]}",
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Map<StopID, Stop>> getStops() async {
|
||||||
|
final result = <StopID, Stop>{};
|
||||||
|
for (final row in await readCsv(stopsFile)) {
|
||||||
|
final stopID = StopID(Provider.bc, row[0]);
|
||||||
|
final name = row[2];
|
||||||
|
final description = row[3];
|
||||||
|
final latitude = double.parse(row[4]);
|
||||||
|
final longitude = double.parse(row[5]);
|
||||||
|
final stop = Stop(
|
||||||
|
id: stopID,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
coordinates: (lat: latitude, long: longitude),
|
||||||
|
provider: Provider.bc,
|
||||||
|
);
|
||||||
|
result[stopID] = stop;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void findRoutesForStops({
|
||||||
|
required Iterable<Stop> stops,
|
||||||
|
required Map<TripID, List<StopID>> trips,
|
||||||
|
required Map<TripID, RouteID> routes,
|
||||||
|
required Map<RouteID, String> routeNames,
|
||||||
|
}) {
|
||||||
|
for (final stop in stops) {
|
||||||
|
for (final (tripID, trip) in trips.records) {
|
||||||
|
if (!trip.contains(stop.id)) continue;
|
||||||
|
final routeID = routes[tripID]!;
|
||||||
|
final routeName = routeNames[routeID];
|
||||||
|
if (routeName == null) continue; // old route
|
||||||
|
stop.addRoute(routeID, routeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
51
src/shared/lib/src/generator/stops_occt.dart
Normal file
51
src/shared/lib/src/generator/stops_occt.dart
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "utils.dart";
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_routes&token=TESTING
|
||||||
|
static final routesFile = File(occtDataDir / "routes.json");
|
||||||
|
|
||||||
|
Future<Map<StopID, Stop>> getStops() async {
|
||||||
|
final result = <StopID, Stop>{};
|
||||||
|
for (final stopJson in await readJson(stopsFile)) {
|
||||||
|
final id = StopID(Provider.occt, stopJson["id"]);
|
||||||
|
result[id] ??= Stop.fromOcctJson(stopJson);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<StopID, List<RouteID>>> getRoutes() async {
|
||||||
|
final result = <StopID, List<RouteID>>{};
|
||||||
|
for (final json in await readJson(stopsFile)) {
|
||||||
|
final stopID = StopID(Provider.occt, json["id"]);
|
||||||
|
final routeID = RouteID(Provider.occt, json["rid"]);
|
||||||
|
result.addToList(stopID, routeID);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<RouteID, String>> getRouteNames() async => {
|
||||||
|
for (final json in await readJson(routesFile))
|
||||||
|
RouteID(Provider.occt, json["id"]): json["name"],
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<StopID, Stop>> parse() async {
|
||||||
|
final stops = await getStops();
|
||||||
|
final routes = await getRoutes();
|
||||||
|
final routeNames = await getRouteNames();
|
||||||
|
final routesToSkip = <RouteID>{RouteID(Provider.occt, "11")};
|
||||||
|
for (final (stopID, stop) in stops.records) {
|
||||||
|
for (final routeID in routes[stopID]!) {
|
||||||
|
if (routesToSkip.contains(routeID)) continue;
|
||||||
|
final routeName = routeNames[routeID]!;
|
||||||
|
stop.addRoute(routeID, routeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stops;
|
||||||
|
}
|
||||||
|
}
|
50
src/shared/lib/src/generator/utils.dart
Normal file
50
src/shared/lib/src/generator/utils.dart
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import "dart:convert";
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:csv/csv.dart";
|
||||||
|
import "package:shared/data.dart";
|
||||||
|
export "package:shared/data.dart";
|
||||||
|
|
||||||
|
extension type TripID(String id) {
|
||||||
|
TripID.fromJson(dynamic value) : id = value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DirectoryUtils on Directory {
|
||||||
|
String operator /(String child) => "$path/$child";
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MapListUtils<K, V> on Map<K, List<V>> {
|
||||||
|
void addToList(K key, V value) {
|
||||||
|
this[key] ??= [];
|
||||||
|
this[key]!.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MapSetUtils<K, V> on Map<K, Set<V>> {
|
||||||
|
void addToSet(K key, V value) {
|
||||||
|
this[key] ??= {};
|
||||||
|
this[key]!.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Parser<ID, T> {
|
||||||
|
Future<Map<ID, T>> parse();
|
||||||
|
}
|
||||||
|
|
||||||
|
final csvConverter = CsvCodec(shouldParseNumbers: false).decoder;
|
||||||
|
Future<Iterable<CsvRow>> readCsv(File file) async {
|
||||||
|
final contents = await file.readAsString();
|
||||||
|
final csv = csvConverter.convert(contents);
|
||||||
|
return csv.skip(1).map<CsvRow>((row) => row.cast<String>());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Json>> readJson(File file) async {
|
||||||
|
final contents = await file.readAsString();
|
||||||
|
final json = jsonDecode(contents);
|
||||||
|
return (json as List).cast<Json>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final serverDir = Directory("../server");
|
||||||
|
final _dataDir = Directory(serverDir / "data");
|
||||||
|
final occtDataDir = Directory(_dataDir / "OCCT");
|
||||||
|
final bcDataDir = Directory(_dataDir / "BCT");
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
177
src/shared/lib/src/graph/state.dart
Normal file
177
src/shared/lib/src/graph/state.dart
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import "package:a_star/a_star.dart";
|
||||||
|
import "package:shared/data.dart";
|
||||||
|
|
||||||
|
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 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() {
|
||||||
|
final stop = stops[stopID];
|
||||||
|
final goal = stops[goalID];
|
||||||
|
final route = routes[routeID];
|
||||||
|
return "${method.name}-$stop-$goal-$route";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isGoal() => stopID == goalID;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double heuristic() {
|
||||||
|
final stop = stops[stopID]!;
|
||||||
|
final goal = stops[goalID]!;
|
||||||
|
return stop.coordinates.distanceTo(goal.coordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<StopState> expand() {
|
||||||
|
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 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 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 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);
|
||||||
|
}
|
22
src/shared/pubspec.yaml
Normal file
22
src/shared/pubspec.yaml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
name: shared
|
||||||
|
description: A sample command-line application.
|
||||||
|
version: 1.0.0
|
||||||
|
# repository: https://github.com/my_org/my_repo
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.7.0
|
||||||
|
|
||||||
|
resolution: workspace
|
||||||
|
|
||||||
|
# Add regular dependencies here.
|
||||||
|
dependencies:
|
||||||
|
csv: ^6.0.0
|
||||||
|
a_star: ^3.0.1
|
||||||
|
shelf: ^1.4.2
|
||||||
|
shelf_router: ^1.1.4
|
||||||
|
# path: ^1.8.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
lints: ^5.0.0
|
||||||
|
test: ^1.24.0
|
||||||
|
very_good_analysis: ^7.0.0
|
Loading…
Reference in a new issue