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")) +