From d58ef2562d522cee4a5c313046ae7d74405fe488 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Wed, 6 Nov 2024 14:45:43 +0100 Subject: [PATCH 1/8] image querying from within the frontend --- .../current_trip_loading_indicator.dart | 94 +++++++++++-------- frontend/lib/modules/current_trip_panel.dart | 9 +- frontend/lib/modules/landmark_card.dart | 20 ++-- .../lib/modules/new_trip_location_search.dart | 46 ++++++--- frontend/lib/modules/new_trip_map.dart | 16 ++-- frontend/lib/pages/current_trip.dart | 2 +- frontend/lib/structs/landmark.dart | 7 +- frontend/lib/utils/fetch_trip.dart | 14 ++- frontend/lib/utils/load_landmark_image.dart | 60 ++++++++++++ frontend/pubspec.lock | 8 ++ frontend/pubspec.yaml | 1 + testing_image_query.py | 50 ++++++++++ 12 files changed, 245 insertions(+), 82 deletions(-) create mode 100644 frontend/lib/utils/load_landmark_image.dart create mode 100644 testing_image_query.py diff --git a/frontend/lib/modules/current_trip_loading_indicator.dart b/frontend/lib/modules/current_trip_loading_indicator.dart index 7d61b2a..3072f72 100644 --- a/frontend/lib/modules/current_trip_loading_indicator.dart +++ b/frontend/lib/modules/current_trip_loading_indicator.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; @@ -15,46 +17,60 @@ class CurrentTripLoadingIndicator extends StatefulWidget { State createState() => _CurrentTripLoadingIndicatorState(); } + +Widget bottomLoadingIndicator = Container( + height: 20.0, // Increase the height to take up more vertical space + + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), // Apply blur effect + child: Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(),) + ), +); + + +Widget loadingText(Trip trip) => FutureBuilder( + future: trip.cityName, + builder: (BuildContext context, AsyncSnapshot snapshot) { + Widget greeter; + + if (snapshot.hasData) { + greeter = AutoSizeText( + maxLines: 1, + 'Generating your trip to ${snapshot.data}...', + style: greeterStyle, + ); + } else if (snapshot.hasError) { + // the exact error is shown in the central part of the trip overview. No need to show it here + greeter = AutoSizeText( + maxLines: 1, + 'Error while loading trip.', + style: greeterStyle, + ); + } else { + greeter = AutoSizeText( + maxLines: 1, + 'Generating your trip...', + style: greeterStyle, + ); + } + return greeter; + } +); + + + + class _CurrentTripLoadingIndicatorState extends State { @override - Widget build(BuildContext context) => Center( - child: FutureBuilder( - future: widget.trip.cityName, - builder: (BuildContext context, AsyncSnapshot snapshot) { - Widget greeter; - Widget loadingIndicator = const Padding( - padding: EdgeInsets.only(top: 10), - child: CircularProgressIndicator() - ); - - if (snapshot.hasData) { - greeter = AutoSizeText( - maxLines: 1, - 'Generating your trip to ${snapshot.data}...', - style: greeterStyle, - ); - } else if (snapshot.hasError) { - // the exact error is shown in the central part of the trip overview. No need to show it here - greeter = AutoSizeText( - maxLines: 1, - 'Error while loading trip.', - style: greeterStyle, - ); - } else { - greeter = AutoSizeText( - maxLines: 1, - 'Generating your trip...', - style: greeterStyle, - ); - } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - greeter, - loadingIndicator, - ], - ); - } - ) + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + Center(child: loadingText(widget.trip)), + Align( + alignment: Alignment.bottomCenter, + child: bottomLoadingIndicator, + ) + ], ); + } \ No newline at end of file diff --git a/frontend/lib/modules/current_trip_panel.dart b/frontend/lib/modules/current_trip_panel.dart index 45ecdcc..33a499f 100644 --- a/frontend/lib/modules/current_trip_panel.dart +++ b/frontend/lib/modules/current_trip_panel.dart @@ -36,7 +36,7 @@ class _CurrentTripPanelState extends State { child: SizedBox( // reuse the exact same height as the panel has when collapsed // this way the greeter will be centered when the panel is collapsed - height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20, + height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT, child: CurrentTripErrorMessage(trip: widget.trip) ), ); @@ -46,19 +46,20 @@ class _CurrentTripPanelState extends State { child: SizedBox( // reuse the exact same height as the panel has when collapsed // this way the greeter will be centered when the panel is collapsed - height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20, + height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT, child: CurrentTripLoadingIndicator(trip: widget.trip), ), ); } else { return ListView( controller: widget.controller, - padding: const EdgeInsets.only(bottom: 30), + padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30), children: [ SizedBox( // reuse the exact same height as the panel has when collapsed // this way the greeter will be centered when the panel is collapsed - height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20, + // note that we need to account for the padding above + height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10, child: CurrentTripGreeter(trip: widget.trip), ), diff --git a/frontend/lib/modules/landmark_card.dart b/frontend/lib/modules/landmark_card.dart index 43d42ef..3ba17ef 100644 --- a/frontend/lib/modules/landmark_card.dart +++ b/frontend/lib/modules/landmark_card.dart @@ -38,8 +38,6 @@ class _LandmarkCardState extends State { imageUrl: widget.landmark.imageURL ?? '', placeholder: (context, url) => Center(child: CircularProgressIndicator()), errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined), - // TODO: make this a switch statement to load a placeholder if null - // cover the whole container meaning the image will be cropped fit: BoxFit.cover, ), ), @@ -103,15 +101,15 @@ class _LandmarkCardState extends State { icon: Icon(Icons.link), label: Text('Website'), ), - if (widget.landmark.wikipediaURL != null) - TextButton.icon( - onPressed: () async { - // open a browser with the wikipedia link - await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); - }, - icon: Icon(Icons.book), - label: Text('Wikipedia'), - ), + // if (widget.landmark.wikipediaURL != null) + // TextButton.icon( + // onPressed: () async { + // // open a browser with the wikipedia link + // await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); + // }, + // icon: Icon(Icons.book), + // label: Text('Wikipedia'), + // ), ], ), ), diff --git a/frontend/lib/modules/new_trip_location_search.dart b/frontend/lib/modules/new_trip_location_search.dart index 2f285c1..d89d4a2 100644 --- a/frontend/lib/modules/new_trip_location_search.dart +++ b/frontend/lib/modules/new_trip_location_search.dart @@ -9,6 +9,15 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; +const Map debugLocations = { + 'paris': [48.8575, 2.3514], + 'london': [51.5074, -0.1278], + 'new york': [40.7128, -74.0060], + 'tokyo': [35.6895, 139.6917], +}; + + + class NewTripLocationSearch extends StatefulWidget { Future prefs = SharedPreferences.getInstance(); Trip trip; @@ -27,26 +36,35 @@ class _NewTripLocationSearchState extends State { setTripLocation (String query) async { List locations = []; + Location startLocation; log('Searching for: $query'); - - try{ - locations = await locationFromAddress(query); - } catch (e) { - log('No results found for: $query : $e'); + if (GeocodingPlatform.instance != null) { + locations.addAll(await locationFromAddress(query)); } if (locations.isNotEmpty) { - Location location = locations.first; - widget.trip.landmarks.clear(); - widget.trip.addLandmark( - Landmark( - uuid: 'pending', - name: query, - location: [location.latitude, location.longitude], - type: typeStart - ) + startLocation = locations.first; + } else { + log('No results found for: $query. Is geocoding available?'); + log('Setting Fallback location'); + List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514]; + startLocation = Location( + latitude: coordinates[0], + longitude: coordinates[1], + timestamp: DateTime.now(), ); } + + widget.trip.landmarks.clear(); + widget.trip.addLandmark( + Landmark( + uuid: 'pending', + name: query, + location: [startLocation.latitude, startLocation.longitude], + type: typeStart + ) + ); + } late Widget locationSearchBar = SearchBar( diff --git a/frontend/lib/modules/new_trip_map.dart b/frontend/lib/modules/new_trip_map.dart index 49445fc..deb7078 100644 --- a/frontend/lib/modules/new_trip_map.dart +++ b/frontend/lib/modules/new_trip_map.dart @@ -26,7 +26,7 @@ class _NewTripMapState extends State { target: LatLng(48.8566, 2.3522), zoom: 11.0, ); - late GoogleMapController _mapController; + GoogleMapController? _mapController; final Set _markers = {}; _onLongPress(LatLng location) { @@ -56,11 +56,15 @@ class _NewTripMapState extends State { ), ) ); - _mapController.moveCamera( - CameraUpdate.newLatLng( - LatLng(landmark.location[0], landmark.location[1]) - ) - ); + // check if the controller is ready + + if (_mapController != null) { + _mapController!.animateCamera( + CameraUpdate.newLatLng( + LatLng(landmark.location[0], landmark.location[1]) + ) + ); + } setState(() {}); } } diff --git a/frontend/lib/pages/current_trip.dart b/frontend/lib/pages/current_trip.dart index 8967f6c..3fc1a71 100644 --- a/frontend/lib/pages/current_trip.dart +++ b/frontend/lib/pages/current_trip.dart @@ -41,7 +41,7 @@ class _TripPageState extends State { maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT, // padding in this context is annoying: it offsets the notion of vertical alignment. // children that want to be centered vertically need to have their size adjusted by 2x the padding - padding: const EdgeInsets.all(10.0), + // padding: const EdgeInsets.all(10.0), // Panel snapping should not be disabled because it significantly improves the user experience // panelSnapping: false borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)), diff --git a/frontend/lib/structs/landmark.dart b/frontend/lib/structs/landmark.dart index 6729a65..de7d893 100644 --- a/frontend/lib/structs/landmark.dart +++ b/frontend/lib/structs/landmark.dart @@ -24,8 +24,7 @@ final class Landmark extends LinkedListEntry{ // description to be shown in the overview final String? nameEN; final String? websiteURL; - final String? wikipediaURL; - final String? imageURL; + String? imageURL; // not final because it can be patched final String? description; final Duration? duration; final bool? visited; @@ -44,7 +43,6 @@ final class Landmark extends LinkedListEntry{ this.nameEN, this.websiteURL, - this.wikipediaURL, this.imageURL, this.description, this.duration, @@ -70,7 +68,6 @@ final class Landmark extends LinkedListEntry{ final isSecondary = json['is_secondary'] as bool?; final nameEN = json['name_en'] as String?; final websiteURL = json['website_url'] as String?; - final wikipediaURL = json['wikipedia_url'] as String?; final imageURL = json['image_url'] as String?; final description = json['description'] as String?; var duration = Duration(minutes: json['duration'] ?? 0) as Duration?; @@ -85,7 +82,6 @@ final class Landmark extends LinkedListEntry{ isSecondary: isSecondary, nameEN: nameEN, websiteURL: websiteURL, - wikipediaURL: wikipediaURL, imageURL: imageURL, description: description, duration: duration, @@ -112,7 +108,6 @@ final class Landmark extends LinkedListEntry{ 'is_secondary': isSecondary, 'name_en': nameEN, 'website_url': websiteURL, - 'wikipedia_url': wikipediaURL, 'image_url': imageURL, 'description': description, 'duration': duration?.inMinutes, diff --git a/frontend/lib/utils/fetch_trip.dart b/frontend/lib/utils/fetch_trip.dart index e6c1f2c..e24f81d 100644 --- a/frontend/lib/utils/fetch_trip.dart +++ b/frontend/lib/utils/fetch_trip.dart @@ -1,5 +1,6 @@ import "dart:convert"; import "dart:developer"; +import "package:anyway/utils/load_landmark_image.dart"; import 'package:dio/dio.dart'; import 'package:anyway/constants.dart'; @@ -85,6 +86,15 @@ fetchTrip( } +patchLandmarkImage(Landmark landmark) async { + // patch the landmark to include an image from an external source + if (landmark.imageURL == null) { + String? newUrl = await getImageUrlFromName(landmark.name); + if (newUrl != null) { + landmark.imageURL = newUrl; + } + } +} Future<(Landmark, String?)> fetchLandmark(String uuid) async { final response = await dio.get( @@ -101,5 +111,7 @@ Future<(Landmark, String?)> fetchLandmark(String uuid) async { log(response.data.toString()); Map json = response.data; String? nextUUID = json["next_uuid"]; - return (Landmark.fromJson(json), nextUUID); + Landmark landmark = Landmark.fromJson(json); + patchLandmarkImage(landmark); + return (landmark, nextUUID); } diff --git a/frontend/lib/utils/load_landmark_image.dart b/frontend/lib/utils/load_landmark_image.dart new file mode 100644 index 0000000..c02a020 --- /dev/null +++ b/frontend/lib/utils/load_landmark_image.dart @@ -0,0 +1,60 @@ +import 'dart:developer'; + +import 'package:dio/dio.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'dart:convert'; + +import 'package:fuzzywuzzy/model/extracted_result.dart'; + +const String baseUrl = "https://en.wikipedia.org/w/api.php"; +final Dio dio = Dio(); + +Future bestPageMatch(String title) async { + final response = await dio.get(baseUrl, queryParameters: { + "action": "query", + "format": "json", + "list": "prefixsearch", + "pssearch": title, + }); + + final data = jsonDecode(response.toString()); + log(data.toString()); + final List results = data["query"]["prefixsearch"] ?? {}; + final Map titlesAndIds = { + for (var d in results) d["title"]: d["pageid"] + }; + if (titlesAndIds.isEmpty) { + log("No pages found for $title"); + return null; + } + + // after the empty check, we can safely assume that there is a best match + final ExtractedResult bestMatch = extractOne( + query: title, + choices: titlesAndIds.keys.toList(), + cutoff: 70, + ); + return titlesAndIds[bestMatch.choice]; +} + +Future getImageUrl(int pageId) async { + final response = await dio.get(baseUrl, queryParameters: { + "action": "query", + "format": "json", + "prop": "pageimages", + "pageids": pageId, + "pithumbsize": 500, + }); + + final data = jsonDecode(response.toString()); + final pageData = data["query"]["pages"][pageId.toString()]; + return pageData["thumbnail"]?["source"]; +} + +Future getImageUrlFromName(String title) async { + int? pageId = await bestPageMatch(title); + if (pageId == null) { + return null; + } + return await getImageUrl(pageId); +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 002529f..770f12c 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -232,6 +232,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fuzzywuzzy: + dependency: "direct main" + description: + name: fuzzywuzzy + sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c" + url: "https://pub.dev" + source: hosted + version: "1.2.0" geocoding: dependency: "direct main" description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 98c7fc2..458a132 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: flutter_launcher_icons: ^0.13.1 permission_handler: ^11.3.1 geolocator: ^13.0.1 + fuzzywuzzy: ^1.2.0 dev_dependencies: flutter_test: diff --git a/testing_image_query.py b/testing_image_query.py new file mode 100644 index 0000000..4c62c4d --- /dev/null +++ b/testing_image_query.py @@ -0,0 +1,50 @@ +import httpx +import json + +base_url = "https://en.wikipedia.org/w/api.php" + +def best_page_match(title) -> int: + params = { + "action": "query", + "format": "json", + "list": "prefixsearch", + "pssearch": title, + } + response = httpx.get(base_url, params=params) + data = response.json() + data = data.get("query", {}).get("prefixsearch", []) + titles_and_ids = {d["title"]: d["pageid"] for d in data} + + for t in titles_and_ids: + if title.lower() == t.lower(): + print("Matched") + return titles_and_ids[t] + +def get_image_url(page_id) -> str: + # https://en.wikipedia.org/w/api.php?action=query&titles=K%C3%B6lner%20Dom&prop=imageinfo&iiprop=url&format=json + params = { + "action": "query", + "format": "json", + "prop": "pageimages", + "pageids": page_id, + "pithumbsize": 500, + } + response = httpx.get(base_url, params=params) + data = response.json() + data = data.get("query", {}).get("pages", {}) + data = data.get(str(page_id), {}) + return data.get("thumbnail", {}).get("source") + +def get_image_url_from_title(title) -> str: + page_id = best_page_match(title) + if page_id is None: + return None + return get_image_url(page_id) + + +print(get_image_url_from_title("kölner dom")) +print(get_image_url_from_title("grossmünster")) +print(get_image_url_from_title("eiffel tower")) +print(get_image_url_from_title("taj mahal")) +print(get_image_url_from_title("big ben")) + From 3f1fe463bfdafae67995f3de2fcc699115c3d881 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Mon, 18 Nov 2024 17:42:52 +0100 Subject: [PATCH 2/8] better help and onboarding --- ...otlin-compiler-17075128324497521906.salive | 0 frontend/lib/main.dart | 4 +- .../current_trip_loading_indicator.dart | 2 +- frontend/lib/modules/current_trip_panel.dart | 2 +- .../lib/modules/current_trip_save_button.dart | 76 +++++++++------- frontend/lib/modules/help_dialog.dart | 25 ++++++ frontend/lib/modules/landmark_card.dart | 1 - frontend/lib/modules/new_trip_button.dart | 4 +- frontend/lib/modules/onboarding_card.dart | 68 +++++++------- frontend/lib/modules/trips_saved_list.dart | 7 +- .../lib/{layout.dart => pages/base_page.dart} | 88 +++++++------------ frontend/lib/pages/current_trip.dart | 11 ++- frontend/lib/pages/new_trip_location.dart | 33 ++++--- frontend/lib/pages/new_trip_preferences.dart | 72 +++++++++------ frontend/lib/pages/onboarding.dart | 63 +++++++++++-- frontend/lib/pages/settings.dart | 52 ++++++----- frontend/lib/utils/get_first_page.dart | 27 ++++++ frontend/lib/utils/load_trips.dart | 3 - frontend/test/widget_test.dart | 30 ------- 19 files changed, 326 insertions(+), 242 deletions(-) create mode 100644 frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive create mode 100644 frontend/lib/modules/help_dialog.dart rename frontend/lib/{layout.dart => pages/base_page.dart} (61%) create mode 100644 frontend/lib/utils/get_first_page.dart delete mode 100644 frontend/test/widget_test.dart diff --git a/frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive b/frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive new file mode 100644 index 0000000..e69de29 diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 77c7616..8e87dc3 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,6 +1,6 @@ +import 'package:anyway/utils/get_first_page.dart'; import 'package:flutter/material.dart'; import 'package:anyway/constants.dart'; -import 'package:anyway/layout.dart'; void main() => runApp(const App()); @@ -14,7 +14,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: APP_NAME, - home: BasePage(mainScreen: "map"), + home: getFirstPage(), theme: APP_THEME, scaffoldMessengerKey: rootScaffoldMessengerKey ); diff --git a/frontend/lib/modules/current_trip_loading_indicator.dart b/frontend/lib/modules/current_trip_loading_indicator.dart index 3072f72..1f81d60 100644 --- a/frontend/lib/modules/current_trip_loading_indicator.dart +++ b/frontend/lib/modules/current_trip_loading_indicator.dart @@ -73,4 +73,4 @@ class _CurrentTripLoadingIndicatorState extends State { const Padding(padding: EdgeInsets.only(top: 10)), - Center(child: saveButton(widget.trip)), + Center(child: saveButton(trip: widget.trip)), ], ); } diff --git a/frontend/lib/modules/current_trip_save_button.dart b/frontend/lib/modules/current_trip_save_button.dart index f4587cf..03ffe66 100644 --- a/frontend/lib/modules/current_trip_save_button.dart +++ b/frontend/lib/modules/current_trip_save_button.dart @@ -5,37 +5,51 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -Widget saveButton(Trip trip) => ElevatedButton( - onPressed: () async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - trip.toPrefs(prefs); - rootScaffoldMessengerKey.currentState!.showSnackBar( - SnackBar( - content: Text('Trip saved'), - duration: Duration(seconds: 2), - dismissDirection: DismissDirection.horizontal + +class saveButton extends StatefulWidget { + Trip trip; + saveButton({super.key, required this.trip}); + + @override + State createState() => _saveButtonState(); +} + +class _saveButtonState extends State { + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + setState(() => widget.trip.toPrefs(prefs)); + rootScaffoldMessengerKey.currentState!.showSnackBar( + SnackBar( + content: Text('Trip saved'), + duration: Duration(seconds: 2), + dismissDirection: DismissDirection.horizontal + ) + ); + }, + child: SizedBox( + width: 100, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.save, + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5), + child: AutoSizeText( + 'Save trip', + maxLines: 2, + ), + ), + ), + ], + ), ) ); - }, - child: SizedBox( - width: 100, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.save, - ), - Expanded( - child: Padding( - padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5), - child: AutoSizeText( - 'Save trip', - maxLines: 2, - ), - ), - ), - ], - ), - ) -); + } +} diff --git a/frontend/lib/modules/help_dialog.dart b/frontend/lib/modules/help_dialog.dart new file mode 100644 index 0000000..75db7b0 --- /dev/null +++ b/frontend/lib/modules/help_dialog.dart @@ -0,0 +1,25 @@ + +import 'package:flutter/material.dart'; + +Future helpDialog(BuildContext context, String title, String content) { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Got it!'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); +} diff --git a/frontend/lib/modules/landmark_card.dart b/frontend/lib/modules/landmark_card.dart index 3ba17ef..8b2d824 100644 --- a/frontend/lib/modules/landmark_card.dart +++ b/frontend/lib/modules/landmark_card.dart @@ -17,7 +17,6 @@ class LandmarkCard extends StatefulWidget { class _LandmarkCardState extends State { @override Widget build(BuildContext context) { - ThemeData theme = Theme.of(context); return Container( height: 160, child: Card( diff --git a/frontend/lib/modules/new_trip_button.dart b/frontend/lib/modules/new_trip_button.dart index ebc1c5b..9dca910 100644 --- a/frontend/lib/modules/new_trip_button.dart +++ b/frontend/lib/modules/new_trip_button.dart @@ -1,5 +1,5 @@ -import 'package:anyway/layout.dart'; import 'package:anyway/main.dart'; +import 'package:anyway/pages/current_trip.dart'; import 'package:anyway/structs/preferences.dart'; import 'package:anyway/structs/trip.dart'; import 'package:anyway/utils/fetch_trip.dart'; @@ -57,7 +57,7 @@ class _NewTripButtonState extends State { fetchTrip(trip, widget.preferences); Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BasePage(mainScreen: "map", trip: trip) + builder: (context) => TripPage(trip: trip) ) ); } diff --git a/frontend/lib/modules/onboarding_card.dart b/frontend/lib/modules/onboarding_card.dart index 4124754..05fc340 100644 --- a/frontend/lib/modules/onboarding_card.dart +++ b/frontend/lib/modules/onboarding_card.dart @@ -2,13 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; class OnboardingCard extends StatelessWidget { - int index; - String title; - String description; - String imagePath; + final String title; + final String description; + final String imagePath; - OnboardingCard({ - required this.index, + const OnboardingCard({ required this.title, required this.description, required this.imagePath, @@ -16,41 +14,35 @@ class OnboardingCard extends StatelessWidget { @override Widget build(BuildContext context) { - Color baseColor = Theme.of(context).colorScheme.secondary; - // have a different color for each card, incrementing the hue - Color currentColor = baseColor.withAlpha(baseColor.alpha - index * 30); - return Container( - color: currentColor, - alignment: Alignment.center, - child: Padding( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - title, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + + return Padding( + padding: EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, ), - Padding(padding: EdgeInsets.only(top: 20)), - SvgPicture.asset( - imagePath, - height: 200, - ), - Padding(padding: EdgeInsets.only(top: 20)), - Text( - description, - style: TextStyle( - fontSize: 16, - ), + ), + Padding(padding: EdgeInsets.only(top: 20)), + SvgPicture.asset( + imagePath, + height: 200, + ), + Padding(padding: EdgeInsets.only(top: 20)), + Text( + description, + style: TextStyle( + fontSize: 16, ), + ), - ] - ), - ) + ] + ), ); } } \ No newline at end of file diff --git a/frontend/lib/modules/trips_saved_list.dart b/frontend/lib/modules/trips_saved_list.dart index 08fce0a..7f6f561 100644 --- a/frontend/lib/modules/trips_saved_list.dart +++ b/frontend/lib/modules/trips_saved_list.dart @@ -1,6 +1,6 @@ +import 'package:anyway/pages/current_trip.dart'; import 'package:flutter/material.dart'; -import 'package:anyway/layout.dart'; import 'package:anyway/structs/trip.dart'; @@ -16,7 +16,6 @@ class TripsOverview extends StatefulWidget { } class _TripsOverviewState extends State { - Widget listBuild (BuildContext context, AsyncSnapshot> snapshot) { List children; if (snapshot.hasData) { @@ -39,7 +38,7 @@ class _TripsOverviewState extends State { onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BasePage(mainScreen: "map", trip: trip) + builder: (context) => TripPage(trip: trip) ) ); }, @@ -74,4 +73,4 @@ class _TripsOverviewState extends State { builder: listBuild, ); } -} \ No newline at end of file +} diff --git a/frontend/lib/layout.dart b/frontend/lib/pages/base_page.dart similarity index 61% rename from frontend/lib/layout.dart rename to frontend/lib/pages/base_page.dart index 3441f6b..0fd9434 100644 --- a/frontend/lib/layout.dart +++ b/frontend/lib/pages/base_page.dart @@ -1,3 +1,5 @@ +import 'package:anyway/modules/help_dialog.dart'; +import 'package:anyway/pages/current_trip.dart'; import 'package:anyway/pages/settings.dart'; import 'package:flutter/material.dart'; @@ -8,22 +10,24 @@ import 'package:anyway/modules/trips_saved_list.dart'; import 'package:anyway/utils/load_trips.dart'; import 'package:anyway/pages/new_trip_location.dart'; -import 'package:anyway/pages/current_trip.dart'; import 'package:anyway/pages/onboarding.dart'; -// BasePage is the scaffold that holds all other pages -// A side drawer is used to switch between pages +// BasePage is the scaffold that holds a child page and a side drawer +// The side drawer is the main way to switch between pages + class BasePage extends StatefulWidget { - final String mainScreen; - final Trip? trip; + final Widget mainScreen; + final Widget title; + final List helpTexts; const BasePage({ super.key, required this.mainScreen, - this.trip, + this.title = const Text(APP_NAME), + this.helpTexts = const [], }); @override @@ -34,53 +38,24 @@ class _BasePageState extends State { @override Widget build(BuildContext context) { - Widget currentView = const Text("loading..."); Future> trips = loadTrips(); - - - if (widget.mainScreen == "map") { - if (widget.trip != null) { - currentView = TripPage(trip: widget.trip!); - } else { - currentView = FutureBuilder( - future: trips, - builder: (context, snapshot) { - if (snapshot.hasData) { - List availableTrips = snapshot.data!; - if (availableTrips.isNotEmpty) { - return TripPage(trip: availableTrips[0]); - } else { - return Scaffold( - body: Center( - child: Text("Wow, so empty!"), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const NewTripPage() - ) - ); - }, - label: Text("Plan a trip"), - ), - ); - } - } else { - return const Text("loading..."); - } - }, - ); - } - } else if (widget.mainScreen == "tutorial") { - currentView = OnboardingPage(); - } else if (widget.mainScreen == "settings") { - currentView = SettingsPage(); - } return Scaffold( - appBar: AppBar(title: Text(APP_NAME)), - body: Center(child: currentView), + appBar: AppBar( + title: widget.title, + actions: [ + IconButton( + icon: const Icon(Icons.help), + tooltip: 'Help', + onPressed: () { + if (widget.helpTexts.isNotEmpty) { + helpDialog(context, widget.helpTexts[0], widget.helpTexts[1]); + } + } + ), + ], + ), + body: Center(child: widget.mainScreen), drawer: Drawer( child: Column( children: [ @@ -104,7 +79,8 @@ class _BasePageState extends State { ListTile( title: const Text('Your Trips'), leading: const Icon(Icons.map), - selected: widget.mainScreen == "map", + // TODO: this is not working! + selected: widget.mainScreen is TripPage, onTap: () {}, trailing: ElevatedButton( onPressed: () { @@ -134,11 +110,12 @@ class _BasePageState extends State { ListTile( title: const Text('How to use'), leading: Icon(Icons.help), - selected: widget.mainScreen == "tutorial", + // TODO: this is not working! + selected: widget.mainScreen is OnboardingPage, onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BasePage(mainScreen: "tutorial") + builder: (context) => OnboardingPage() ) ); }, @@ -148,11 +125,12 @@ class _BasePageState extends State { ListTile( title: const Text('Settings'), leading: const Icon(Icons.settings), - selected: widget.mainScreen == "settings", + // TODO: this is not working! + selected: widget.mainScreen is SettingsPage, onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BasePage(mainScreen: "settings") + builder: (context) => SettingsPage() ) ); }, diff --git a/frontend/lib/pages/current_trip.dart b/frontend/lib/pages/current_trip.dart index 3fc1a71..7f59c3d 100644 --- a/frontend/lib/pages/current_trip.dart +++ b/frontend/lib/pages/current_trip.dart @@ -1,4 +1,5 @@ import 'package:anyway/constants.dart'; +import 'package:anyway/pages/base_page.dart'; import 'package:flutter/material.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart'; @@ -31,7 +32,8 @@ class _TripPageState extends State { @override Widget build(BuildContext context) { - return SlidingUpPanel( + return BasePage( + mainScreen: SlidingUpPanel( // use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview panelBuilder: (scrollcontroller) => CurrentTripPanel(controller: scrollcontroller, trip: widget.trip), // using collapsed and panelBuilder seems to show both at the same time, so we include the greeter in the panelBuilder @@ -52,6 +54,13 @@ class _TripPageState extends State { color: Colors.black, ) ], + ), + title: FutureBuilder( + future: widget.trip.cityName, + builder: (context, snapshot) => Text( + 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}', + ) + ), ); } } diff --git a/frontend/lib/pages/new_trip_location.dart b/frontend/lib/pages/new_trip_location.dart index 7fb3b4b..2fba16f 100644 --- a/frontend/lib/pages/new_trip_location.dart +++ b/frontend/lib/pages/new_trip_location.dart @@ -1,5 +1,5 @@ -import 'package:anyway/modules/new_trip_button.dart'; import 'package:anyway/modules/new_trip_options_button.dart'; +import 'package:anyway/pages/base_page.dart'; import 'package:flutter/material.dart'; import "package:anyway/structs/trip.dart"; @@ -19,23 +19,28 @@ class _NewTripPageState extends State { final TextEditingController lonController = TextEditingController(); Trip trip = Trip(); + @override Widget build(BuildContext context) { // floating search bar and map as a background - return Scaffold( - appBar: AppBar( - title: const Text('New Trip'), + return BasePage( + mainScreen: Scaffold( + body: Stack( + children: [ + NewTripMap(trip), + Padding( + padding: EdgeInsets.all(15), + child: NewTripLocationSearch(trip), + ), + ], + ), + floatingActionButton: NewTripOptionsButton(trip: trip), ), - body: Stack( - children: [ - NewTripMap(trip), - Padding( - padding: EdgeInsets.all(15), - child: NewTripLocationSearch(trip), - ), - ], - ), - floatingActionButton: NewTripOptionsButton(trip: trip), + title: Text("New Trip"), + helpTexts: [ + "Setting the start location", + "To set the starting point, type a city name in the search bar. You can also navigate the map like you're used to and long press anywhere to set a starting point." + ], ); } } diff --git a/frontend/lib/pages/new_trip_preferences.dart b/frontend/lib/pages/new_trip_preferences.dart index 882cb53..15b7066 100644 --- a/frontend/lib/pages/new_trip_preferences.dart +++ b/frontend/lib/pages/new_trip_preferences.dart @@ -1,4 +1,5 @@ import 'package:anyway/modules/new_trip_button.dart'; +import 'package:anyway/pages/base_page.dart'; import 'package:anyway/structs/preferences.dart'; import 'package:anyway/structs/trip.dart'; import 'package:flutter/cupertino.dart'; @@ -19,41 +20,54 @@ class _NewTripPreferencesPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: ListView( - children: [ - // Center( - // child: CircleAvatar( - // radius: 100, - // child: Icon(Icons.person, size: 100), - // ) - // ), - Padding(padding: EdgeInsets.only(top: 30)), - Center( - child: FutureBuilder( - future: widget.trip.cityName, - builder: (context, snapshot) => Text( - 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold) - ) - ) - ), + return BasePage( + mainScreen: Scaffold( + body: ListView( + children: [ + // Center( + // child: CircleAvatar( + // radius: 100, + // child: Icon(Icons.person, size: 100), + // ) + // ), + // Padding(padding: EdgeInsets.only(top: 30)), + // Center( + // child: FutureBuilder( + // future: widget.trip.cityName, + // builder: (context, snapshot) => Text( + // 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}', + // style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold) + // ) + // ) + // ), - Center( - child: Padding( - padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0), - child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18)) + Center( + child: Padding( + padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0), + child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18)) + ), ), - ), - Divider(indent: 25, endIndent: 25, height: 50), + Divider(indent: 25, endIndent: 25, height: 50), - durationPicker(preferences.maxTime), + durationPicker(preferences.maxTime), - preferenceSliders([preferences.sightseeing, preferences.shopping, preferences.nature]), - ] + preferenceSliders([preferences.sightseeing, preferences.shopping, preferences.nature]), + ] + ), + floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences), ), - floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences), + + title: FutureBuilder( + future: widget.trip.cityName, + builder: (context, snapshot) => Text( + 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}', + ) + ), + helpTexts: [ + 'Trip preferences', + 'Set your preferences for this trip. These will be used to generate a custom itinerary.' + ], ); } diff --git a/frontend/lib/pages/onboarding.dart b/frontend/lib/pages/onboarding.dart index e23a016..0f5dc25 100644 --- a/frontend/lib/pages/onboarding.dart +++ b/frontend/lib/pages/onboarding.dart @@ -2,6 +2,26 @@ import 'package:anyway/modules/onboarding_card.dart'; import 'package:anyway/pages/new_trip_location.dart'; import 'package:flutter/material.dart'; + +const List onboardingCards = [ + OnboardingCard( + title: "Welcome to anyway!", + description: "Anyway helps you plan a city trip that suits your wishes.", + imagePath: "assets/city.svg" + ), + OnboardingCard( + title: "Find your way", + description: "Bored by churches? No problem! Hate shopping? No worries! Instead of suggesting the generic trips that bore you, anyway will try to give you recommendations that really suit you.", + imagePath: "assets/plan.svg" + ), + OnboardingCard( + title: "Change your mind", + description: "Feet get sore, the weather changes. Anyway understands that! Move or remove destinations, visit hidden gems along your journey, do your own thing. Anyway adapts to your spontaneous decisions.", + imagePath: "assets/cat.svg" + ), +]; + + class OnboardingPage extends StatefulWidget { const OnboardingPage({super.key}); @@ -13,24 +33,32 @@ class _OnboardingPageState extends State { @override Widget build(BuildContext context) { final PageController _controller = PageController(); + return Scaffold( body: Stack( children: [ + // horizontally scrollable list of pages PageView( - // horizontally scrollable list of pages controller: _controller, - children: [ - OnboardingCard(index: 1, title: "Welcome to anyway!", description: "Anyway helps you plan a city trip that suits your wishes.", imagePath: "assets/city.svg"), - OnboardingCard(index: 2, title: "Find your way", description: "Bored by churches? No problem! Hate shopping? No worries! More than showing you the typical 'must-sees' of a city, anyway will try to give you recommendations that really suit you.", imagePath: "assets/plan.svg"), - OnboardingCard(index: 3, title: "Change your mind", description: "Life happens when you're busy making plans. Anyway understands that! Move or remove destinations, visit hidden gems along your journey, do your own thing. Anyway adapts to your spontaneous decisions.", imagePath: "assets/cat.svg"), - ], + children: List.generate( + onboardingCards.length, + (index) { + Color currentColor = Colors.red.withAlpha(Colors.red.alpha - index * 30); + return Container( + color: currentColor, + alignment: Alignment.center, + child: onboardingCards[index], + ); + } + ) ), ], ), - floatingActionButton: FloatingActionButton( + + floatingActionButton: FloatingActionButton.extended( onPressed: () { - if (_controller.page == 2) { + if (_controller.page == onboardingCards.length - 1) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => const NewTripPage() @@ -40,7 +68,24 @@ class _OnboardingPageState extends State { _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease); } }, - child: Icon(Icons.arrow_forward), + label: ListenableBuilder( + listenable: _controller, + builder: (context, child) { + if (_controller.page == onboardingCards.length - 1) { + // icon and text side by side + return Row( + children: [ + const Text("Start planning!"), + Padding(padding: const EdgeInsets.only(right: 8.0)), + const Icon(Icons.map_outlined) + ], + ); + + } else { + return const Icon(Icons.arrow_forward); + } + } + ) ), ); } diff --git a/frontend/lib/pages/settings.dart b/frontend/lib/pages/settings.dart index d4c69a0..3d5aff6 100644 --- a/frontend/lib/pages/settings.dart +++ b/frontend/lib/pages/settings.dart @@ -1,5 +1,6 @@ import 'package:anyway/constants.dart'; import 'package:anyway/main.dart'; +import 'package:anyway/pages/base_page.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -16,30 +17,37 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { @override Widget build(BuildContext context) { - return ListView( - padding: EdgeInsets.all(15), - children: [ - // First a round, centered image - Center( - child: CircleAvatar( - radius: 75, - child: Icon(Icons.settings, size: 100), - ) - ), - Center( - child: Text('Global settings', style: TextStyle(fontSize: 24)) - ), + return BasePage( + mainScreen: ListView( + padding: EdgeInsets.all(15), + children: [ + // First a round, centered image + Center( + child: CircleAvatar( + radius: 75, + child: Icon(Icons.settings, size: 100), + ) + ), + Center( + child: Text('Global settings', style: TextStyle(fontSize: 24)) + ), - Divider(indent: 25, endIndent: 25, height: 50), + Divider(indent: 25, endIndent: 25, height: 50), - darkMode(), - setLocationUsage(), - setDebugMode(), + darkMode(), + setLocationUsage(), + setDebugMode(), - Divider(indent: 25, endIndent: 25, height: 50), + Divider(indent: 25, endIndent: 25, height: 50), - privacyInfo(), - ] + privacyInfo(), + ] + ), + title: Text('Settings'), + helpTexts: [ + 'Settings', + 'Preferences set in this page are global and will affect the entire application.' + ], ); } @@ -169,7 +177,9 @@ class _SettingsPageState extends State { return Center( child: Column( children: [ - Text('Our privacy policy is available under:'), + Text('AnyWay does not collect or store any of the data that is submitted via the app. The location of your trip is not stored. The location feature is only used to show your current location on the map, it is not transmitted to our servers.', textAlign: TextAlign.center), + Padding(padding: EdgeInsets.only(top: 3)), + Text('Our full privacy policy is available under:', textAlign: TextAlign.center), TextButton.icon( icon: Icon(Icons.info), diff --git a/frontend/lib/utils/get_first_page.dart b/frontend/lib/utils/get_first_page.dart new file mode 100644 index 0000000..161e957 --- /dev/null +++ b/frontend/lib/utils/get_first_page.dart @@ -0,0 +1,27 @@ +import 'package:anyway/pages/current_trip.dart'; +import 'package:anyway/pages/onboarding.dart'; +import 'package:anyway/structs/trip.dart'; +import 'package:anyway/utils/load_trips.dart'; +import 'package:flutter/material.dart'; + +Widget getFirstPage() { + Future> trips = loadTrips(); + // test if there are any active trips + // if there are, return the trip list + // if there are not, return the onboarding page + return FutureBuilder( + future: trips, + builder: (context, snapshot) { + if (snapshot.hasData) { + List availableTrips = snapshot.data!; + if (availableTrips.isNotEmpty) { + return TripPage(trip: availableTrips[0]); + } else { + return OnboardingPage(); + } + } else { + return CircularProgressIndicator(); + } + } + ); +} \ No newline at end of file diff --git a/frontend/lib/utils/load_trips.dart b/frontend/lib/utils/load_trips.dart index cd4bdd7..e54e899 100644 --- a/frontend/lib/utils/load_trips.dart +++ b/frontend/lib/utils/load_trips.dart @@ -1,7 +1,4 @@ -import 'dart:collection'; - import 'package:anyway/structs/trip.dart'; -import 'package:anyway/structs/landmark.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future> loadTrips() async { diff --git a/frontend/test/widget_test.dart b/frontend/test/widget_test.dart deleted file mode 100644 index cb3797b..0000000 --- a/frontend/test/widget_test.dart +++ /dev/null @@ -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:anyway/main.dart'; -import 'package:anyway/layout.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(BasePage(mainScreen: "map",)); - - // Verfiy that the title is displayed - expect(find.text('City Nav'), findsOneWidget); - - // 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); - }); -} From 4baf045c8c23731e44b8afa2b13e3b9116e989b0 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Mon, 2 Dec 2024 10:43:42 +0100 Subject: [PATCH 3/8] better onboarding --- ...otlin-compiler-17075128324497521906.salive | 0 frontend/assets/confused.svg | 427 ++++++++++++++++++ frontend/lib/pages/onboarding.dart | 91 ++-- 3 files changed, 475 insertions(+), 43 deletions(-) delete mode 100644 frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive create mode 100644 frontend/assets/confused.svg diff --git a/frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive b/frontend/android/.kotlin/sessions/kotlin-compiler-17075128324497521906.salive deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/assets/confused.svg b/frontend/assets/confused.svg new file mode 100644 index 0000000..e5182a2 --- /dev/null +++ b/frontend/assets/confused.svg @@ -0,0 +1,427 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/lib/pages/onboarding.dart b/frontend/lib/pages/onboarding.dart index 0f5dc25..ff335e8 100644 --- a/frontend/lib/pages/onboarding.dart +++ b/frontend/lib/pages/onboarding.dart @@ -2,7 +2,6 @@ import 'package:anyway/modules/onboarding_card.dart'; import 'package:anyway/pages/new_trip_location.dart'; import 'package:flutter/material.dart'; - const List onboardingCards = [ OnboardingCard( title: "Welcome to anyway!", @@ -19,9 +18,13 @@ const List onboardingCards = [ description: "Feet get sore, the weather changes. Anyway understands that! Move or remove destinations, visit hidden gems along your journey, do your own thing. Anyway adapts to your spontaneous decisions.", imagePath: "assets/cat.svg" ), + OnboardingCard( + title: "Feeling lost?", + description: "Whenever you are confused or need help with the app, look out for the question mark in the top right corner. Help is just a tap away!", + imagePath: "assets/confused.svg" + ), ]; - class OnboardingPage extends StatefulWidget { const OnboardingPage({super.key}); @@ -37,55 +40,57 @@ class _OnboardingPageState extends State { return Scaffold( body: Stack( children: [ - // horizontally scrollable list of pages - PageView( - controller: _controller, - - children: List.generate( - onboardingCards.length, - (index) { - Color currentColor = Colors.red.withAlpha(Colors.red.alpha - index * 30); - return Container( - color: currentColor, - alignment: Alignment.center, - child: onboardingCards[index], - ); - } - ) + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.red, Colors.blue], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: PageView( + controller: _controller, + children: List.generate( + onboardingCards.length, + (index) { + return Container( + alignment: Alignment.center, + child: onboardingCards[index], + ); + } + ), + ), ), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { + onPressed: () { + if (_controller.page == onboardingCards.length - 1) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const NewTripPage() + ) + ); + } else { + _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease); + } + }, + label: ListenableBuilder( + listenable: _controller, + builder: (context, child) { if (_controller.page == onboardingCards.length - 1) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const NewTripPage() - ) + return Row( + children: [ + const Text("Start planning!"), + Padding(padding: const EdgeInsets.only(right: 8.0)), + const Icon(Icons.map_outlined) + ], ); } else { - _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease); + return const Icon(Icons.arrow_forward); } - }, - label: ListenableBuilder( - listenable: _controller, - builder: (context, child) { - if (_controller.page == onboardingCards.length - 1) { - // icon and text side by side - return Row( - children: [ - const Text("Start planning!"), - Padding(padding: const EdgeInsets.only(right: 8.0)), - const Icon(Icons.map_outlined) - ], - ); - - } else { - return const Icon(Icons.arrow_forward); - } - } - ) + } + ) ), ); } From d186a51a8771505e0f5c2343e097371f3cc255e2 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Sun, 15 Dec 2024 16:30:17 +0100 Subject: [PATCH 4/8] WIP: ladnmark card adjustments --- .../modules/current_trip_landmarks_list.dart | 26 +- frontend/lib/modules/landmark_card.dart | 238 +++++++++++------- 2 files changed, 145 insertions(+), 119 deletions(-) diff --git a/frontend/lib/modules/current_trip_landmarks_list.dart b/frontend/lib/modules/current_trip_landmarks_list.dart index b486a97..39333f6 100644 --- a/frontend/lib/modules/current_trip_landmarks_list.dart +++ b/frontend/lib/modules/current_trip_landmarks_list.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:anyway/modules/landmark_card.dart'; import 'package:anyway/structs/landmark.dart'; import 'package:anyway/structs/trip.dart'; -import 'package:anyway/main.dart'; @@ -25,30 +24,7 @@ List landmarksList(Trip trip) { for (Landmark landmark in trip.landmarks) { children.add( - Dismissible( - key: ValueKey(landmark.hashCode), - child: LandmarkCard(landmark), - dismissThresholds: {DismissDirection.endToStart: 0.95, DismissDirection.startToEnd: 0.95}, - onDismissed: (direction) { - log('Removing ${landmark.name}'); - trip.removeLandmark(landmark); - - rootScaffoldMessengerKey.currentState!.showSnackBar( - SnackBar(content: Text("We won't show ${landmark.name} again")) - ); - }, - - background: Container(color: Colors.red), - secondaryBackground: Container( - color: Colors.red, - child: Icon( - Icons.delete, - color: Colors.white, - ), - padding: EdgeInsets.all(15), - alignment: Alignment.centerRight, - ), - ) + LandmarkCard(landmark, trip), ); if (landmark.next != null) { diff --git a/frontend/lib/modules/landmark_card.dart b/frontend/lib/modules/landmark_card.dart index 8b2d824..1b49fc2 100644 --- a/frontend/lib/modules/landmark_card.dart +++ b/frontend/lib/modules/landmark_card.dart @@ -1,3 +1,5 @@ +import 'package:anyway/main.dart'; +import 'package:anyway/structs/trip.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -6,8 +8,12 @@ import 'package:anyway/structs/landmark.dart'; class LandmarkCard extends StatefulWidget { final Landmark landmark; + final Trip parentTrip; - LandmarkCard(this.landmark); + LandmarkCard( + this.landmark, + this.parentTrip, + ); @override _LandmarkCardState createState() => _LandmarkCardState(); @@ -18,106 +24,150 @@ class _LandmarkCardState extends State { @override Widget build(BuildContext context) { return Container( - height: 160, child: Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0), ), elevation: 5, clipBehavior: Clip.antiAliasWithSaveLayer, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( // the image on the left - // inherit the height of the parent container - height: double.infinity, - // force a fixed width - width: 160, - child: CachedNetworkImage( - imageUrl: widget.landmark.imageURL ?? '', - placeholder: (context, url) => Center(child: CircularProgressIndicator()), - errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined), - fit: BoxFit.cover, - ), - ), - Flexible( - child: Padding( - padding: EdgeInsets.all(10), - child: Column( - children: [ - Row( - children: [ - Flexible( - child: Text( - widget.landmark.name, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - ), - ) - ], - ), - if (widget.landmark.nameEN != null) - Row( - children: [ - Flexible( - child: Text( - widget.landmark.nameEN!, - style: const TextStyle( - fontSize: 16, - ), - maxLines: 1, - ), - ) - ], - ), - SingleChildScrollView( - // allows the buttons to be scrolled - scrollDirection: Axis.horizontal, - child: Wrap( - spacing: 10, - // show the type, the website, and the wikipedia link as buttons/labels in a row - children: [ - TextButton.icon( - onPressed: () {}, - icon: widget.landmark.type.icon, - label: Text(widget.landmark.type.name), - ), - if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0) - TextButton.icon( - onPressed: () {}, - icon: Icon(Icons.hourglass_bottom), - label: Text('${widget.landmark.duration!.inMinutes} minutes'), - ), - if (widget.landmark.websiteURL != null) - TextButton.icon( - onPressed: () async { - // open a browser with the website link - await launchUrl(Uri.parse(widget.landmark.websiteURL!)); - }, - icon: Icon(Icons.link), - label: Text('Website'), - ), - // if (widget.landmark.wikipediaURL != null) - // TextButton.icon( - // onPressed: () async { - // // open a browser with the wikipedia link - // await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); - // }, - // icon: Icon(Icons.book), - // label: Text('Wikipedia'), - // ), - ], - ), - ), - ], - ), - ), - ), - ], + // if the image is available, display it on the left side of the card, otherwise only display the text + child: widget.landmark.imageURL != null ? splitLayout() : textLayout(), + ), + ); + } + + Widget splitLayout() { + // If an image is available, display it on the left side of the card + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + // the image on the left + width: 160, + height: 160, + + child: CachedNetworkImage( + imageUrl: widget.landmark.imageURL ?? '', + placeholder: (context, url) => Center(child: CircularProgressIndicator()), + errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined), + fit: BoxFit.cover, + ), ), + Flexible( + child: textLayout(), + ), + ], + ); + } + + Widget textLayout() { + return Padding( + padding: EdgeInsets.all(10), + child: Column( + children: [ + Row( + children: [ + Flexible( + child: Text( + widget.landmark.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + ), + ) + ], + ), + if (widget.landmark.nameEN != null) + Row( + children: [ + Flexible( + child: Text( + widget.landmark.nameEN!, + style: const TextStyle( + fontSize: 16, + ), + maxLines: 1, + ), + ) + ], + ), + SingleChildScrollView( + // allows the buttons to be scrolled + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 10, + // show the type, the website, and the wikipedia link as buttons/labels in a row + children: [ + TextButton.icon( + onPressed: () {}, + icon: widget.landmark.type.icon, + label: Text(widget.landmark.type.name), + ), + if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0) + TextButton.icon( + onPressed: () {}, + icon: Icon(Icons.hourglass_bottom), + label: Text('${widget.landmark.duration!.inMinutes} minutes'), + ), + if (widget.landmark.websiteURL != null) + TextButton.icon( + onPressed: () async { + // open a browser with the website link + await launchUrl(Uri.parse(widget.landmark.websiteURL!)); + }, + icon: Icon(Icons.link), + label: Text('Website'), + ), + // if (widget.landmark.wikipediaURL != null) + // TextButton.icon( + // onPressed: () async { + // // open a browser with the wikipedia link + // await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); + // }, + // icon: Icon(Icons.book), + // label: Text('Wikipedia'), + // ), + PopupMenuButton( + icon: Icon(Icons.settings), + style: TextButtonTheme.of(context).style, + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + leading: Icon(Icons.delete), + title: Text('Delete'), + onTap: () async { + setState(() { + widget.parentTrip.removeLandmark(widget.landmark); + }); + rootScaffoldMessengerKey.currentState!.showSnackBar( + SnackBar(content: Text("We won't show ${widget.landmark.name} again")) + ); + + + }, + ), + ), + PopupMenuItem( + child: ListTile( + leading: Icon(Icons.star), + title: Text('Favorite'), + onTap: () async { + // delete the landmark + // await deleteLandmark(widget.landmark); + + }, + ), + ), + + ], + ) + + ], + ), + ), + ], ), ); } From e78bee4597d2ccd9460a42f03dac5c56d8e0abed Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Tue, 17 Dec 2024 10:28:33 +0100 Subject: [PATCH 5/8] some more images --- frontend/lib/modules/landmark_card.dart | 130 ++++++++++---------- frontend/lib/utils/fetch_trip.dart | 5 + frontend/lib/utils/load_landmark_image.dart | 11 ++ 3 files changed, 80 insertions(+), 66 deletions(-) diff --git a/frontend/lib/modules/landmark_card.dart b/frontend/lib/modules/landmark_card.dart index 1b49fc2..e920e79 100644 --- a/frontend/lib/modules/landmark_card.dart +++ b/frontend/lib/modules/landmark_card.dart @@ -23,6 +23,15 @@ class LandmarkCard extends StatefulWidget { class _LandmarkCardState extends State { @override Widget build(BuildContext context) { + if (widget.landmark.type == typeStart || widget.landmark.type == typeFinish) { + return TextButton.icon( + onPressed: () {}, + icon: widget.landmark.type.icon, + label: Text(widget.landmark.name), + ); + + } + // else: return Container( child: Card( shape: RoundedRectangleBorder( @@ -93,78 +102,67 @@ class _LandmarkCardState extends State { ) ], ), - SingleChildScrollView( - // allows the buttons to be scrolled - scrollDirection: Axis.horizontal, - child: Wrap( - spacing: 10, - // show the type, the website, and the wikipedia link as buttons/labels in a row - children: [ - TextButton.icon( - onPressed: () {}, - icon: widget.landmark.type.icon, - label: Text(widget.landmark.type.name), - ), - if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0) + Padding(padding: EdgeInsets.only(top: 10)), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + // allows the buttons to be scrolled + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 10, + // show the type, the website, and the wikipedia link as buttons/labels in a row + children: [ TextButton.icon( onPressed: () {}, - icon: Icon(Icons.hourglass_bottom), - label: Text('${widget.landmark.duration!.inMinutes} minutes'), + icon: widget.landmark.type.icon, + label: Text(widget.landmark.type.name), ), - if (widget.landmark.websiteURL != null) - TextButton.icon( - onPressed: () async { - // open a browser with the website link - await launchUrl(Uri.parse(widget.landmark.websiteURL!)); - }, - icon: Icon(Icons.link), - label: Text('Website'), - ), - // if (widget.landmark.wikipediaURL != null) - // TextButton.icon( - // onPressed: () async { - // // open a browser with the wikipedia link - // await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); - // }, - // icon: Icon(Icons.book), - // label: Text('Wikipedia'), - // ), - PopupMenuButton( - icon: Icon(Icons.settings), - style: TextButtonTheme.of(context).style, - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - leading: Icon(Icons.delete), - title: Text('Delete'), - onTap: () async { - setState(() { + if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0) + TextButton.icon( + onPressed: () {}, + icon: Icon(Icons.hourglass_bottom), + label: Text('${widget.landmark.duration!.inMinutes} minutes'), + ), + if (widget.landmark.websiteURL != null) + TextButton.icon( + onPressed: () async { + // open a browser with the website link + await launchUrl(Uri.parse(widget.landmark.websiteURL!)); + }, + icon: Icon(Icons.link), + label: Text('Website'), + ), + PopupMenuButton( + icon: Icon(Icons.settings), + style: TextButtonTheme.of(context).style, + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + leading: Icon(Icons.delete), + title: Text('Delete'), + onTap: () async { widget.parentTrip.removeLandmark(widget.landmark); - }); - rootScaffoldMessengerKey.currentState!.showSnackBar( - SnackBar(content: Text("We won't show ${widget.landmark.name} again")) - ); - - - }, + rootScaffoldMessengerKey.currentState!.showSnackBar( + SnackBar(content: Text("We won't show ${widget.landmark.name} again")) + ); + }, + ), ), - ), - PopupMenuItem( - child: ListTile( - leading: Icon(Icons.star), - title: Text('Favorite'), - onTap: () async { - // delete the landmark - // await deleteLandmark(widget.landmark); - - }, + PopupMenuItem( + child: ListTile( + leading: Icon(Icons.star), + title: Text('Favorite'), + onTap: () async { + // delete the landmark + // await deleteLandmark(widget.landmark); + }, + ), ), - ), - - ], - ) - - ], + ], + ) + + ], + ), ), ), ], diff --git a/frontend/lib/utils/fetch_trip.dart b/frontend/lib/utils/fetch_trip.dart index e24f81d..4fc35e2 100644 --- a/frontend/lib/utils/fetch_trip.dart +++ b/frontend/lib/utils/fetch_trip.dart @@ -93,6 +93,11 @@ patchLandmarkImage(Landmark landmark) async { if (newUrl != null) { landmark.imageURL = newUrl; } + } else if (landmark.imageURL!.contains("photos.app.goo.gl")) { + // the image is a google photos link, we should get the image behind the link + String? newUrl = await getImageUrlFromGooglePhotos(landmark.imageURL!); + // also set the new url if it is null + landmark.imageURL = newUrl; } } diff --git a/frontend/lib/utils/load_landmark_image.dart b/frontend/lib/utils/load_landmark_image.dart index c02a020..8b81bf4 100644 --- a/frontend/lib/utils/load_landmark_image.dart +++ b/frontend/lib/utils/load_landmark_image.dart @@ -58,3 +58,14 @@ Future getImageUrlFromName(String title) async { } return await getImageUrl(pageId); } + + +Future getImageUrlFromGooglePhotos(String url) async { + // this is a very simple implementation that just gets the image behind the link + // it is not guaranteed to work for all google photos links + final response = await dio.get(url); + final data = response.toString(); + final int start = data.indexOf("https://lh3.googleusercontent.com"); + final int end = data.indexOf('"', start); + return data.substring(start, end); +} From d992b6253359ae87d97ff2930fdb89d584728be2 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Tue, 17 Dec 2024 11:17:59 +0100 Subject: [PATCH 6/8] tentatively shrink trip overview, nicer onboarding --- frontend/lib/main.dart | 2 + .../lib/modules/current_trip_save_button.dart | 6 +- frontend/lib/modules/trips_saved_list.dart | 76 ++++++++----------- frontend/lib/pages/base_page.dart | 8 +- frontend/lib/pages/onboarding.dart | 70 +++++++++++------ frontend/lib/structs/trip.dart | 7 -- frontend/lib/utils/get_first_page.dart | 44 +++++++---- frontend/lib/utils/load_trips.dart | 41 +++++++--- 8 files changed, 149 insertions(+), 105 deletions(-) diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 8e87dc3..5f3dc49 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,10 +1,12 @@ import 'package:anyway/utils/get_first_page.dart'; +import 'package:anyway/utils/load_trips.dart'; import 'package:flutter/material.dart'; import 'package:anyway/constants.dart'; void main() => runApp(const App()); final GlobalKey rootScaffoldMessengerKey = GlobalKey(); +final SavedTrips savedTrips = SavedTrips(); class App extends StatelessWidget { const App({super.key}); diff --git a/frontend/lib/modules/current_trip_save_button.dart b/frontend/lib/modules/current_trip_save_button.dart index 03ffe66..0b8e773 100644 --- a/frontend/lib/modules/current_trip_save_button.dart +++ b/frontend/lib/modules/current_trip_save_button.dart @@ -3,7 +3,6 @@ import 'package:anyway/main.dart'; import 'package:anyway/structs/trip.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; class saveButton extends StatefulWidget { @@ -19,8 +18,9 @@ class _saveButtonState extends State { Widget build(BuildContext context) { return ElevatedButton( onPressed: () async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - setState(() => widget.trip.toPrefs(prefs)); + savedTrips.addTrip(widget.trip); + // SharedPreferences prefs = await SharedPreferences.getInstance(); + // setState(() => widget.trip.toPrefs(prefs)); rootScaffoldMessengerKey.currentState!.showSnackBar( SnackBar( content: Text('Trip saved'), diff --git a/frontend/lib/modules/trips_saved_list.dart b/frontend/lib/modules/trips_saved_list.dart index 7f6f561..fd53ada 100644 --- a/frontend/lib/modules/trips_saved_list.dart +++ b/frontend/lib/modules/trips_saved_list.dart @@ -1,11 +1,12 @@ import 'package:anyway/pages/current_trip.dart'; +import 'package:anyway/utils/load_trips.dart'; import 'package:flutter/material.dart'; import 'package:anyway/structs/trip.dart'; class TripsOverview extends StatefulWidget { - final Future> trips; + final SavedTrips trips; const TripsOverview({ super.key, required this.trips, @@ -16,49 +17,34 @@ class TripsOverview extends StatefulWidget { } class _TripsOverviewState extends State { - Widget listBuild (BuildContext context, AsyncSnapshot> snapshot) { + Widget listBuild (BuildContext context, SavedTrips trips) { List children; - if (snapshot.hasData) { - children = List.generate(snapshot.data!.length, (index) { - Trip trip = snapshot.data![index]; - return ListTile( - title: FutureBuilder( - future: trip.cityName, - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData) { - return Text("Trip to ${snapshot.data}"); - } else if (snapshot.hasError) { - return Text("Error: ${snapshot.error}"); - } else { - return const Text("Trip to ..."); - } - }, - ), - leading: Icon(Icons.pin_drop), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => TripPage(trip: trip) - ) - ); + List items = trips.trips; + children = List.generate(items.length, (index) { + Trip trip = items[index]; + return ListTile( + title: FutureBuilder( + future: trip.cityName, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return Text("Trip to ${snapshot.data}"); + } else if (snapshot.hasError) { + return Text("Error: ${snapshot.error}"); + } else { + return const Text("Trip to ..."); + } }, - ); - }); - } else if (snapshot.hasError) { - children = [ - const Icon( - Icons.error_outline, - color: Colors.red, - size: 60, ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: Text('Error: ${snapshot.error}'), - ), - ]; - } else { - children = [Center(child: CircularProgressIndicator())]; - } + leading: Icon(Icons.pin_drop), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TripPage(trip: trip) + ) + ); + }, + ); + }); return ListView( children: children, @@ -68,9 +54,11 @@ class _TripsOverviewState extends State { @override Widget build(BuildContext context) { - return FutureBuilder( - future: widget.trips, - builder: listBuild, + return ListenableBuilder( + listenable: widget.trips, + builder: (BuildContext context, Widget? child) { + return listBuild(context, widget.trips); + } ); } } diff --git a/frontend/lib/pages/base_page.dart b/frontend/lib/pages/base_page.dart index 0fd9434..ce36f1f 100644 --- a/frontend/lib/pages/base_page.dart +++ b/frontend/lib/pages/base_page.dart @@ -1,3 +1,4 @@ +import 'package:anyway/main.dart'; import 'package:anyway/modules/help_dialog.dart'; import 'package:anyway/pages/current_trip.dart'; import 'package:anyway/pages/settings.dart'; @@ -38,7 +39,8 @@ class _BasePageState extends State { @override Widget build(BuildContext context) { - Future> trips = loadTrips(); + savedTrips.loadTrips(); + return Scaffold( appBar: AppBar( @@ -98,11 +100,11 @@ class _BasePageState extends State { // through the options in the drawer if there isn't enough vertical // space to fit everything. Expanded( - child: TripsOverview(trips: trips), + child: TripsOverview(trips: savedTrips), ), ElevatedButton( onPressed: () async { - removeAllTripsFromPrefs(); + savedTrips.clearTrips(); }, child: const Text('Clear trips'), ), diff --git a/frontend/lib/pages/onboarding.dart b/frontend/lib/pages/onboarding.dart index ff335e8..3692cb8 100644 --- a/frontend/lib/pages/onboarding.dart +++ b/frontend/lib/pages/onboarding.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:anyway/constants.dart'; import 'package:anyway/modules/onboarding_card.dart'; import 'package:anyway/pages/new_trip_location.dart'; import 'package:flutter/material.dart'; @@ -33,32 +36,51 @@ class OnboardingPage extends StatefulWidget { } class _OnboardingPageState extends State { + final PageController _controller = PageController(); + @override Widget build(BuildContext context) { - final PageController _controller = PageController(); - return Scaffold( body: Stack( children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.red, Colors.blue], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: PageView( - controller: _controller, - children: List.generate( - onboardingCards.length, - (index) { - return Container( - alignment: Alignment.center, - child: onboardingCards[index], - ); - } - ), + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: APP_GRADIENT.colors, + stops: [ + (_controller.hasClients ? _controller.page ?? _controller.initialPage : _controller.initialPage) / onboardingCards.length, + (_controller.hasClients ? _controller.page ?? _controller.initialPage + 1 : _controller.initialPage + 1) / onboardingCards.length, + ], + ), + ), + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), + child: Container( + color: Colors.black.withOpacity(0), + ), + ), + ], + ); + }, + ), + PageView( + controller: _controller, + children: List.generate( + onboardingCards.length, + (index) { + return Container( + alignment: Alignment.center, + child: onboardingCards[index], + ); + } ), ), ], @@ -75,10 +97,10 @@ class _OnboardingPageState extends State { _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease); } }, - label: ListenableBuilder( - listenable: _controller, + label: AnimatedBuilder( + animation: _controller, builder: (context, child) { - if (_controller.page == onboardingCards.length - 1) { + if ((_controller.page ?? _controller.initialPage) == onboardingCards.length - 1) { return Row( children: [ const Text("Start planning!"), diff --git a/frontend/lib/structs/trip.dart b/frontend/lib/structs/trip.dart index 296ba25..44e12c3 100644 --- a/frontend/lib/structs/trip.dart +++ b/frontend/lib/structs/trip.dart @@ -113,10 +113,3 @@ LinkedList readLandmarks(SharedPreferences prefs, String? firstUUID) { } return landmarks; } - - - -void removeAllTripsFromPrefs () async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - prefs.clear(); -} diff --git a/frontend/lib/utils/get_first_page.dart b/frontend/lib/utils/get_first_page.dart index 161e957..e2dffd2 100644 --- a/frontend/lib/utils/get_first_page.dart +++ b/frontend/lib/utils/get_first_page.dart @@ -5,23 +5,37 @@ import 'package:anyway/utils/load_trips.dart'; import 'package:flutter/material.dart'; Widget getFirstPage() { - Future> trips = loadTrips(); - // test if there are any active trips - // if there are, return the trip list - // if there are not, return the onboarding page - return FutureBuilder( - future: trips, - builder: (context, snapshot) { - if (snapshot.hasData) { - List availableTrips = snapshot.data!; - if (availableTrips.isNotEmpty) { - return TripPage(trip: availableTrips[0]); - } else { - return OnboardingPage(); - } + SavedTrips trips = SavedTrips(); + trips.loadTrips(); + + return ListenableBuilder( + listenable: trips, + builder: (BuildContext context, Widget? child) { + List items = trips.trips; + if (items.isNotEmpty) { + return TripPage(trip: items[0]); } else { - return CircularProgressIndicator(); + return OnboardingPage(); } } ); + // Future> trips = loadTrips(); + // // test if there are any active trips + // // if there are, return the trip list + // // if there are not, return the onboarding page + // return FutureBuilder( + // future: trips, + // builder: (context, snapshot) { + // if (snapshot.hasData) { + // List availableTrips = snapshot.data!; + // if (availableTrips.isNotEmpty) { + // return TripPage(trip: availableTrips[0]); + // } else { + // return OnboardingPage(); + // } + // } else { + // return CircularProgressIndicator(); + // } + // } + // ); } \ No newline at end of file diff --git a/frontend/lib/utils/load_trips.dart b/frontend/lib/utils/load_trips.dart index e54e899..dbf7aa7 100644 --- a/frontend/lib/utils/load_trips.dart +++ b/frontend/lib/utils/load_trips.dart @@ -1,16 +1,39 @@ import 'package:anyway/structs/trip.dart'; import 'package:shared_preferences/shared_preferences.dart'; -Future> loadTrips() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); +import 'package:flutter/foundation.dart'; - List trips = []; - Set keys = prefs.getKeys(); - for (String key in keys) { - if (key.startsWith('trip_')) { - String uuid = key.replaceFirst('trip_', ''); - trips.add(Trip.fromPrefs(prefs, uuid)); +class SavedTrips extends ChangeNotifier { + List _trips = []; + + List get trips => _trips; + + void loadTrips() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + + List trips = []; + Set keys = prefs.getKeys(); + for (String key in keys) { + if (key.startsWith('trip_')) { + String uuid = key.replaceFirst('trip_', ''); + trips.add(Trip.fromPrefs(prefs, uuid)); + } } + _trips = trips; + notifyListeners(); + } + + void addTrip(Trip trip) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + trip.toPrefs(prefs); + _trips.add(trip); + notifyListeners(); + } + + void clearTrips () async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.clear(); + _trips = []; + notifyListeners(); } - return trips; } From d4de945df81375196db151cece2a63921e469e49 Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Wed, 18 Dec 2024 13:09:53 +0100 Subject: [PATCH 7/8] cleaner trip loading indicator --- backend/test.py | 111 +++++++++++++ .../current_trip_loading_indicator.dart | 152 ++++++++++++++---- frontend/lib/pages/current_trip.dart | 2 +- frontend/lib/structs/landmark.dart | 2 +- frontend/pubspec.lock | 30 ++-- testing_image_query.py | 50 ------ 6 files changed, 247 insertions(+), 100 deletions(-) create mode 100644 backend/test.py delete mode 100644 testing_image_query.py diff --git a/backend/test.py b/backend/test.py new file mode 100644 index 0000000..672b166 --- /dev/null +++ b/backend/test.py @@ -0,0 +1,111 @@ +import numpy as np + +def euclidean_distance(p1, p2): + print(p1, p2) + return np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) + + +def maximize_score(places, max_distance, fixed_entry, top_k=3): + """ + Maximizes the total score of visited places while staying below the maximum distance. + + Parameters: + places (list of tuples): Each tuple contains (score, (x, y), location). + max_distance (float): The maximum distance that can be traveled. + fixed_entry (tuple): The place that needs to be visited independently of its score. + top_k (int): Number of top candidates to consider in each iteration. + + Returns: + list of tuples: The visited places. + float: The total score of the visited places. + """ + # Initialize total distance and score + total_distance = 0 + total_score = 0 + visited_places = [] + + # Add the fixed entry to the visited list + score, (x, y), _ = fixed_entry + visited_places.append(fixed_entry) + total_score += score + + # Remove the fixed entry from the list of places + remaining_places = [place for place in places if place != fixed_entry] + + # Sort remaining places by score-to-distance ratio + + remaining_places.sort(key=lambda p: p[0] / euclidean_distance((x, y), (p[1][0], p[1][1])), reverse=True) + + # Add places to the visited list if they don't exceed the maximum distance + current_location = (x, y) + while remaining_places and total_distance < max_distance: + # Consider top_k candidates + candidates = remaining_places[:top_k] + best_candidate = None + best_score_increase = -np.inf + + for candidate in candidates: + score, (cx, cy), location = candidate + distance = euclidean_distance(current_location, (cx, cy)) + if total_distance + distance <= max_distance: + score_increase = score / distance + if score_increase > best_score_increase: + best_score_increase = score_increase + best_candidate = candidate + + if best_candidate: + visited_places.append(best_candidate) + total_distance += euclidean_distance(current_location, best_candidate[1]) + total_score += best_candidate[0] + current_location = best_candidate[1] + remaining_places.remove(best_candidate) + else: + break + + return visited_places, total_score + +# Example usage +places = [ + (10, (0, 0), 'A'), + (8, (4, 2), 'B'), + (15, (6, 4), 'C'), + (7, (5, 6), 'D'), + (12, (1, 8), 'E'), + (14, (34, 10), 'F'), + (15, (65, 12), 'G'), + (12, (3, 14), 'H'), + (12, (15, 1), 'I'), + (7, (17, 4), 'J'), + (12, (3, 3), 'K'), + (4, (21, 22), 'L'), + (12, (23, 24), 'M'), + (4, (25, 26), 'N'), + (2, (27, 28), 'O'), +] +fixed_entry = (10, (0, 0), 'A') +max_distance = 50 + +visited_places, total_score = maximize_score(places, max_distance, fixed_entry) +print("Visited Places:", visited_places) +print("Total Score:", total_score) + +import matplotlib.pyplot as plt + +# Plot the route +def plot_route(visited_places): + x_coords = [place[1][0] for place in visited_places] + y_coords = [place[1][1] for place in visited_places] + labels = [place[2] for place in visited_places] + + plt.figure(figsize=(10, 6)) + plt.plot(x_coords, y_coords, marker='o', linestyle='-', color='b') + for i, label in enumerate(labels): + plt.text(x_coords[i], y_coords[i], label, fontsize=12, ha='right') + + plt.title('Route of Visited Places') + plt.xlabel('X Coordinate') + plt.ylabel('Y Coordinate') + plt.grid(True) + plt.savefig('route.png') + +plot_route(visited_places) \ No newline at end of file diff --git a/frontend/lib/modules/current_trip_loading_indicator.dart b/frontend/lib/modules/current_trip_loading_indicator.dart index 1f81d60..96648a3 100644 --- a/frontend/lib/modules/current_trip_loading_indicator.dart +++ b/frontend/lib/modules/current_trip_loading_indicator.dart @@ -1,11 +1,20 @@ -import 'dart:ui'; - +import 'package:anyway/constants.dart'; import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:anyway/structs/trip.dart'; import 'package:anyway/pages/current_trip.dart'; + +final List statusTexts = [ + 'Parsing your preferences...', + 'Finding the best places...', + 'Crunching the numbers...', + 'Calculating the best route...', + 'Making sure you have a great time...', +]; + + class CurrentTripLoadingIndicator extends StatefulWidget { final Trip trip; const CurrentTripLoadingIndicator({ @@ -18,14 +27,52 @@ class CurrentTripLoadingIndicator extends StatefulWidget { } -Widget bottomLoadingIndicator = Container( - height: 20.0, // Increase the height to take up more vertical space +class _CurrentTripLoadingIndicatorState extends State { + @override + Widget build(BuildContext context) => Stack( + fit: StackFit.expand, + children: [ + // In the very center of the panel, show the greeter which tells the user that the trip is being generated + Center(child: loadingText(widget.trip)), + // As a gimmick, and a way to show that the app is still working, show a few loading dots + Align( + alignment: Alignment.bottomCenter, + child: statusText(), + ) + ], + ); +} + +// automatically cycle through the greeter texts +class statusText extends StatefulWidget { + const statusText({Key? key}) : super(key: key); + + @override + _statusTextState createState() => _statusTextState(); +} + +class _statusTextState extends State { + int statusIndex = 0; + + @override + void initState() { + super.initState(); + Future.delayed(Duration(seconds: 5), () { + setState(() { + statusIndex = (statusIndex + 1) % statusTexts.length; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AutoSizeText( + statusTexts[statusIndex], + style: Theme.of(context).textTheme.labelSmall, + ); + } +} - child: ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), // Apply blur effect - child: Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(),) - ), -); Widget loadingText(Trip trip) => FutureBuilder( @@ -34,43 +81,82 @@ Widget loadingText(Trip trip) => FutureBuilder( Widget greeter; if (snapshot.hasData) { - greeter = AutoSizeText( - maxLines: 1, - 'Generating your trip to ${snapshot.data}...', + greeter = AnimatedGradientText( + text: 'Creating your trip to ${snapshot.data}...', style: greeterStyle, ); } else if (snapshot.hasError) { // the exact error is shown in the central part of the trip overview. No need to show it here - greeter = AutoSizeText( - maxLines: 1, - 'Error while loading trip.', + greeter = AnimatedGradientText( + text: 'Error while loading trip.', style: greeterStyle, - ); + ); } else { - greeter = AutoSizeText( - maxLines: 1, - 'Generating your trip...', + greeter = AnimatedGradientText( + text: 'Creating your trip...', style: greeterStyle, - ); + ); } return greeter; } ); +class AnimatedGradientText extends StatefulWidget { + final String text; + final TextStyle style; + const AnimatedGradientText({ + Key? key, + required this.text, + required this.style, + }) : super(key: key); - -class _CurrentTripLoadingIndicatorState extends State { @override - Widget build(BuildContext context) => Stack( - fit: StackFit.expand, - children: [ - Center(child: loadingText(widget.trip)), - Align( - alignment: Alignment.bottomCenter, - child: bottomLoadingIndicator, - ) - ], - ); - + _AnimatedGradientTextState createState() => _AnimatedGradientTextState(); } + +class _AnimatedGradientTextState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + colors: [GRADIENT_START, GRADIENT_END, GRADIENT_START], + stops: [ + _controller.value - 1.0, + _controller.value, + _controller.value + 1.0, + ], + tileMode: TileMode.mirror, + ).createShader(bounds); + }, + child: Text( + widget.text, + style: widget.style, + ), + ); + }, + ); + } +} + diff --git a/frontend/lib/pages/current_trip.dart b/frontend/lib/pages/current_trip.dart index 7f59c3d..6f45e7f 100644 --- a/frontend/lib/pages/current_trip.dart +++ b/frontend/lib/pages/current_trip.dart @@ -11,7 +11,7 @@ final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 20 TextStyle greeterStyle = TextStyle( foreground: Paint()..shader = textGradient, fontWeight: FontWeight.bold, - fontSize: 26 + fontSize: 25 ); diff --git a/frontend/lib/structs/landmark.dart b/frontend/lib/structs/landmark.dart index de7d893..50a71c5 100644 --- a/frontend/lib/structs/landmark.dart +++ b/frontend/lib/structs/landmark.dart @@ -125,7 +125,7 @@ class LandmarkType { LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) { switch (name) { case 'sightseeing': - icon = const Icon(Icons.church); + icon = const Icon(Icons.castle); break; case 'nature': icon = const Icon(Icons.eco); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 770f12c..bb2aad4 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" crypto: dependency: transitive description: @@ -412,18 +412,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -708,7 +708,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" sliding_up_panel: dependency: "direct main" description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -777,10 +777,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" synchronized: dependency: transitive description: @@ -801,10 +801,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" typed_data: dependency: transitive description: @@ -921,10 +921,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" web: dependency: transitive description: diff --git a/testing_image_query.py b/testing_image_query.py deleted file mode 100644 index 4c62c4d..0000000 --- a/testing_image_query.py +++ /dev/null @@ -1,50 +0,0 @@ -import httpx -import json - -base_url = "https://en.wikipedia.org/w/api.php" - -def best_page_match(title) -> int: - params = { - "action": "query", - "format": "json", - "list": "prefixsearch", - "pssearch": title, - } - response = httpx.get(base_url, params=params) - data = response.json() - data = data.get("query", {}).get("prefixsearch", []) - titles_and_ids = {d["title"]: d["pageid"] for d in data} - - for t in titles_and_ids: - if title.lower() == t.lower(): - print("Matched") - return titles_and_ids[t] - -def get_image_url(page_id) -> str: - # https://en.wikipedia.org/w/api.php?action=query&titles=K%C3%B6lner%20Dom&prop=imageinfo&iiprop=url&format=json - params = { - "action": "query", - "format": "json", - "prop": "pageimages", - "pageids": page_id, - "pithumbsize": 500, - } - response = httpx.get(base_url, params=params) - data = response.json() - data = data.get("query", {}).get("pages", {}) - data = data.get(str(page_id), {}) - return data.get("thumbnail", {}).get("source") - -def get_image_url_from_title(title) -> str: - page_id = best_page_match(title) - if page_id is None: - return None - return get_image_url(page_id) - - -print(get_image_url_from_title("kölner dom")) -print(get_image_url_from_title("grossmünster")) -print(get_image_url_from_title("eiffel tower")) -print(get_image_url_from_title("taj mahal")) -print(get_image_url_from_title("big ben")) - From f6e396e54beeb9efb33ea8c53f31e8cb7336dd2d Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Wed, 5 Feb 2025 13:53:10 +0100 Subject: [PATCH 8/8] undo add test.py --- backend/test.py | 111 ------------------------------------------------ 1 file changed, 111 deletions(-) delete mode 100644 backend/test.py diff --git a/backend/test.py b/backend/test.py deleted file mode 100644 index 672b166..0000000 --- a/backend/test.py +++ /dev/null @@ -1,111 +0,0 @@ -import numpy as np - -def euclidean_distance(p1, p2): - print(p1, p2) - return np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) - - -def maximize_score(places, max_distance, fixed_entry, top_k=3): - """ - Maximizes the total score of visited places while staying below the maximum distance. - - Parameters: - places (list of tuples): Each tuple contains (score, (x, y), location). - max_distance (float): The maximum distance that can be traveled. - fixed_entry (tuple): The place that needs to be visited independently of its score. - top_k (int): Number of top candidates to consider in each iteration. - - Returns: - list of tuples: The visited places. - float: The total score of the visited places. - """ - # Initialize total distance and score - total_distance = 0 - total_score = 0 - visited_places = [] - - # Add the fixed entry to the visited list - score, (x, y), _ = fixed_entry - visited_places.append(fixed_entry) - total_score += score - - # Remove the fixed entry from the list of places - remaining_places = [place for place in places if place != fixed_entry] - - # Sort remaining places by score-to-distance ratio - - remaining_places.sort(key=lambda p: p[0] / euclidean_distance((x, y), (p[1][0], p[1][1])), reverse=True) - - # Add places to the visited list if they don't exceed the maximum distance - current_location = (x, y) - while remaining_places and total_distance < max_distance: - # Consider top_k candidates - candidates = remaining_places[:top_k] - best_candidate = None - best_score_increase = -np.inf - - for candidate in candidates: - score, (cx, cy), location = candidate - distance = euclidean_distance(current_location, (cx, cy)) - if total_distance + distance <= max_distance: - score_increase = score / distance - if score_increase > best_score_increase: - best_score_increase = score_increase - best_candidate = candidate - - if best_candidate: - visited_places.append(best_candidate) - total_distance += euclidean_distance(current_location, best_candidate[1]) - total_score += best_candidate[0] - current_location = best_candidate[1] - remaining_places.remove(best_candidate) - else: - break - - return visited_places, total_score - -# Example usage -places = [ - (10, (0, 0), 'A'), - (8, (4, 2), 'B'), - (15, (6, 4), 'C'), - (7, (5, 6), 'D'), - (12, (1, 8), 'E'), - (14, (34, 10), 'F'), - (15, (65, 12), 'G'), - (12, (3, 14), 'H'), - (12, (15, 1), 'I'), - (7, (17, 4), 'J'), - (12, (3, 3), 'K'), - (4, (21, 22), 'L'), - (12, (23, 24), 'M'), - (4, (25, 26), 'N'), - (2, (27, 28), 'O'), -] -fixed_entry = (10, (0, 0), 'A') -max_distance = 50 - -visited_places, total_score = maximize_score(places, max_distance, fixed_entry) -print("Visited Places:", visited_places) -print("Total Score:", total_score) - -import matplotlib.pyplot as plt - -# Plot the route -def plot_route(visited_places): - x_coords = [place[1][0] for place in visited_places] - y_coords = [place[1][1] for place in visited_places] - labels = [place[2] for place in visited_places] - - plt.figure(figsize=(10, 6)) - plt.plot(x_coords, y_coords, marker='o', linestyle='-', color='b') - for i, label in enumerate(labels): - plt.text(x_coords[i], y_coords[i], label, fontsize=12, ha='right') - - plt.title('Route of Visited Places') - plt.xlabel('X Coordinate') - plt.ylabel('Y Coordinate') - plt.grid(True) - plt.savefig('route.png') - -plot_route(visited_places) \ No newline at end of file