diff --git a/src/client/lib/src/data/stop.dart b/src/client/lib/src/data/stop.dart index ccabbe3..10b491f 100644 --- a/src/client/lib/src/data/stop.dart +++ b/src/client/lib/src/data/stop.dart @@ -1,13 +1,20 @@ -import "trip.dart"; import "utils.dart"; +extension type StopID(String id) { } + class Stop { - final double latitude; - final double longitude; - final List trips; + final StopID id; + final String name; + final String description; + final Coordinates coordinates; + final String provider; + final List routes; Stop.fromJson(Json json) : - latitude = json["latitude"], - longitude = json["longitude"], - trips = json["trips"]; + id = StopID(json["id"]), + name = json["name"], + description = json["description"], + coordinates = (lat: json["latitude"], long: json["longitude"]), + provider = json["provider"], + routes = (json["routes"] as List).cast(); } diff --git a/src/client/lib/src/data/trip.dart b/src/client/lib/src/data/trip.dart index 07ad343..4889e37 100644 --- a/src/client/lib/src/data/trip.dart +++ b/src/client/lib/src/data/trip.dart @@ -3,7 +3,6 @@ import "utils.dart"; import "package:flutter/material.dart" show TimeOfDay; extension type TripID(String id) { } -extension type StopID(String id) { } class Trip { final List stops; diff --git a/src/client/lib/src/data/utils.dart b/src/client/lib/src/data/utils.dart index 9734124..40de501 100644 --- a/src/client/lib/src/data/utils.dart +++ b/src/client/lib/src/data/utils.dart @@ -1,3 +1,5 @@ +import "package:google_maps_flutter/google_maps_flutter.dart"; + /// A JSON object typedef Json = Map; @@ -28,3 +30,7 @@ extension ListUtils on List { } } } + +extension CoordinatesUtils on Coordinates { + LatLng toLatLng() => LatLng(lat, long); +} diff --git a/src/client/lib/src/pages/home.dart b/src/client/lib/src/pages/home.dart index 9fb5a39..7dcf689 100644 --- a/src/client/lib/src/pages/home.dart +++ b/src/client/lib/src/pages/home.dart @@ -13,91 +13,105 @@ class HomePage extends ReactiveWidget { @override Widget build(BuildContext context, HomeModel model) => Scaffold( appBar: AppBar(title: const Text("Counter")), - body: Column( + body: Row( children: [ - SizedBox( - width: 300, - child: Row( - children: [ - Expanded( - child: TextField( - controller: model.startLatitudeController, - decoration: const InputDecoration( - hintText: "Start latitude", - border: OutlineInputBorder(), + AnimatedContainer( + duration: Durations.short4, + width: model.shouldShowMarkers ? 250 : 0, + child: Card( + clipBehavior: Clip.hardEdge, + elevation: 8, + child: Column( + children: [ + Text( + "Select routes", + maxLines: 1, + style: context.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + Text( + "Or click anywhere on the map", + maxLines: 1, + style: context.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const Divider(), + Expanded( + child: ListView( + children: [ + for (final route in model.routeNames) ListTile( + title: Text(route, maxLines: 1), + subtitle: const Text("BC Transit", maxLines: 1), + // Can't use CheckboxListTile, since we must remove the + // checkboxes manually to prevent layour errors + trailing: !model.shouldShowMarkers ? null : Checkbox( + value: model.routesToShow.contains(route), + onChanged: (value) => model.showRoute(route, shouldShow: value!), + ), + ), + ], ), ), - ), - 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 ?? []) 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( - initialCameraPosition: const CameraPosition( - target: LatLng(42.10125081757972, -75.94181323552698), - zoom: 14, + child: Column( + children: [ + LatLongEditor( + latitudeController: model.startLatitudeController, + longitudeController: model.startLongitudeController, + label: "Start at", + isShowingMarkers: model.markerState == MarkerState.start, + showMarkers: () => model.showMarkers(MarkerState.start), + hideMarkers: model.hideMarkers, ), - polylines: { - for (final (index, route) in model.routes.indexed) Polyline( - polylineId: PolylineId(index.toString()), - color: routeColors[index], - points: route, + 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 ?? []) Text( + "Get on at ${step.enter.lat}, ${step.enter.long}\n" + "Get off at ${step.exit.lat}, ${step.exit.long}", ), - }, - ) - : const Center(child: CircularProgressIndicator()), + Expanded( + child: model.isGoogleReady + ? GoogleMap( + onMapCreated: (controller) => model.mapController = controller, + initialCameraPosition: const CameraPosition( + target: LatLng(42.10125081757972, -75.94181323552698), + zoom: 14, + ), + markers: model.markers, + onTap: model.onMapTapped, + polylines: { + for (final (index, route) in model.routes.indexed) Polyline( + polylineId: PolylineId(index.toString()), + color: routeColors[index], + points: route, + ), + }, + ) + : const Center(child: CircularProgressIndicator()), + ), + ], + ), ), ], ), diff --git a/src/client/lib/src/services/api.dart b/src/client/lib/src/services/api.dart index 7045116..073320d 100644 --- a/src/client/lib/src/services/api.dart +++ b/src/client/lib/src/services/api.dart @@ -1,15 +1,13 @@ -import "dart:convert"; - import "package:flutter/foundation.dart" show debugPrint, kDebugMode; -import "package:http/http.dart" as http; import "package:client/data.dart"; +import "api_client.dart"; import "service.dart"; class ApiService extends Service { static const usingDocker = !kDebugMode; - final client = http.Client(); + final _client = ApiClient(); @override Future init() async { @@ -20,31 +18,16 @@ class ApiService extends Service { ? Uri(path: "api/") : Uri(scheme: "http", host: "localhost", port: 80); - Future?> _getAll(Uri uri, FromJson fromJson) async { - try { - final response = await client.get(uri); - if (response.statusCode != 200) return null; - final json = jsonDecode(response.body)["path"] as List; - return [ - for (final obj in json.cast()) - fromJson(obj), - ]; - } catch (error, stackTrace) { - // ignore: avoid_print - print("Error: $error\n$stackTrace"); - return null; - } - } - - Future?> getTrips() => _getAll( + Future?> getTrips() => _client.getJsonList( _base.resolve("trips"), Trip.fromJson, + key: "path", ); Future getPath({ required Coordinates start, required Coordinates end, - }) => _getAll( + }) => _client.getJsonList( _base.resolve("path").replace( queryParameters: { "start_lat": start.lat.toString(), @@ -54,8 +37,12 @@ class ApiService extends Service { "time": DateTime.now().millisecondsSinceEpoch.toString(), }, ), + key: "path", PathStep.fromJson, ); - + Future?> getStops() => _client.getJsonList( + _base.resolve("stops"), + Stop.fromJson, + ); } diff --git a/src/client/lib/src/services/api_client.dart b/src/client/lib/src/services/api_client.dart new file mode 100644 index 0000000..08f54d1 --- /dev/null +++ b/src/client/lib/src/services/api_client.dart @@ -0,0 +1,28 @@ +import "dart:convert"; + +import "package:client/data.dart"; +import "package:http/http.dart" as http; + +class ApiClient { + final client = http.Client(); + + Future?> getJsonList(Uri uri, FromJson 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() + : ((jsonDecode(response.body) as Json)[key] as List).cast(); + return [ + for (final json in listOfJson) + fromJson(json), + ]; + } catch (error, stackTrace) { + // ignore: avoid_print + print("Error: $error\n$stackTrace"); + return null; + } + } +} diff --git a/src/client/lib/src/view_models/home.dart b/src/client/lib/src/view_models/home.dart index 67b9045..e63e72b 100644 --- a/src/client/lib/src/view_models/home.dart +++ b/src/client/lib/src/view_models/home.dart @@ -1,21 +1,23 @@ +import "dart:async"; + import "package:client/data.dart"; import "package:client/services.dart"; import "package:flutter/widgets.dart" hide Path; import "package:google_maps_flutter/google_maps_flutter.dart"; +import "home_markers.dart"; import "view_model.dart"; -typedef LatLng2 = (double lat, double lng); - /// The view model for the home page. -class HomeModel extends ViewModel { +class HomeModel extends ViewModel with HomeMarkers { final startLatitudeController = TextEditingController(); final startLongitudeController = TextEditingController(); final endLatitudeController = TextEditingController(); final endLongitudeController = TextEditingController(); Path? path; + GoogleMapController? mapController; double? get startLatitude => double.tryParse(startLatitudeController.text); double? get startLongitude => double.tryParse(startLongitudeController.text); @@ -24,15 +26,43 @@ class HomeModel extends ViewModel { bool isSearching = false; bool isGoogleReady = false; + List> routes = []; @override Future init() async { await Future.delayed(const Duration(seconds: 2)); isGoogleReady = true; notifyListeners(); + await updateMarkers(); } - List> routes = []; + void onMapTapped(LatLng coordinates) { + if (!shouldShowMarkers) return; + final marker = Marker( + markerId: markerId, + position: coordinates, + icon: markerIcon, + infoWindow: InfoWindow(title: "$startOrEnd here"), + ); + switch (markerState!) { + case MarkerState.start: updateStart(coordinates, marker); + case MarkerState.end: updateEnd(coordinates, marker); + } + } + + @override + void updateStart(LatLng coordinates, Marker marker) { + startLatitudeController.text = coordinates.latitude.toString(); + startLongitudeController.text = coordinates.longitude.toString(); + super.updateStart(coordinates, marker); + } + + @override + void updateEnd(LatLng coordinates, Marker marker) { + endLatitudeController.text = coordinates.latitude.toString(); + endLongitudeController.text = coordinates.longitude.toString(); + super.updateEnd(coordinates, marker); + } Future search() async { final start = (lat: startLatitude, long: startLongitude); diff --git a/src/client/lib/src/view_models/home_markers.dart b/src/client/lib/src/view_models/home_markers.dart new file mode 100644 index 0000000..a745325 --- /dev/null +++ b/src/client/lib/src/view_models/home_markers.dart @@ -0,0 +1,115 @@ +import "package:client/data.dart"; +import "package:client/services.dart"; +import "package:flutter/foundation.dart"; +import "package:google_maps_flutter/google_maps_flutter.dart"; + +enum MarkerState { + start, + end; +} + +mixin HomeMarkers on ChangeNotifier { + MarkerState? markerState; + bool get shouldShowMarkers => markerState != null; + + List? stops; + Marker? _startMarker; + Marker? _endMarker; + + Set get _filteredMarkers => { + for (final stop in stops ?? []) + if (stop.routes.any(routesToShow.contains)) + Marker( + markerId: MarkerId(stop.name), + position: stop.coordinates.toLatLng(), + onTap: () => onMarkerTapped(stop), + consumeTapEvents: true, + infoWindow: InfoWindow( + title: stop.name, + snippet: "${stop.description}\n\nRoutes: ${stop.routes.join("\n")}", + ), + ), + }; + + Set get markers => shouldShowMarkers ? _filteredMarkers : { + if (_startMarker != null) _startMarker!, + if (_endMarker != null) _endMarker!, + }; + + Set routeNames = {}; + Set routesToShow = {}; + + MarkerId get markerId => switch (markerState!) { + MarkerState.start => const MarkerId("start"), + MarkerState.end => const MarkerId("end"), + }; + + BitmapDescriptor get markerIcon => switch (markerState!) { + MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan), + MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + }; + + String get startOrEnd => switch (markerState!) { + MarkerState.start => "Start", + MarkerState.end => "End", + }; + + Future updateMarkers() async { + stops = await services.api.getStops(); + if (stops == null) return; + for (final stop in stops!) { + routeNames.addAll(stop.routes); + } + notifyListeners(); + } + + void showMarkers(MarkerState newState) { + markerState = newState; + notifyListeners(); + } + + void hideMarkers() { + markerState = null; + notifyListeners(); + } + + void onMarkerTapped(Stop stop) { + final coordinates = stop.coordinates.toLatLng(); + final marker = Marker( + markerId: markerId, + position: coordinates, + infoWindow: InfoWindow( + title: "$startOrEnd at ${stop.name}", + snippet: stop.description, + ), + icon: markerIcon, + ); + switch (markerState!) { + case MarkerState.start: updateStart(coordinates, marker); + case MarkerState.end: updateEnd(coordinates, marker); + } + } + + @mustCallSuper + void updateStart(LatLng coordinates, Marker marker) { + _startMarker = marker; + markerState = null; + notifyListeners(); + } + + @mustCallSuper + void updateEnd(LatLng coordinates, Marker marker) { + _endMarker = marker; + markerState = null; + notifyListeners(); + } + + void showRoute(String route, {required bool shouldShow}) { + if (shouldShow) { + routesToShow.add(route); + } else { + routesToShow.remove(route); + } + notifyListeners(); + } +} diff --git a/src/client/lib/src/widgets/lat_long_editor.dart b/src/client/lib/src/widgets/lat_long_editor.dart new file mode 100644 index 0000000..f99f879 --- /dev/null +++ b/src/client/lib/src/widgets/lat_long_editor.dart @@ -0,0 +1,60 @@ +import "package:client/widgets.dart"; +import "package:flutter/material.dart"; + +class LatLongEditor extends StatelessWidget { + final TextEditingController latitudeController; + final TextEditingController longitudeController; + final String label; + final VoidCallback showMarkers; + final VoidCallback hideMarkers; + final bool isShowingMarkers; + + const LatLongEditor({ + required this.latitudeController, + required this.longitudeController, + required this.label, + required this.isShowingMarkers, + required this.showMarkers, + required this.hideMarkers, + super.key, + }); + + @override + Widget build(BuildContext context) => SizedBox( + width: 400, + child: Row( + children: [ + Text(label, style: context.textTheme.titleSmall), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: latitudeController, + decoration: const InputDecoration( + hintText: "Latitude", + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: longitudeController, + decoration: const InputDecoration( + hintText: "Longitude", + border: OutlineInputBorder(), + ), + ), + ), + if (isShowingMarkers) IconButton( + icon: const Icon(Icons.location_on), + onPressed: hideMarkers, + tooltip: "Hide stops on map", + ) else IconButton( + icon: const Icon(Icons.location_off), + onPressed: showMarkers, + tooltip: "Show stops on map", + ), + ], + ), + ); +} diff --git a/src/client/lib/src/widgets/generic/reactive_widget.dart b/src/client/lib/src/widgets/reactive_widget.dart similarity index 100% rename from src/client/lib/src/widgets/generic/reactive_widget.dart rename to src/client/lib/src/widgets/reactive_widget.dart diff --git a/src/client/lib/view_models.dart b/src/client/lib/view_models.dart index 4e3b248..3b926f1 100644 --- a/src/client/lib/view_models.dart +++ b/src/client/lib/view_models.dart @@ -1,3 +1,4 @@ export "src/view_models/view_model.dart"; +export "src/view_models/home_markers.dart"; export "src/view_models/home.dart"; diff --git a/src/client/lib/widgets.dart b/src/client/lib/widgets.dart index c24c152..dcfb679 100644 --- a/src/client/lib/widgets.dart +++ b/src/client/lib/widgets.dart @@ -2,7 +2,8 @@ import "package:flutter/material.dart"; export "package:go_router/go_router.dart"; -export "src/widgets/generic/reactive_widget.dart"; +export "src/widgets/lat_long_editor.dart"; +export "src/widgets/reactive_widget.dart"; /// Helpful methods on [BuildContext]. extension ContextUtils on BuildContext {