Frontend UX improvements #37
| @@ -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<CurrentTripLoadingIndicator> 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<String> 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<CurrentTripLoadingIndicator> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Center( | ||||
|     child: FutureBuilder( | ||||
|       future: widget.trip.cityName, | ||||
|       builder: (BuildContext context, AsyncSnapshot<String> 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, | ||||
|       ) | ||||
|     ], | ||||
|   ); | ||||
|  | ||||
| } | ||||
| @@ -36,7 +36,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> { | ||||
|               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<CurrentTripPanel> { | ||||
|               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), | ||||
|               ), | ||||
|  | ||||
|   | ||||
| @@ -38,8 +38,6 @@ class _LandmarkCardState extends State<LandmarkCard> { | ||||
|                 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<LandmarkCard> { | ||||
|                               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'), | ||||
|                           // ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|   | ||||
| @@ -9,6 +9,15 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:geolocator/geolocator.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
|  | ||||
| const Map<String, List> 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<SharedPreferences> prefs = SharedPreferences.getInstance(); | ||||
|   Trip trip; | ||||
| @@ -27,26 +36,35 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> { | ||||
|  | ||||
|   setTripLocation (String query) async { | ||||
|     List<Location> 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( | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class _NewTripMapState extends State<NewTripMap> { | ||||
|     target: LatLng(48.8566, 2.3522), | ||||
|     zoom: 11.0, | ||||
|   ); | ||||
|   late GoogleMapController _mapController; | ||||
|   GoogleMapController? _mapController; | ||||
|   final Set<Marker> _markers = <Marker>{}; | ||||
|  | ||||
|   _onLongPress(LatLng location) { | ||||
| @@ -56,11 +56,15 @@ class _NewTripMapState extends State<NewTripMap> { | ||||
|           ), | ||||
|         ) | ||||
|       ); | ||||
|       _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(() {}); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class _TripPageState extends State<TripPage> { | ||||
|         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)), | ||||
|   | ||||
| @@ -24,8 +24,7 @@ final class Landmark extends LinkedListEntry<Landmark>{ | ||||
|   // 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<Landmark>{ | ||||
|  | ||||
|     this.nameEN, | ||||
|     this.websiteURL, | ||||
|     this.wikipediaURL, | ||||
|     this.imageURL, | ||||
|     this.description, | ||||
|     this.duration, | ||||
| @@ -70,7 +68,6 @@ final class Landmark extends LinkedListEntry<Landmark>{ | ||||
|       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<Landmark>{ | ||||
|         isSecondary: isSecondary, | ||||
|         nameEN: nameEN, | ||||
|         websiteURL: websiteURL, | ||||
|         wikipediaURL: wikipediaURL, | ||||
|         imageURL: imageURL, | ||||
|         description: description, | ||||
|         duration: duration, | ||||
| @@ -112,7 +108,6 @@ final class Landmark extends LinkedListEntry<Landmark>{ | ||||
|     'is_secondary': isSecondary, | ||||
|     'name_en': nameEN, | ||||
|     'website_url': websiteURL, | ||||
|     'wikipedia_url': wikipediaURL, | ||||
|     'image_url': imageURL, | ||||
|     'description': description, | ||||
|     'duration': duration?.inMinutes, | ||||
|   | ||||
| @@ -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<String, dynamic> json = response.data; | ||||
|   String? nextUUID = json["next_uuid"]; | ||||
|   return (Landmark.fromJson(json), nextUUID); | ||||
|   Landmark landmark = Landmark.fromJson(json); | ||||
|   patchLandmarkImage(landmark); | ||||
|   return (landmark, nextUUID); | ||||
| } | ||||
|   | ||||
							
								
								
									
										60
									
								
								frontend/lib/utils/load_landmark_image.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/lib/utils/load_landmark_image.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<int?> 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<dynamic> results = data["query"]["prefixsearch"] ?? {}; | ||||
|     final Map<String, int> 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<String> bestMatch = extractOne( | ||||
|       query: title, | ||||
|       choices: titlesAndIds.keys.toList(), | ||||
|       cutoff: 70, | ||||
|     ); | ||||
|     return titlesAndIds[bestMatch.choice]; | ||||
| } | ||||
|  | ||||
| Future<String?> 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<String?> getImageUrlFromName(String title) async { | ||||
|     int? pageId = await bestPageMatch(title); | ||||
|     if (pageId == null) { | ||||
|         return null; | ||||
|     } | ||||
|     return await getImageUrl(pageId); | ||||
| } | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										50
									
								
								testing_image_query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								testing_image_query.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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")) | ||||
|  | ||||
		Reference in New Issue
	
	Block a user