Frontend UX improvements #37

Merged
remoll merged 8 commits from feature/frontend/image-loading into main 2025-02-05 12:55:26 +00:00
12 changed files with 245 additions and 82 deletions
Showing only changes of commit d58ef2562d - Show all commits

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
@ -15,17 +17,21 @@ class CurrentTripLoadingIndicator extends StatefulWidget {
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState(); State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
} }
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override Widget bottomLoadingIndicator = Container(
Widget build(BuildContext context) => Center( height: 20.0, // Increase the height to take up more vertical space
child: FutureBuilder(
future: widget.trip.cityName, 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) { builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
Widget greeter; Widget greeter;
Widget loadingIndicator = const Padding(
padding: EdgeInsets.only(top: 10),
child: CircularProgressIndicator()
);
if (snapshot.hasData) { if (snapshot.hasData) {
greeter = AutoSizeText( greeter = AutoSizeText(
@ -47,14 +53,24 @@ class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicato
style: greeterStyle, style: greeterStyle,
); );
} }
return Column( return greeter;
mainAxisAlignment: MainAxisAlignment.center, }
);
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [ children: [
greeter, Center(child: loadingText(widget.trip)),
loadingIndicator, Align(
alignment: Alignment.bottomCenter,
child: bottomLoadingIndicator,
)
], ],
); );
}
)
);
} }

View File

@ -36,7 +36,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
child: SizedBox( child: SizedBox(
// reuse the exact same height as the panel has when collapsed // reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is 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) child: CurrentTripErrorMessage(trip: widget.trip)
), ),
); );
@ -46,19 +46,20 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
child: SizedBox( child: SizedBox(
// reuse the exact same height as the panel has when collapsed // reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is 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), child: CurrentTripLoadingIndicator(trip: widget.trip),
), ),
); );
} else { } else {
return ListView( return ListView(
controller: widget.controller, controller: widget.controller,
padding: const EdgeInsets.only(bottom: 30), padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30),
children: [ children: [
SizedBox( SizedBox(
// reuse the exact same height as the panel has when collapsed // reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is 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), child: CurrentTripGreeter(trip: widget.trip),
), ),

View File

@ -38,8 +38,6 @@ class _LandmarkCardState extends State<LandmarkCard> {
imageUrl: widget.landmark.imageURL ?? '', imageUrl: widget.landmark.imageURL ?? '',
placeholder: (context, url) => Center(child: CircularProgressIndicator()), placeholder: (context, url) => Center(child: CircularProgressIndicator()),
errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined), 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, fit: BoxFit.cover,
), ),
), ),
@ -103,15 +101,15 @@ class _LandmarkCardState extends State<LandmarkCard> {
icon: Icon(Icons.link), icon: Icon(Icons.link),
label: Text('Website'), label: Text('Website'),
), ),
if (widget.landmark.wikipediaURL != null) // if (widget.landmark.wikipediaURL != null)
TextButton.icon( // TextButton.icon(
onPressed: () async { // onPressed: () async {
// open a browser with the wikipedia link // // open a browser with the wikipedia link
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); // await launchUrl(Uri.parse(widget.landmark.wikipediaURL!));
}, // },
icon: Icon(Icons.book), // icon: Icon(Icons.book),
label: Text('Wikipedia'), // label: Text('Wikipedia'),
), // ),
], ],
), ),
), ),

View File

@ -9,6 +9,15 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.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 { class NewTripLocationSearch extends StatefulWidget {
Future<SharedPreferences> prefs = SharedPreferences.getInstance(); Future<SharedPreferences> prefs = SharedPreferences.getInstance();
Trip trip; Trip trip;
@ -27,26 +36,35 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
setTripLocation (String query) async { setTripLocation (String query) async {
List<Location> locations = []; List<Location> locations = [];
Location startLocation;
log('Searching for: $query'); log('Searching for: $query');
if (GeocodingPlatform.instance != null) {
try{ locations.addAll(await locationFromAddress(query));
locations = await locationFromAddress(query);
} catch (e) {
log('No results found for: $query : $e');
} }
if (locations.isNotEmpty) { if (locations.isNotEmpty) {
Location location = locations.first; 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.landmarks.clear();
widget.trip.addLandmark( widget.trip.addLandmark(
Landmark( Landmark(
uuid: 'pending', uuid: 'pending',
name: query, name: query,
location: [location.latitude, location.longitude], location: [startLocation.latitude, startLocation.longitude],
type: typeStart type: typeStart
) )
); );
}
} }
late Widget locationSearchBar = SearchBar( late Widget locationSearchBar = SearchBar(

View File

@ -26,7 +26,7 @@ class _NewTripMapState extends State<NewTripMap> {
target: LatLng(48.8566, 2.3522), target: LatLng(48.8566, 2.3522),
zoom: 11.0, zoom: 11.0,
); );
late GoogleMapController _mapController; GoogleMapController? _mapController;
final Set<Marker> _markers = <Marker>{}; final Set<Marker> _markers = <Marker>{};
_onLongPress(LatLng location) { _onLongPress(LatLng location) {
@ -56,11 +56,15 @@ class _NewTripMapState extends State<NewTripMap> {
), ),
) )
); );
_mapController.moveCamera( // check if the controller is ready
if (_mapController != null) {
_mapController!.animateCamera(
CameraUpdate.newLatLng( CameraUpdate.newLatLng(
LatLng(landmark.location[0], landmark.location[1]) LatLng(landmark.location[0], landmark.location[1])
) )
); );
}
setState(() {}); setState(() {});
} }
} }

View File

@ -41,7 +41,7 @@ class _TripPageState extends State<TripPage> {
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT, maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
// padding in this context is annoying: it offsets the notion of vertical alignment. // 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 // 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 // Panel snapping should not be disabled because it significantly improves the user experience
// panelSnapping: false // panelSnapping: false
borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)), borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)),

View File

@ -24,8 +24,7 @@ final class Landmark extends LinkedListEntry<Landmark>{
// description to be shown in the overview // description to be shown in the overview
final String? nameEN; final String? nameEN;
final String? websiteURL; final String? websiteURL;
final String? wikipediaURL; String? imageURL; // not final because it can be patched
final String? imageURL;
final String? description; final String? description;
final Duration? duration; final Duration? duration;
final bool? visited; final bool? visited;
@ -44,7 +43,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
this.nameEN, this.nameEN,
this.websiteURL, this.websiteURL,
this.wikipediaURL,
this.imageURL, this.imageURL,
this.description, this.description,
this.duration, this.duration,
@ -70,7 +68,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
final isSecondary = json['is_secondary'] as bool?; final isSecondary = json['is_secondary'] as bool?;
final nameEN = json['name_en'] as String?; final nameEN = json['name_en'] as String?;
final websiteURL = json['website_url'] as String?; final websiteURL = json['website_url'] as String?;
final wikipediaURL = json['wikipedia_url'] as String?;
final imageURL = json['image_url'] as String?; final imageURL = json['image_url'] as String?;
final description = json['description'] as String?; final description = json['description'] as String?;
var duration = Duration(minutes: json['duration'] ?? 0) as Duration?; var duration = Duration(minutes: json['duration'] ?? 0) as Duration?;
@ -85,7 +82,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
isSecondary: isSecondary, isSecondary: isSecondary,
nameEN: nameEN, nameEN: nameEN,
websiteURL: websiteURL, websiteURL: websiteURL,
wikipediaURL: wikipediaURL,
imageURL: imageURL, imageURL: imageURL,
description: description, description: description,
duration: duration, duration: duration,
@ -112,7 +108,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
'is_secondary': isSecondary, 'is_secondary': isSecondary,
'name_en': nameEN, 'name_en': nameEN,
'website_url': websiteURL, 'website_url': websiteURL,
'wikipedia_url': wikipediaURL,
'image_url': imageURL, 'image_url': imageURL,
'description': description, 'description': description,
'duration': duration?.inMinutes, 'duration': duration?.inMinutes,

View File

@ -1,5 +1,6 @@
import "dart:convert"; import "dart:convert";
import "dart:developer"; import "dart:developer";
import "package:anyway/utils/load_landmark_image.dart";
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:anyway/constants.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 { Future<(Landmark, String?)> fetchLandmark(String uuid) async {
final response = await dio.get( final response = await dio.get(
@ -101,5 +111,7 @@ Future<(Landmark, String?)> fetchLandmark(String uuid) async {
log(response.data.toString()); log(response.data.toString());
Map<String, dynamic> json = response.data; Map<String, dynamic> json = response.data;
String? nextUUID = json["next_uuid"]; String? nextUUID = json["next_uuid"];
return (Landmark.fromJson(json), nextUUID); Landmark landmark = Landmark.fromJson(json);
patchLandmarkImage(landmark);
return (landmark, nextUUID);
} }

View 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);
}

View File

@ -232,6 +232,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuzzywuzzy:
dependency: "direct main"
description:
name: fuzzywuzzy
sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
geocoding: geocoding:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -51,6 +51,7 @@ dependencies:
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
permission_handler: ^11.3.1 permission_handler: ^11.3.1
geolocator: ^13.0.1 geolocator: ^13.0.1
fuzzywuzzy: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

50
testing_image_query.py Normal file
View 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"))