34 Commits

Author SHA1 Message Date
f6d0cd5360 Merge pull request 'backend/feature/add-description' (#63) from backend/feature/add-description into main
Some checks failed
Build and deploy the backend to production / Build and push image (push) Successful in 1m36s
/ push-to-remote (push) Failing after 33s
Build and deploy the backend to production / Deploy to production (push) Successful in 25s
Reviewed-on: #63
2025-02-21 07:38:15 +00:00
7a18830e99 removed debug from prod
Some checks failed
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m51s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 25s
2025-02-20 20:07:20 +01:00
ba14a0279e better logs again
Some checks failed
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m41s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 25s
2025-02-20 19:49:18 +01:00
5a2c61d343 better logs
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m40s
Run linting on the backend code / Build (pull_request) Successful in 55s
Run testing on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 25s
2025-02-20 19:11:23 +01:00
5e27dd9d79 corrected import
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m11s
Run linting on the backend code / Build (pull_request) Has been cancelled
Run testing on the backend code / Build (pull_request) Failing after 35m5s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 26s
2025-02-19 16:09:52 +01:00
d92001faaf forgot to add main
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m39s
Run linting on the backend code / Build (pull_request) Successful in 29s
Run testing on the backend code / Build (pull_request) Failing after 47s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 26s
2025-02-19 16:04:31 +01:00
73f0dc8361 linting
Some checks failed
Run linting on the backend code / Build (pull_request) Has been cancelled
Run testing on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
2025-02-19 16:04:18 +01:00
05092e55f1 better structure
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m45s
Run linting on the backend code / Build (pull_request) Successful in 27s
Run testing on the backend code / Build (pull_request) Failing after 45s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 25s
2025-02-19 15:53:41 +01:00
83be4b7616 linting
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Successful in 28s
2025-02-19 14:51:38 +01:00
8a9ec6b4d8 fixed double description
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m39s
Run linting on the backend code / Build (pull_request) Successful in 27s
Run testing on the backend code / Build (pull_request) Failing after 26m40s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 26s
2025-02-19 11:16:01 +01:00
8c3145dfc9 increased max_iter and park support 2025-02-19 11:11:23 +01:00
2bf38119d6 added descriptions 2025-02-19 11:04:18 +01:00
ca711c614f test 2025-02-18 18:50:09 +01:00
357edf3000 added branch 2025-02-18 18:24:04 +01:00
444c47e3a4 Merge pull request 'backend/feature/recompute-trip-time' (#62) from backend/feature/recompute-trip-time into main
Some checks failed
/ push-to-remote (push) Has been cancelled
Build and deploy the backend to production / Build and push image (push) Successful in 1m40s
Build and deploy the backend to production / Deploy to production (push) Successful in 25s
Reviewed-on: #62
2025-02-17 05:40:22 +00:00
da6ab207d9 Update backend/src/logging_config.py
Some checks failed
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
2025-02-17 05:39:20 +00:00
c15e257dea add trip time update
Some checks failed
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
Run testing on the backend code / Build (pull_request) Has been cancelled
Run linting on the backend code / Build (pull_request) Successful in 27s
2025-02-11 15:42:14 +01:00
5a698dd02c Merge pull request 'Adding licenses' (#58) from licenses into main
Reviewed-on: #58
2025-02-11 08:36:16 +00:00
7e4a4b3dc7 added general license 2025-02-11 08:25:02 +00:00
84e5902436 Merge pull request 'fixed cluster names' (#57) from backend/fix/missing-cluster-names into main
Some checks failed
Build and deploy the backend to production / Build and push image (push) Successful in 2m9s
/ push-to-remote (push) Failing after 41s
Build and deploy the backend to production / Deploy to production (push) Successful in 23s
Reviewed-on: #57
2025-02-11 06:50:11 +00:00
81330e5eb3 fixed cluster names
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m54s
Run linting on the backend code / Build (pull_request) Successful in 27s
Run testing on the backend code / Build (pull_request) Failing after 4m19s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 24s
2025-02-11 07:34:50 +01:00
9002483036 Merge pull request 'Removes the rounding from the trip details' (#56) from frontend/no-more-rounding into main
Reviewed-on: #56
2025-02-07 18:09:31 +00:00
0271c3d7a7 removed rounding but app won't compile
Some checks failed
Build and release debug APK / Build APK (pull_request) Failing after 3m37s
2025-02-07 15:14:21 +01:00
4fd1272ea4 test
Some checks failed
Build and release debug APK / Build APK (pull_request) Failing after 3m7s
2025-02-07 15:08:00 +01:00
6bedd04a57 removed rounding
Some checks failed
Build and release debug APK / Build APK (pull_request) Failing after 4m10s
2025-02-07 14:44:18 +01:00
d31ca9f81f Merge pull request 'Frontend UX improvements' (#37) from feature/frontend/image-loading into main
Reviewed-on: #37
2025-02-05 12:55:24 +00:00
f6e396e54b undo add test.py
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Has been cancelled
Build and deploy the backend to staging / Deploy to staging (pull_request) Has been cancelled
Build and release debug APK / Build APK (pull_request) Has been cancelled
2025-02-05 13:53:10 +01:00
d4de945df8 cleaner trip loading indicator
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m57s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 26s
Build and release debug APK / Build APK (pull_request) Has been cancelled
2025-02-05 13:50:38 +01:00
d992b62533 tentatively shrink trip overview, nicer onboarding 2024-12-17 11:17:59 +01:00
e78bee4597 some more images 2024-12-17 10:28:33 +01:00
d186a51a87 WIP: ladnmark card adjustments 2024-12-15 16:30:17 +01:00
4baf045c8c better onboarding
All checks were successful
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m1s
Build and release debug APK / Build APK (pull_request) Successful in 10m54s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 14s
2024-12-02 10:43:42 +01:00
3f1fe463bf better help and onboarding
All checks were successful
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m40s
Build and release debug APK / Build APK (pull_request) Successful in 7m23s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 14s
2024-11-18 17:42:52 +01:00
d58ef2562d image querying from within the frontend
All checks were successful
Build and release debug APK / Build APK (pull_request) Successful in 7m40s
2024-11-06 14:45:43 +01:00
54 changed files with 2110 additions and 672 deletions

30
LICENSE.md Normal file
View File

@@ -0,0 +1,30 @@
# License
## Proprietary License
All code and resources in this repository are the property of AnyDev. The software and related documentation are provided solely for use with services provided by AnyDev. Redistribution, modification, or use of this software outside of its intended service is strictly prohibited without explicit permission.
### Copyright © 2024 AnyDev
All rights reserved.
### Restrictions
- You may not modify, distribute, copy, or reverse engineer any part of this codebase.
- This software is licensed for use solely in conjunction with services provided by AnyDev.
- Any commercial use of this software is strictly prohibited without explicit written consent from AnyDev.
## Third-Party Dependencies
This project uses third-party dependencies, which are subject to their respective licenses.
- Python backend dependencies: fastapi, pydantic, numpy, shapely, etc. Licensed under their respective licenses.
- Flutter frontend dependencies: Cupertino Icons, sliding_up_panel, http, etc. Licensed under their respective licenses.
Please refer to each project's documentation for the specific terms and conditions.
## OpenStreetMap Data Usage
This project uses data derived from **OpenStreetMap**. OpenStreetMap data is available under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). We comply with the ODbL license, and some of the data displayed in the service may be derived from OpenStreetMap sources. We do not redistribute raw OpenStreetMap data; instead, it is processed and transformed before being used in our services.
More information about OpenStreetMap data usage can be found [here](https://www.openstreetmap.org/copyright).

3
backend/.gitignore vendored
View File

@@ -1,6 +1,9 @@
# osm-cache # osm-cache
cache_XML/ cache_XML/
# secrets
*secrets.yaml
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

363
backend/landmarks.json Normal file
View File

@@ -0,0 +1,363 @@
[
{
"name": "Chinatown",
"type": "shopping",
"location": [
45.7554934,
4.8444852
],
"osm_type": "way",
"osm_id": 996515596,
"attractiveness": 129,
"n_tags": 0,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": {},
"description": null,
"duration": 30,
"name_en": null,
"uuid": "285d159c-68ee-4b37-8d71-f27ee3d38b02",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Galeries Lafayette",
"type": "shopping",
"location": [
45.7627107,
4.8556833
],
"osm_type": "way",
"osm_id": 1069872743,
"attractiveness": 197,
"n_tags": 11,
"image_url": null,
"website_url": "http://www.galerieslafayette.com/",
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "28f1bc30-10d3-4944-8861-0ed9abca012d",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Muji",
"type": "shopping",
"location": [
45.7615971,
4.8543781
],
"osm_type": "way",
"osm_id": 1044165817,
"attractiveness": 259,
"n_tags": 14,
"image_url": null,
"website_url": "https://www.muji.com/fr/",
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": "Muji",
"uuid": "957f86a5-6c00-41a2-815d-d6f739052be4",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "HEMA",
"type": "shopping",
"location": [
45.7619133,
4.8565239
],
"osm_type": "way",
"osm_id": 1069872750,
"attractiveness": 156,
"n_tags": 9,
"image_url": null,
"website_url": "https://fr.westfield.com/lapartdieu/store/HEMA/www.hema.fr",
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "8dae9d3e-e4c4-4e80-941d-0b106e22c85b",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Cordeliers",
"type": "shopping",
"location": [
45.7622752,
4.8337998
],
"osm_type": "node",
"osm_id": 5545183519,
"attractiveness": 813,
"n_tags": 0,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": {},
"description": null,
"duration": 30,
"name_en": null,
"uuid": "ba02adb5-e28f-4645-8c2d-25ead6232379",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Halles de Lyon Paul Bocuse",
"type": "shopping",
"location": [
45.7628282,
4.8505601
],
"osm_type": "relation",
"osm_id": 971529,
"attractiveness": 272,
"n_tags": 12,
"image_url": null,
"website_url": "https://www.halles-de-lyon-paulbocuse.com/",
"wiki_url": "fr:Halles de Lyon-Paul Bocuse",
"keywords": {
"importance": "national",
"height": null,
"place_type": "marketplace",
"date": null
},
"description": "Halles de Lyon Paul Bocuse is a marketplace of national importance.",
"duration": 30,
"name_en": null,
"uuid": "bbd50de3-aa91-425d-90c2-d4abfd1b4abe",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Grand Bazar",
"type": "shopping",
"location": [
45.7632141,
4.8361975
],
"osm_type": "way",
"osm_id": 82399951,
"attractiveness": 93,
"n_tags": 7,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "3de9131c-87c5-4efb-9fa8-064896fb8b29",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Shopping Area",
"type": "shopping",
"location": [
45.7673452,
4.8438683
],
"osm_type": "node",
"osm_id": 0,
"attractiveness": 156,
"n_tags": 0,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": {},
"description": null,
"duration": 30,
"name_en": null,
"uuid": "df2482a8-7e2e-4536-aad3-564899b2fa65",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Cour Oxyg\u00e8ne",
"type": "shopping",
"location": [
45.7620905,
4.8568873
],
"osm_type": "way",
"osm_id": 132673030,
"attractiveness": 63,
"n_tags": 5,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "ed134f76-9a02-4bee-9c10-78454f7bc4ce",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "P\u00f4le de Commerces et de Loisirs Confluence",
"type": "shopping",
"location": [
45.7410414,
4.8171031
],
"osm_type": "way",
"osm_id": 440270633,
"attractiveness": 259,
"n_tags": 14,
"image_url": null,
"website_url": "https://www.confluence.fr/",
"wiki_url": null,
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "dd7e2f5f-0e60-4560-b903-e5ded4b6e36a",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Grand H\u00f4tel-Dieu",
"type": "shopping",
"location": [
45.7586955,
4.8364597
],
"osm_type": "relation",
"osm_id": 300128,
"attractiveness": 546,
"n_tags": 22,
"image_url": null,
"website_url": "https://grand-hotel-dieu.com",
"wiki_url": "fr:H\u00f4tel-Dieu de Lyon",
"keywords": {
"importance": "international",
"height": null,
"place_type": "building",
"date": "C17"
},
"description": "Grand H\u00f4tel-Dieu is an internationally famous building. It was constructed in C17.",
"duration": 30,
"name_en": null,
"uuid": "a91265a8-ffbd-44f7-a7ab-3ff75f08fbab",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Westfield La Part-Dieu",
"type": "shopping",
"location": [
45.761331,
4.855676
],
"osm_type": "way",
"osm_id": 62338376,
"attractiveness": 546,
"n_tags": 22,
"image_url": null,
"website_url": "https://fr.westfield.com/lapartdieu",
"wiki_url": "fr:La Part-Dieu (centre commercial)",
"keywords": null,
"description": null,
"duration": 30,
"name_en": null,
"uuid": "7d60316f-d689-4fcf-be68-ffc09353b826",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
},
{
"name": "Ainay",
"type": "shopping",
"location": [
45.7553105,
4.8312084
],
"osm_type": "node",
"osm_id": 5545126047,
"attractiveness": 132,
"n_tags": 0,
"image_url": null,
"website_url": null,
"wiki_url": null,
"keywords": {},
"description": null,
"duration": 30,
"name_en": null,
"uuid": "ad214f3d-a4b9-4078-876a-446caa7ab01c",
"must_do": false,
"must_avoid": false,
"is_secondary": false,
"time_to_reach_next": 0,
"next_uuid": null,
"is_viewpoint": false,
"is_place_of_worship": false
}
]

File diff suppressed because one or more lines are too long

View File

View File

@@ -1,6 +1,6 @@
"""Find clusters of interest to add more general areas of visit to the tour.""" """Find clusters of interest to add more general areas of visit to the tour."""
import logging import logging
from typing import Literal from typing import Literal, Tuple
import numpy as np import numpy as np
from sklearn.cluster import DBSCAN from sklearn.cluster import DBSCAN
@@ -8,8 +8,8 @@ from pydantic import BaseModel
from ..overpass.overpass import Overpass, get_base_info from ..overpass.overpass import Overpass, get_base_info
from ..structs.landmark import Landmark from ..structs.landmark import Landmark
from .get_time_distance import get_distance from ..utils.get_time_distance import get_distance
from .utils import create_bbox from ..utils.bbox import create_bbox
@@ -33,7 +33,7 @@ class Cluster(BaseModel):
""" """
type: Literal['street', 'area'] type: Literal['street', 'area']
importance: int importance: int
centroid: tuple centroid: Tuple[float, float]
# start: Optional[list] = None # for later use if we want to have streets as well # start: Optional[list] = None # for later use if we want to have streets as well
# end: Optional[list] = None # end: Optional[list] = None
@@ -103,7 +103,7 @@ class ClusterManager:
out = out out = out
) )
except Exception as e: except Exception as e:
self.logger.error(f"Error fetching clusters: {e}") self.logger.warning(f"Error fetching clusters: {e}")
if result is None : if result is None :
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.") self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
@@ -178,11 +178,12 @@ class ClusterManager:
# Calculate the centroid as the mean of the points # Calculate the centroid as the mean of the points
centroid = np.mean(current_cluster, axis=0) centroid = np.mean(current_cluster, axis=0)
centroid = tuple((round(centroid[0], 7), round(centroid[1], 7)))
if self.cluster_type == 'shopping' : if self.cluster_type == 'shopping' :
score = len(current_cluster)*2 score = len(current_cluster)*3
else : else :
score = len(current_cluster)*8 score = len(current_cluster)*15
locations.append(Cluster( locations.append(Cluster(
type='area', type='area',
centroid=centroid, centroid=centroid,
@@ -215,7 +216,7 @@ class ClusterManager:
""" """
# Define the bounding box for a given radius around the coordinates # Define the bounding box for a given radius around the coordinates
bbox = create_bbox(cluster.centroid, 1000) bbox = create_bbox(cluster.centroid, 300)
# Query neighborhoods and shopping malls # Query neighborhoods and shopping malls
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"'] selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
@@ -223,10 +224,10 @@ class ClusterManager:
if self.cluster_type == 'shopping' : if self.cluster_type == 'shopping' :
selectors.append('"shop"="mall"') selectors.append('"shop"="mall"')
new_name = 'Shopping Area' new_name = 'Shopping Area'
t = 40 t = 30
else : else :
new_name = 'Neighborhood' new_name = 'Neighborhood'
t = 15 t = 20
min_dist = float('inf') min_dist = float('inf')
osm_id = 0 osm_id = 0
@@ -238,30 +239,28 @@ class ClusterManager:
result = self.overpass.send_query(bbox = bbox, result = self.overpass.send_query(bbox = bbox,
osm_types = osm_types, osm_types = osm_types,
selector = sel, selector = sel,
out = 'ids center' out = 'ids center tags'
) )
except Exception as e: except Exception as e:
self.logger.error(f"Error fetching clusters: {e}") self.logger.warning(f"Error fetching clusters: {e}")
continue continue
if result is None : if result is None :
self.logger.error(f"Error fetching clusters: {e}") self.logger.warning(f"Error fetching clusters: query result is None")
continue continue
for elem in result: for elem in result:
osm_type = elem.get('type') # Get basic info
id, coords, name = get_base_info(elem, elem.get('type'), with_name=True)
id, coords, name = get_base_info(elem, osm_type, with_name=True)
if name is None or coords is None : if name is None or coords is None :
continue continue
d = get_distance(cluster.centroid, coords) d = get_distance(cluster.centroid, coords)
if d < min_dist : if d < min_dist :
min_dist = d min_dist = d
new_name = name new_name = name # add name
osm_type = osm_type # Add type: 'way' or 'relation' osm_type = elem.get('type') # add type: 'way' or 'relation'
osm_id = id # Add OSM id osm_id = id # add OSM id
return Landmark( return Landmark(
name=new_name, name=new_name,

View File

@@ -4,10 +4,10 @@ import yaml
from ..structs.preferences import Preferences from ..structs.preferences import Preferences
from ..structs.landmark import Landmark from ..structs.landmark import Landmark
from .take_most_important import take_most_important from ..utils.take_most_important import take_most_important
from .cluster_manager import ClusterManager from .cluster_manager import ClusterManager
from ..overpass.overpass import Overpass, get_base_info from ..overpass.overpass import Overpass, get_base_info
from .utils import create_bbox from ..utils.bbox import create_bbox
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH
@@ -39,7 +39,6 @@ class LandmarkManager:
self.overall_coeff = parameters['overall_coeff'] self.overall_coeff = parameters['overall_coeff']
self.tag_exponent = parameters['tag_exponent'] self.tag_exponent = parameters['tag_exponent']
self.image_bonus = parameters['image_bonus'] self.image_bonus = parameters['image_bonus']
self.name_bonus = parameters['name_bonus']
self.wikipedia_bonus = parameters['wikipedia_bonus'] self.wikipedia_bonus = parameters['wikipedia_bonus']
self.viewpoint_bonus = parameters['viewpoint_bonus'] self.viewpoint_bonus = parameters['viewpoint_bonus']
self.pay_bonus = parameters['pay_bonus'] self.pay_bonus = parameters['pay_bonus']
@@ -147,6 +146,8 @@ class LandmarkManager:
score *= self.wikipedia_bonus score *= self.wikipedia_bonus
if landmark.is_place_of_worship : if landmark.is_place_of_worship :
score *= self.church_coeff score *= self.church_coeff
if landmark.is_viewpoint :
score *= self.viewpoint_bonus
if landmarktype == 'nature' : if landmarktype == 'nature' :
score *= self.nature_coeff score *= self.nature_coeff
@@ -196,12 +197,12 @@ class LandmarkManager:
out = 'ids center tags' out = 'ids center tags'
) )
except Exception as e: except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}") self.logger.debug(f"Failed to fetch landmarks, proceeding without: {str(e)}")
continue continue
return_list += self._to_landmarks(result, landmarktype, preference_level) return_list += self._to_landmarks(result, landmarktype, preference_level)
self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}") # self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
return return_list return return_list
@@ -245,8 +246,6 @@ class LandmarkManager:
attractiveness=0, attractiveness=0,
n_tags=len(tags)) n_tags=len(tags))
# self.logger.debug('added landmark.')
# Browse through tags to add information to landmark. # Browse through tags to add information to landmark.
for key, value in tags.items(): for key, value in tags.items():
@@ -267,7 +266,7 @@ class LandmarkManager:
landmark.image_url = value landmark.image_url = value
if key == 'website' : if key == 'website' :
landmark.website_url = value landmark.website_url = value
if key == 'place_of_worship' : if value == 'place_of_worship' :
landmark.is_place_of_worship = True landmark.is_place_of_worship = True
if key == 'wikipedia' : if key == 'wikipedia' :
landmark.wiki_url = value landmark.wiki_url = value
@@ -276,6 +275,7 @@ class LandmarkManager:
if 'building:' in key or 'pay' in key : if 'building:' in key or 'pay' in key :
landmark.n_tags -= 1 landmark.n_tags -= 1
# Set the duration. # Set the duration.
if value in ['museum', 'aquarium', 'planetarium'] : if value in ['museum', 'aquarium', 'planetarium'] :
landmark.duration = 60 landmark.duration = 60
@@ -286,14 +286,138 @@ class LandmarkManager:
landmark.is_place_of_worship = False landmark.is_place_of_worship = False
landmark.duration = 10 landmark.duration = 10
else: landmark.description, landmark.keywords = self.description_and_keywords(tags)
self.set_landmark_score(landmark, landmarktype, preference_level) self.set_landmark_score(landmark, landmarktype, preference_level)
landmarks.append(landmark) landmarks.append(landmark)
continue continue
return landmarks return landmarks
def description_and_keywords(self, tags: dict):
"""
Generates a description and a set of keywords for a given landmark based on its tags.
Params:
tags (dict): A dictionary containing metadata about the landmark, including its name,
importance, height, date of construction, and visitor information.
Returns:
description (str): A string description of the landmark.
keywords (dict): A dictionary of keywords with fields such as 'importance', 'height',
'place_type', and 'date'.
"""
# Extract relevant fields
name = tags.get('name')
importance = tags.get('importance', None)
n_visitors = tags.get('tourism:visitors', None)
height = tags.get('height')
place_type = self.get_place_type(tags)
date = self.get_date(tags)
if place_type is None :
return None, None
# Start the description.
if importance is None :
if len(tags.keys()) < 5 :
return None, None
if len(tags.keys()) < 10 :
description = f"{name} is a well known {place_type}."
elif len(tags.keys()) < 17 :
importance = 'national'
description = f"{name} is a {place_type} of national importance."
else :
importance = 'international'
description = f"{name} is an internationally famous {place_type}."
else :
description = f"{name} is a {place_type} of {importance} importance."
if height is not None and date is not None :
description += f" This {place_type} was constructed in {date} and is ca. {height} meters high."
elif height is not None :
description += f" This {place_type} stands ca. {height} meters tall."
elif date is not None:
description += f" It was constructed in {date}."
# Format the visitor number
if n_visitors is not None :
n_visitors = int(n_visitors)
if n_visitors < 1000000 :
description += f" It welcomes {int(n_visitors/1000)} thousand visitors every year."
else :
description += f" It welcomes {round(n_visitors/1000000, 1)} million visitors every year."
# Set the keywords.
keywords = {"importance": importance,
"height": height,
"place_type": place_type,
"date": date}
return description, keywords
def get_place_type(self, data):
"""
Determines the type of the place based on available tags such as 'amenity', 'building',
'historic', and 'leisure'. The priority order is: 'historic' > 'building' (if not generic) >
'amenity' > 'leisure'.
Params:
data (dict): A dictionary containing metadata about the place.
Returns:
place_type (str): The determined type of the place, or None if no relevant type is found.
"""
amenity = data.get('amenity', None)
building = data.get('building', None)
historic = data.get('historic', None)
leisure = data.get('leisure')
if historic and historic != "yes":
return historic
if building and building not in ["yes", "civic", "government", "apartments", "residential", "commericial", "industrial", "retail", "religious", "public", "service"]:
return building
if amenity:
return amenity
if leisure:
return leisure
return None
def get_date(self, data):
"""
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
'start_date', 'year_of_construction', and 'opening_date' in that order.
Params:
data (dict): A dictionary containing metadata about the place.
Returns:
date (str): The most relevant date found, or None if no date is available.
"""
construction_date = data.get('construction_date', None)
opening_date = data.get('opening_date', None)
start_date = data.get('start_date', None)
year_of_construction = data.get('year_of_construction', None)
# Prioritize based on availability
if construction_date:
return construction_date
if start_date:
return start_date
if year_of_construction:
return year_of_construction
if opening_date:
return opening_date
return None
def dict_to_selector_list(d: dict) -> list: def dict_to_selector_list(d: dict) -> list:
""" """
Convert a dictionary of key-value pairs to a list of Overpass query strings. Convert a dictionary of key-value pairs to a list of Overpass query strings.

View File

@@ -1,17 +1,16 @@
"""Main app for backend api""" """Main app for backend api"""
import logging import logging
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query from fastapi import FastAPI, HTTPException, BackgroundTasks
from .logging_config import configure_logging from .logging_config import configure_logging
from .structs.landmark import Landmark, Toilets from .structs.landmark import Landmark
from .structs.preferences import Preferences from .structs.preferences import Preferences
from .structs.linked_landmarks import LinkedLandmarks from .structs.linked_landmarks import LinkedLandmarks
from .structs.trip import Trip from .structs.trip import Trip
from .utils.landmarks_manager import LandmarkManager from .landmarks.landmarks_manager import LandmarkManager
from .utils.toilets_manager import ToiletsManager from .toilets.toilet_routes import router as toilets_router
from .optimization.optimizer import Optimizer from .optimization.optimizer import Optimizer
from .optimization.refiner import Refiner from .optimization.refiner import Refiner
from .overpass.overpass import fill_cache from .overpass.overpass import fill_cache
@@ -37,6 +36,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.include_router(toilets_router)
@app.post("/trip/new") @app.post("/trip/new")
def new_trip(preferences: Preferences, def new_trip(preferences: Preferences,
start: tuple[float, float], start: tuple[float, float],
@@ -66,6 +69,8 @@ def new_trip(preferences: Preferences,
end = start end = start
logger.info("No end coordinates provided. Using start=end.") logger.info("No end coordinates provided. Using start=end.")
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
start_landmark = Landmark(name='start', start_landmark = Landmark(name='start',
type='start', type='start',
location=(start[0], start[1]), location=(start[0], start[1]),
@@ -87,6 +92,7 @@ def new_trip(preferences: Preferences,
n_tags=0) n_tags=0)
start_time = time.time() start_time = time.time()
# Generate the landmarks from the start location # Generate the landmarks from the start location
landmarks, landmarks_short = manager.generate_landmarks_list( landmarks, landmarks_short = manager.generate_landmarks_list(
center_coordinates = start, center_coordinates = start,
@@ -108,6 +114,7 @@ def new_trip(preferences: Preferences,
try: try:
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short) base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
except Exception as exc: except Exception as exc:
logger.error(f"Trip generation failed: {str(exc)}")
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
t_first_stage = time.time() - start_time t_first_stage = time.time() - start_time
@@ -119,22 +126,21 @@ def new_trip(preferences: Preferences,
refined_tour = refiner.refine_optimization(landmarks, base_tour, refined_tour = refiner.refine_optimization(landmarks, base_tour,
preferences.max_time_minute, preferences.max_time_minute,
preferences.detour_tolerance_minute) preferences.detour_tolerance_minute)
except TimeoutError as te :
logger.error(f'Refiner failed : {str(te)} Using base tour.')
refined_tour = base_tour
except Exception as exc : except Exception as exc :
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(exc)}") from exc logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
refined_tour = base_tour
t_second_stage = time.time() - start_time t_second_stage = time.time() - start_time
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds') logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds') logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds') logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
linked_tour = LinkedLandmarks(refined_tour) linked_tour = LinkedLandmarks(refined_tour)
# upon creation of the trip, persistence of both the trip and its landmarks is ensured. # upon creation of the trip, persistence of both the trip and its landmarks is ensured.
trip = Trip.from_linked_landmarks(linked_tour, cache_client) trip = Trip.from_linked_landmarks(linked_tour, cache_client)
logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.') logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.')
logger.debug('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
background_tasks.add_task(fill_cache) background_tasks.add_task(fill_cache)
@@ -157,6 +163,7 @@ def get_trip(trip_uuid: str) -> Trip:
trip = cache_client.get(f"trip_{trip_uuid}") trip = cache_client.get(f"trip_{trip_uuid}")
return trip return trip
except KeyError as exc: except KeyError as exc:
logger.error(f"Failed to fetch trip with UUID {trip_uuid}: {str(exc)}")
raise HTTPException(status_code=404, detail="Trip not found") from exc raise HTTPException(status_code=404, detail="Trip not found") from exc
@@ -175,32 +182,45 @@ def get_landmark(landmark_uuid: str) -> Landmark:
landmark = cache_client.get(f"landmark_{landmark_uuid}") landmark = cache_client.get(f"landmark_{landmark_uuid}")
return landmark return landmark
except KeyError as exc: except KeyError as exc:
logger.error(f"Failed to fetch landmark with UUID {landmark_uuid}: {str(exc)}")
raise HTTPException(status_code=404, detail="Landmark not found") from exc raise HTTPException(status_code=404, detail="Landmark not found") from exc
@app.post("/toilets/new") @app.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] : def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
""" """
Endpoint to find toilets within a specified radius from a given location. Updates the reaching times of a given trip when removing a landmark.
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
Args: Args:
location (tuple[float, float]): The latitude and longitude of the location to search from. landmark_uuid (str) : unique identifier for a Landmark.
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
Returns: Returns:
list[Toilets]: A list of Toilets objects that meet the criteria. (Landmark) : the corresponding Landmark.
""" """
if location is None: # First, fetch the trip in the cache.
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid") try:
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180): trip = cache_client.get(f'trip_{trip_uuid}')
raise HTTPException(status_code=422, detail="Start coordinates not in range")
toilets_manager = ToiletsManager(location, radius)
try :
toilets_list = toilets_manager.generate_toilet_list()
return toilets_list
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail="No toilets found") from exc logger.error(f"Failed to update trip with UUID {trip_uuid} (trip not found): {str(exc)}")
raise HTTPException(status_code=404, detail='Trip not found') from exc
landmarks = []
next_uuid = trip.first_landmark_uuid
# Extract landmarks
try :
while next_uuid is not None:
landmark = cache_client.get(f'landmark_{next_uuid}')
# Filter out the removed landmark.
if next_uuid != removed_landmark_uuid :
landmarks.append(landmark)
next_uuid = landmark.next_uuid # Prepare for the next iteration
except KeyError as exc:
logger.error(f"Failed to update trip with UUID {trip_uuid} : {str(exc)}")
raise HTTPException(status_code=404, detail=f'landmark {next_uuid} not found') from exc
# Re-link every thing and compute times again
linked_tour = LinkedLandmarks(landmarks)
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
return trip

View File

@@ -257,7 +257,6 @@ class Optimizer:
Returns: Returns:
None: This function modifies the `prob` object by adding L-2 equality constraints in-place. None: This function modifies the `prob` object by adding L-2 equality constraints in-place.
""" """
# FIXME: weird 0 artifact in the coefficients popping up
# Loop through rows 1 to L-2 to prevent stacked ones # Loop through rows 1 to L-2 to prevent stacked ones
for i in range(1, L-1): for i in range(1, L-1):
# Add the constraint that sums across each "row" or "block" in the decision variables # Add the constraint that sums across each "row" or "block" in the decision variables
@@ -590,15 +589,15 @@ class Optimizer:
try : try :
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit+1, gapRel=self.gap_rel)) prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit+1, gapRel=self.gap_rel))
except Exception as exc : except Exception as exc :
raise Exception(f"No solution found: {exc}") from exc raise Exception(f"No solution found: {str(exc)}") from exc
status = pl.LpStatus[prob.status] status = pl.LpStatus[prob.status]
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1) solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
self.logger.debug("First results are out. Looking out for circles and correcting.") self.logger.debug("First results are out. Looking out for circles and correcting...")
# Raise error if no solution is found. FIXME: for now this throws the internal server error # Raise error if no solution is found. FIXME: for now this throws the internal server error
if status != 'Optimal' : if status != 'Optimal' :
self.logger.error("The problem is overconstrained, no solution on first try.") self.logger.warning("The problem is overconstrained, no solution on first try.")
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.") raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
# If there is a solution, we're good to go, just check for connectiveness # If there is a solution, we're good to go, just check for connectiveness
@@ -608,7 +607,7 @@ class Optimizer:
while circles is not None : while circles is not None :
i += 1 i += 1
if i == self.max_iter : if i == self.max_iter :
self.logger.error(f'Timeout: No solution found after {self.max_iter} iterations.') self.logger.warning(f'Timeout: No solution found after {self.max_iter} iterations.')
raise TimeoutError(f"Optimization took too long. No solution found after {self.max_iter} iterations.") raise TimeoutError(f"Optimization took too long. No solution found after {self.max_iter} iterations.")
for circle in circles : for circle in circles :
@@ -618,12 +617,13 @@ class Optimizer:
try : try :
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit, gapRel=self.gap_rel)) prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit, gapRel=self.gap_rel))
except Exception as exc : except Exception as exc :
raise Exception(f"No solution found: {exc}") from exc self.logger.warning("No solution found: {str(exc)")
raise Exception(f"No solution found: {str(exc)}") from exc
solution = [pl.value(var) for var in x] solution = [pl.value(var) for var in x]
if pl.LpStatus[prob.status] != 'Optimal' : if pl.LpStatus[prob.status] != 'Optimal' :
self.logger.error("The problem is overconstrained, no solution after {i} cycles.") self.logger.warning("The problem is overconstrained, no solution after {i} cycles.")
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.") raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
circles = self.is_connected(solution) circles = self.is_connected(solution)

View File

@@ -278,7 +278,7 @@ class Refiner :
better_tour_poly = concave_hull(MultiPoint(coords)) # Create concave hull with "core" of tour leaving out start and finish better_tour_poly = concave_hull(MultiPoint(coords)) # Create concave hull with "core" of tour leaving out start and finish
xs, ys = better_tour_poly.exterior.xy xs, ys = better_tour_poly.exterior.xy
""" """
ERROR HERE : FIXED : ERROR HERE :
Exception has occurred: AttributeError Exception has occurred: AttributeError
'LineString' object has no attribute 'exterior' 'LineString' object has no attribute 'exterior'
""" """
@@ -356,7 +356,7 @@ class Refiner :
# If unsuccessful optimization, use the base_tour. # If unsuccessful optimization, use the base_tour.
if new_tour is None: if new_tour is None:
self.logger.warning("No solution found for the refined tour. Returning the initial tour.") self.logger.warning("Refiner failed: No solution found during second stage optimization.")
new_tour = base_tour new_tour = base_tour
# If only one landmark, return it. # If only one landmark, return it.
@@ -369,6 +369,7 @@ class Refiner :
# Fix the tour using Polygons if the path looks weird. # Fix the tour using Polygons if the path looks weird.
# Conditions : circular trip and invalid polygon. # Conditions : circular trip and invalid polygon.
if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid : if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
self.logger.debug("Tours might be funky, attempting to correct with polygons")
better_tour = self.fix_using_polygon(better_tour) better_tour = self.fix_using_polygon(better_tour)
return better_tour return better_tour

View File

@@ -1,3 +1,4 @@
"""Module defining the handling of cache data from Overpass requests."""
import os import os
import json import json
import hashlib import hashlib
@@ -61,7 +62,7 @@ class JSONCache(CachingStrategyBase):
return None return None
def set(self, key, value): def set(self, key, value):
"""Save the JSON data as an ElementTree to the cache.""" """Save the JSON data in the cache."""
filename = self._filename(key) filename = self._filename(key)
try: try:
# Write the JSON data to the cache file # Write the JSON data to the cache file
@@ -94,7 +95,7 @@ class JSONCache(CachingStrategyBase):
def close(self): def close(self):
"""Cleanup method, if needed.""" """Cleanup method, if needed."""
pass
class CachingStrategy: class CachingStrategy:
""" """
@@ -107,6 +108,7 @@ class CachingStrategy:
@classmethod @classmethod
def use(cls, strategy_name='JSON', **kwargs): def use(cls, strategy_name='JSON', **kwargs):
"""Define the caching strategy to use."""
if cls.__strategy: if cls.__strategy:
cls.__strategy.close() cls.__strategy.close()
@@ -119,10 +121,12 @@ class CachingStrategy:
@classmethod @classmethod
def get(cls, key): def get(cls, key):
"""Get the data from the cache."""
return cls.__strategy.get(key) return cls.__strategy.get(key)
@classmethod @classmethod
def set(cls, key, value): def set(cls, key, value):
"""Save the data in the cache."""
cls.__strategy.set(key, value) cls.__strategy.set(key, value)
@classmethod @classmethod

View File

@@ -1,5 +1,6 @@
"""Module allowing connexion to overpass api and fectch data from OSM.""" """Module allowing connexion to overpass api and fectch data from OSM."""
import os import os
import time
import urllib import urllib
import math import math
import logging import logging
@@ -52,22 +53,22 @@ class Overpass :
# Retrieve cached data and identify missing cache entries # Retrieve cached data and identify missing cache entries
cached_responses, non_cached_cells = self._retrieve_cached_data(overlapping_cells, osm_types, selector, conditions, out) cached_responses, non_cached_cells = self._retrieve_cached_data(overlapping_cells, osm_types, selector, conditions, out)
self.logger.info(f'Cache hit for {len(overlapping_cells)-len(non_cached_cells)}/{len(overlapping_cells)} quadrants.') self.logger.debug(f'Cache hit for {len(overlapping_cells)-len(non_cached_cells)}/{len(overlapping_cells)} quadrants.')
# If there is no missing data, return the cached responses after filtering. # If there is no missing data, return the cached responses after filtering.
if not non_cached_cells : if not non_cached_cells :
return Overpass._filter_landmarks(cached_responses, bbox) return Overpass._filter_landmarks(cached_responses, bbox)
# If there is no cached data, fetch all from Overpass. # If there is no cached data, fetch all from Overpass.
elif not cached_responses : if not cached_responses :
query_str = Overpass.build_query(bbox, osm_types, selector, conditions, out) query_str = Overpass.build_query(bbox, osm_types, selector, conditions, out)
self.logger.debug(f'Query string: {query_str}')
return self.fetch_data_from_api(query_str) return self.fetch_data_from_api(query_str)
# Hybrid cache: some data from Overpass, some data from cache.
else :
# Resize the bbox for smaller search area and build new query string. # Resize the bbox for smaller search area and build new query string.
non_cached_bbox = Overpass._get_non_cached_bbox(non_cached_cells, bbox) non_cached_bbox = Overpass._get_non_cached_bbox(non_cached_cells, bbox)
query_str = Overpass.build_query(non_cached_bbox, osm_types, selector, conditions, out) query_str = Overpass.build_query(non_cached_bbox, osm_types, selector, conditions, out)
self.logger.debug(f'Query string: {query_str}')
non_cached_responses = self.fetch_data_from_api(query_str) non_cached_responses = self.fetch_data_from_api(query_str)
return Overpass._filter_landmarks(cached_responses, bbox) + non_cached_responses return Overpass._filter_landmarks(cached_responses, bbox) + non_cached_responses
@@ -94,9 +95,10 @@ class Overpass :
return elements return elements
except urllib.error.URLError as e: except urllib.error.URLError as e:
self.logger.error(f"Error connecting to Overpass API: {e}") self.logger.error(f"Error connecting to Overpass API: {str(e)}")
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
except Exception as exc : except Exception as exc :
self.logger.error(f"unexpected error while fetching data from Overpass: {str(exc)}")
raise Exception(f'An unexpected error occured: {str(exc)}') from exc raise Exception(f'An unexpected error occured: {str(exc)}') from exc
@@ -120,7 +122,7 @@ class Overpass :
self.caching_strategy.set(cache_key, elements) self.caching_strategy.set(cache_key, elements)
self.logger.debug(f'Cache set for {cache_key}') self.logger.debug(f'Cache set for {cache_key}')
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
except Exception as exc : except Exception as exc :
raise Exception(f'An unexpected error occured: {str(exc)}') from exc raise Exception(f'An unexpected error occured: {str(exc)}') from exc
@@ -151,7 +153,7 @@ class Overpass :
- If no conditions are provided, the query will just use the `selector` to filter the OSM - If no conditions are provided, the query will just use the `selector` to filter the OSM
elements without additional constraints. elements without additional constraints.
""" """
query = '[out:json];(' query = '[out:json][timeout:20];('
# convert the bbox to string. # convert the bbox to string.
bbox_str = f"({','.join(map(str, bbox))})" bbox_str = f"({','.join(map(str, bbox))})"
@@ -386,7 +388,7 @@ def get_base_info(elem: dict, osm_type: OSM_TYPES, with_name=False) :
if with_name : if with_name :
name = elem.get('tags', {}).get('name') name = elem.get('tags', {}).get('name')
return osm_id, coords, name return osm_id, coords, name
else :
return osm_id, coords return osm_id, coords
@@ -397,18 +399,25 @@ def fill_cache():
""" """
overpass = Overpass() overpass = Overpass()
n_files = 0
total = 0
with os.scandir(OSM_CACHE_DIR) as it: with os.scandir(OSM_CACHE_DIR) as it:
for entry in it: for entry in it:
if entry.is_file() and entry.name.startswith('hollow_'): if entry.is_file() and entry.name.startswith('hollow_'):
total += 1
try : try :
# Read the whole file content as a string # Read the whole file content as a string
with open(entry.path, 'r') as f: with open(entry.path, 'r', encoding='utf-8') as f:
# load data and fill the cache with the query and key # load data and fill the cache with the query and key
json_data = json.load(f) json_data = json.load(f)
overpass.fill_cache(json_data) overpass.fill_cache(json_data)
n_files += 1
time.sleep(1)
# Now delete the file as the cache is filled # Now delete the file as the cache is filled
os.remove(entry.path) os.remove(entry.path)
except Exception as exc : except Exception as exc :
overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file') overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file: {str(exc)}')
overpass.logger.info(f"Successfully filled {n_files}/{total} cache files.")

View File

@@ -72,6 +72,7 @@ sightseeing:
# - castle # - castle
# - museum # - museum
museums: museums:
tourism: tourism:
- museum - museum

View File

@@ -1,12 +1,11 @@
max_bbox_side: 4000 #m max_bbox_side: 4000 #m
radius_close_to: 50 radius_close_to: 50
church_coeff: 0.55 church_coeff: 0.75
nature_coeff: 1.4 nature_coeff: 1.6
overall_coeff: 10 overall_coeff: 10
tag_exponent: 1.15 tag_exponent: 1.15
image_bonus: 1.1 image_bonus: 1.1
viewpoint_bonus: 5 viewpoint_bonus: 10
wikipedia_bonus: 1.25 wikipedia_bonus: 1.25
name_bonus: 3
N_important: 60 N_important: 60
pay_bonus: -1 pay_bonus: -1

View File

@@ -5,5 +5,5 @@ max_landmarks: 10
max_landmarks_refiner: 20 max_landmarks_refiner: 20
overshoot: 0.0016 overshoot: 0.0016
time_limit: 1 time_limit: 1
gap_rel: 0.05 gap_rel: 0.025
max_iter: 40 max_iter: 80

View File

@@ -1,8 +1,7 @@
"""Definition of the Landmark class to handle visitable objects across the world.""" """Definition of the Landmark class to handle visitable objects across the world."""
from typing import Optional, Literal from typing import Optional, Literal
from uuid import uuid4, UUID from uuid import uuid4, UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
# Output to frontend # Output to frontend
@@ -50,7 +49,8 @@ class Landmark(BaseModel) :
image_url : Optional[str] = None image_url : Optional[str] = None
website_url : Optional[str] = None website_url : Optional[str] = None
wiki_url : Optional[str] = None wiki_url : Optional[str] = None
description : Optional[str] = None # TODO future keywords: Optional[dict] = {}
description : Optional[str] = None
duration : Optional[int] = 5 duration : Optional[int] = 5
name_en : Optional[str] = None name_en : Optional[str] = None
@@ -69,6 +69,7 @@ class Landmark(BaseModel) :
is_viewpoint : Optional[bool] = False is_viewpoint : Optional[bool] = False
is_place_of_worship : Optional[bool] = False is_place_of_worship : Optional[bool] = False
def __str__(self) -> str: def __str__(self) -> str:
""" """
String representation of the Landmark object. String representation of the Landmark object.
@@ -122,26 +123,3 @@ class Landmark(BaseModel) :
return (self.uuid == value.uuid or return (self.uuid == value.uuid or
self.osm_id == value.osm_id or self.osm_id == value.osm_id or
(self.name == value.name and self.distance(value) < 0.001)) (self.name == value.name and self.distance(value) < 0.001))
class Toilets(BaseModel) :
"""
Model for toilets. When false/empty the information is either false either not known.
"""
location : tuple
wheelchair : Optional[bool] = False
changing_table : Optional[bool] = False
fee : Optional[bool] = False
opening_hours : Optional[str] = ""
def __str__(self) -> str:
"""
String representation of the Toilets object.
Returns:
str: A formatted string with the toilets location.
"""
return f'Toilets @{self.location}'
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,26 @@
"""Definition of the Toilets class."""
from typing import Optional
from pydantic import BaseModel, ConfigDict
class Toilets(BaseModel) :
"""
Model for toilets. When false/empty the information is either false either not known.
"""
location : tuple
wheelchair : Optional[bool] = False
changing_table : Optional[bool] = False
fee : Optional[bool] = False
opening_hours : Optional[str] = ""
def __str__(self) -> str:
"""
String representation of the Toilets object.
Returns:
str: A formatted string with the toilets location.
"""
return f'Toilets @{self.location}'
model_config = ConfigDict(from_attributes=True)

View File

@@ -31,9 +31,9 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
"shopping": {"type": "shopping", "score": 0}, "shopping": {"type": "shopping", "score": 0},
"max_time_minute": duration_minutes, "max_time_minute": duration_minutes,
"detour_tolerance_minute": 0}, "detour_tolerance_minute": 0},
# "start": [48.084588, 7.280405] "start": [48.084588, 7.280405]
# "start": [45.74445023349939, 4.8222687890538865] # "start": [45.74445023349939, 4.8222687890538865]
"start": [45.75156398104873, 4.827154464827647] # "start": [45.75156398104873, 4.827154464827647]
} }
) )
result = response.json() result = response.json()
@@ -46,8 +46,6 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
# Add details to report # Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes) log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
# checks : # checks :
assert response.status_code == 200 # check for successful planning assert response.status_code == 200 # check for successful planning

View File

@@ -3,7 +3,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest import pytest
from ..structs.landmark import Toilets from ..structs.toilets import Toilets
from ..main import app from ..main import app

View File

@@ -1,7 +1,6 @@
"""Helper methods for testing.""" """Helper methods for testing."""
import logging import logging
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError
from ..structs.landmark import Landmark from ..structs.landmark import Landmark
from ..cache import client as cache_client from ..cache import client as cache_client
@@ -39,7 +38,7 @@ def fetch_landmark(landmark_uuid: str):
try: try:
landmark = cache_client.get(f'landmark_{landmark_uuid}') landmark = cache_client.get(f'landmark_{landmark_uuid}')
if not landmark : if not landmark :
logger.warning(f'Cache miss for landmark UUID: {landmark_uuid}') logger.error(f'Cache miss for landmark UUID: {landmark_uuid}')
raise HTTPException(status_code=404, detail=f'Landmark with UUID {landmark_uuid} not found in cache.') raise HTTPException(status_code=404, detail=f'Landmark with UUID {landmark_uuid} not found in cache.')
# Validate that the fetched data is a dictionary # Validate that the fetched data is a dictionary

View File

View File

@@ -0,0 +1,38 @@
"""Defines the endpoint for fetching toilet locations."""
from fastapi import HTTPException, APIRouter, Query
from ..structs.toilets import Toilets
from .toilets_manager import ToiletsManager
# Define the API router
router = APIRouter()
@router.post("/toilets/new")
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
"""
Endpoint to find toilets within a specified radius from a given location.
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
Args:
location (tuple[float, float]): The latitude and longitude of the location to search from.
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
Returns:
list[Toilets]: A list of Toilets objects that meet the criteria.
"""
if location is None:
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid")
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180):
raise HTTPException(status_code=422, detail="Start coordinates not in range")
toilets_manager = ToiletsManager(location, radius)
try :
toilets_list = toilets_manager.generate_toilet_list()
except KeyError as exc:
raise HTTPException(status_code=404, detail="No toilets found") from exc
return toilets_list

View File

@@ -2,8 +2,8 @@
import logging import logging
from ..overpass.overpass import Overpass, get_base_info from ..overpass.overpass import Overpass, get_base_info
from ..structs.landmark import Toilets from ..structs.toilets import Toilets
from .utils import create_bbox from ..utils.bbox import create_bbox
# silence the overpass logger # silence the overpass logger
@@ -65,7 +65,7 @@ class ToiletsManager:
try: try:
result = self.overpass.fetch_data_from_api(query_str=query) result = self.overpass.fetch_data_from_api(query_str=query)
except Exception as e: except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}") self.logger.error(f"Error fetching toilets: {e}")
return None return None
toilets_list = self.to_toilets(result) toilets_list = self.to_toilets(result)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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:flutter/material.dart';
import 'package:anyway/constants.dart'; import 'package:anyway/constants.dart';
import 'package:anyway/layout.dart';
void main() => runApp(const App()); void main() => runApp(const App());
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final SavedTrips savedTrips = SavedTrips();
class App extends StatelessWidget { class App extends StatelessWidget {
const App({super.key}); const App({super.key});
@@ -14,7 +16,7 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: APP_NAME, title: APP_NAME,
home: BasePage(mainScreen: "map"), home: getFirstPage(),
theme: APP_THEME, theme: APP_THEME,
scaffoldMessengerKey: rootScaffoldMessengerKey scaffoldMessengerKey: rootScaffoldMessengerKey
); );

View File

@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:anyway/modules/landmark_card.dart'; import 'package:anyway/modules/landmark_card.dart';
import 'package:anyway/structs/landmark.dart'; import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:anyway/main.dart';
@@ -25,30 +24,7 @@ List<Widget> landmarksList(Trip trip) {
for (Landmark landmark in trip.landmarks) { for (Landmark landmark in trip.landmarks) {
children.add( children.add(
Dismissible( LandmarkCard(landmark, trip),
key: ValueKey<int>(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,
),
)
); );
if (landmark.next != null) { if (landmark.next != null) {

View File

@@ -1,9 +1,20 @@
import 'package:anyway/constants.dart';
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';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:anyway/pages/current_trip.dart'; import 'package:anyway/pages/current_trip.dart';
final List<String> 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 { class CurrentTripLoadingIndicator extends StatefulWidget {
final Trip trip; final Trip trip;
const CurrentTripLoadingIndicator({ const CurrentTripLoadingIndicator({
@@ -15,46 +26,137 @@ class CurrentTripLoadingIndicator extends StatefulWidget {
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState(); State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
} }
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> { class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override @override
Widget build(BuildContext context) => Center( Widget build(BuildContext context) => Stack(
child: FutureBuilder( fit: StackFit.expand,
future: widget.trip.cityName, 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<statusText> {
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,
);
}
}
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 = AnimatedGradientText(
maxLines: 1, text: 'Creating your trip to ${snapshot.data}...',
'Generating your trip to ${snapshot.data}...',
style: greeterStyle, style: greeterStyle,
); );
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
// the exact error is shown in the central part of the trip overview. No need to show it here // the exact error is shown in the central part of the trip overview. No need to show it here
greeter = AutoSizeText( greeter = AnimatedGradientText(
maxLines: 1, text: 'Error while loading trip.',
'Error while loading trip.',
style: greeterStyle, style: greeterStyle,
); );
} else { } else {
greeter = AutoSizeText( greeter = AnimatedGradientText(
maxLines: 1, text: 'Creating your trip...',
'Generating your trip...',
style: greeterStyle, style: greeterStyle,
); );
} }
return Column( return greeter;
mainAxisAlignment: MainAxisAlignment.center, }
children: [ );
greeter,
loadingIndicator, class AnimatedGradientText extends StatefulWidget {
final String text;
final TextStyle style;
const AnimatedGradientText({
Key? key,
required this.text,
required this.style,
}) : super(key: key);
@override
_AnimatedGradientTextState createState() => _AnimatedGradientTextState();
}
class _AnimatedGradientTextState extends State<AnimatedGradientText> 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,
),
);
},
); );
} }
)
);
} }

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),
), ),
@@ -72,7 +73,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
const Padding(padding: EdgeInsets.only(top: 10)), const Padding(padding: EdgeInsets.only(top: 10)),
Center(child: saveButton(widget.trip)), Center(child: saveButton(trip: widget.trip)),
], ],
); );
} }

View File

@@ -3,12 +3,24 @@ import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
Widget saveButton(Trip trip) => ElevatedButton(
class saveButton extends StatefulWidget {
Trip trip;
saveButton({super.key, required this.trip});
@override
State<saveButton> createState() => _saveButtonState();
}
class _saveButtonState extends State<saveButton> {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async { onPressed: () async {
SharedPreferences prefs = await SharedPreferences.getInstance(); savedTrips.addTrip(widget.trip);
trip.toPrefs(prefs); // SharedPreferences prefs = await SharedPreferences.getInstance();
// setState(() => widget.trip.toPrefs(prefs));
rootScaffoldMessengerKey.currentState!.showSnackBar( rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar( SnackBar(
content: Text('Trip saved'), content: Text('Trip saved'),
@@ -37,5 +49,7 @@ Widget saveButton(Trip trip) => ElevatedButton(
], ],
), ),
) )
); );
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
Future<void> helpDialog(BuildContext context, String title, String content) {
return showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('Got it!'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}

View File

@@ -1,3 +1,5 @@
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -6,8 +8,12 @@ import 'package:anyway/structs/landmark.dart';
class LandmarkCard extends StatefulWidget { class LandmarkCard extends StatefulWidget {
final Landmark landmark; final Landmark landmark;
final Trip parentTrip;
LandmarkCard(this.landmark); LandmarkCard(
this.landmark,
this.parentTrip,
);
@override @override
_LandmarkCardState createState() => _LandmarkCardState(); _LandmarkCardState createState() => _LandmarkCardState();
@@ -17,34 +23,54 @@ class LandmarkCard extends StatefulWidget {
class _LandmarkCardState extends State<LandmarkCard> { class _LandmarkCardState extends State<LandmarkCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ThemeData theme = Theme.of(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( return Container(
height: 160,
child: Card( child: Card(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0), borderRadius: BorderRadius.circular(15.0),
), ),
elevation: 5, elevation: 5,
clipBehavior: Clip.antiAliasWithSaveLayer, clipBehavior: Clip.antiAliasWithSaveLayer,
child: Row( // 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( // the image on the left Container(
// inherit the height of the parent container // the image on the left
height: double.infinity,
// force a fixed width
width: 160, width: 160,
height: 160,
child: CachedNetworkImage( child: CachedNetworkImage(
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,
), ),
), ),
Flexible( Flexible(
child: Padding( child: textLayout(),
),
],
);
}
Widget textLayout() {
return Padding(
padding: EdgeInsets.all(10), padding: EdgeInsets.all(10),
child: Column( child: Column(
children: [ children: [
@@ -76,7 +102,10 @@ class _LandmarkCardState extends State<LandmarkCard> {
) )
], ],
), ),
SingleChildScrollView( Padding(padding: EdgeInsets.only(top: 10)),
Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
// allows the buttons to be scrolled // allows the buttons to be scrolled
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Wrap( child: Wrap(
@@ -103,25 +132,41 @@ class _LandmarkCardState extends State<LandmarkCard> {
icon: Icon(Icons.link), icon: Icon(Icons.link),
label: Text('Website'), label: Text('Website'),
), ),
if (widget.landmark.wikipediaURL != null) PopupMenuButton(
TextButton.icon( icon: Icon(Icons.settings),
onPressed: () async { style: TextButtonTheme.of(context).style,
// open a browser with the wikipedia link itemBuilder: (context) => [
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!)); 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"))
);
}, },
icon: Icon(Icons.book), ),
label: Text('Wikipedia'), ),
PopupMenuItem(
child: ListTile(
leading: Icon(Icons.star),
title: Text('Favorite'),
onTap: () async {
// delete the landmark
// await deleteLandmark(widget.landmark);
},
),
), ),
], ],
), )
),
], ],
), ),
), ),
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -1,5 +1,5 @@
import 'package:anyway/layout.dart';
import 'package:anyway/main.dart'; import 'package:anyway/main.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/structs/preferences.dart'; import 'package:anyway/structs/preferences.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:anyway/utils/fetch_trip.dart'; import 'package:anyway/utils/fetch_trip.dart';
@@ -57,7 +57,7 @@ class _NewTripButtonState extends State<NewTripButton> {
fetchTrip(trip, widget.preferences); fetchTrip(trip, widget.preferences);
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "map", trip: trip) builder: (context) => TripPage(trip: trip)
) )
); );
} }

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

@@ -2,13 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
class OnboardingCard extends StatelessWidget { class OnboardingCard extends StatelessWidget {
int index; final String title;
String title; final String description;
String description; final String imagePath;
String imagePath;
OnboardingCard({ const OnboardingCard({
required this.index,
required this.title, required this.title,
required this.description, required this.description,
required this.imagePath, required this.imagePath,
@@ -16,13 +14,8 @@ class OnboardingCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Color baseColor = Theme.of(context).colorScheme.secondary;
// have a different color for each card, incrementing the hue return Padding(
Color currentColor = baseColor.withAlpha(baseColor.alpha - index * 30);
return Container(
color: currentColor,
alignment: Alignment.center,
child: Padding(
padding: EdgeInsets.all(20), padding: EdgeInsets.all(20),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -50,7 +43,6 @@ class OnboardingCard extends StatelessWidget {
] ]
), ),
)
); );
} }
} }

View File

@@ -19,8 +19,7 @@ class StepBetweenLandmarks extends StatefulWidget {
class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> { class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
int timeRounded = 5 * ((widget.current.tripTime?.inMinutes ?? 0) ~/ 5); int time = widget.current.tripTime?.inMinutes ?? 0;
// ~/ is integer division (rounding)
return Container( return Container(
margin: EdgeInsets.all(10), margin: EdgeInsets.all(10),
padding: EdgeInsets.all(10), padding: EdgeInsets.all(10),
@@ -34,7 +33,7 @@ class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
Column( Column(
children: [ children: [
Icon(Icons.directions_walk), Icon(Icons.directions_walk),
Text("~$timeRounded min", style: TextStyle(fontSize: 10)), Text("$time min", style: TextStyle(fontSize: 10)),
], ],
), ),
Spacer(), Spacer(),

View File

@@ -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:flutter/material.dart';
import 'package:anyway/layout.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
class TripsOverview extends StatefulWidget { class TripsOverview extends StatefulWidget {
final Future<List<Trip>> trips; final SavedTrips trips;
const TripsOverview({ const TripsOverview({
super.key, super.key,
required this.trips, required this.trips,
@@ -16,12 +17,11 @@ class TripsOverview extends StatefulWidget {
} }
class _TripsOverviewState extends State<TripsOverview> { class _TripsOverviewState extends State<TripsOverview> {
Widget listBuild (BuildContext context, SavedTrips trips) {
Widget listBuild (BuildContext context, AsyncSnapshot<List<Trip>> snapshot) {
List<Widget> children; List<Widget> children;
if (snapshot.hasData) { List<Trip> items = trips.trips;
children = List<Widget>.generate(snapshot.data!.length, (index) { children = List<Widget>.generate(items.length, (index) {
Trip trip = snapshot.data![index]; Trip trip = items[index];
return ListTile( return ListTile(
title: FutureBuilder( title: FutureBuilder(
future: trip.cityName, future: trip.cityName,
@@ -39,27 +39,12 @@ class _TripsOverviewState extends State<TripsOverview> {
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "map", trip: trip) builder: (context) => TripPage(trip: trip)
) )
); );
}, },
); );
}); });
} 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())];
}
return ListView( return ListView(
children: children, children: children,
@@ -69,9 +54,11 @@ class _TripsOverviewState extends State<TripsOverview> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder( return ListenableBuilder(
future: widget.trips, listenable: widget.trips,
builder: listBuild, builder: (BuildContext context, Widget? child) {
return listBuild(context, widget.trips);
}
); );
} }
} }

View File

@@ -1,3 +1,6 @@
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'; import 'package:anyway/pages/settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -8,22 +11,24 @@ import 'package:anyway/modules/trips_saved_list.dart';
import 'package:anyway/utils/load_trips.dart'; import 'package:anyway/utils/load_trips.dart';
import 'package:anyway/pages/new_trip_location.dart'; import 'package:anyway/pages/new_trip_location.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/onboarding.dart'; import 'package:anyway/pages/onboarding.dart';
// BasePage is the scaffold that holds all other pages // BasePage is the scaffold that holds a child page and a side drawer
// A side drawer is used to switch between pages // The side drawer is the main way to switch between pages
class BasePage extends StatefulWidget { class BasePage extends StatefulWidget {
final String mainScreen; final Widget mainScreen;
final Trip? trip; final Widget title;
final List<String> helpTexts;
const BasePage({ const BasePage({
super.key, super.key,
required this.mainScreen, required this.mainScreen,
this.trip, this.title = const Text(APP_NAME),
this.helpTexts = const [],
}); });
@override @override
@@ -34,53 +39,25 @@ class _BasePageState extends State<BasePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget currentView = const Text("loading..."); savedTrips.loadTrips();
Future<List<Trip>> 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<Trip> availableTrips = snapshot.data!;
if (availableTrips.isNotEmpty) {
return TripPage(trip: availableTrips[0]);
} else {
return Scaffold( return Scaffold(
body: Center( appBar: AppBar(
child: Text("Wow, so empty!"), title: widget.title,
), actions: [
floatingActionButton: FloatingActionButton.extended( IconButton(
icon: const Icon(Icons.help),
tooltip: 'Help',
onPressed: () { onPressed: () {
Navigator.of(context).push( if (widget.helpTexts.isNotEmpty) {
MaterialPageRoute( helpDialog(context, widget.helpTexts[0], widget.helpTexts[1]);
builder: (context) => const NewTripPage() }
) }
);
},
label: Text("Plan a trip"),
), ),
); ],
} ),
} else { body: Center(child: widget.mainScreen),
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),
drawer: Drawer( drawer: Drawer(
child: Column( child: Column(
children: [ children: [
@@ -104,7 +81,8 @@ class _BasePageState extends State<BasePage> {
ListTile( ListTile(
title: const Text('Your Trips'), title: const Text('Your Trips'),
leading: const Icon(Icons.map), leading: const Icon(Icons.map),
selected: widget.mainScreen == "map", // TODO: this is not working!
selected: widget.mainScreen is TripPage,
onTap: () {}, onTap: () {},
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: () { onPressed: () {
@@ -122,11 +100,11 @@ class _BasePageState extends State<BasePage> {
// through the options in the drawer if there isn't enough vertical // through the options in the drawer if there isn't enough vertical
// space to fit everything. // space to fit everything.
Expanded( Expanded(
child: TripsOverview(trips: trips), child: TripsOverview(trips: savedTrips),
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
removeAllTripsFromPrefs(); savedTrips.clearTrips();
}, },
child: const Text('Clear trips'), child: const Text('Clear trips'),
), ),
@@ -134,11 +112,12 @@ class _BasePageState extends State<BasePage> {
ListTile( ListTile(
title: const Text('How to use'), title: const Text('How to use'),
leading: Icon(Icons.help), leading: Icon(Icons.help),
selected: widget.mainScreen == "tutorial", // TODO: this is not working!
selected: widget.mainScreen is OnboardingPage,
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "tutorial") builder: (context) => OnboardingPage()
) )
); );
}, },
@@ -148,11 +127,12 @@ class _BasePageState extends State<BasePage> {
ListTile( ListTile(
title: const Text('Settings'), title: const Text('Settings'),
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
selected: widget.mainScreen == "settings", // TODO: this is not working!
selected: widget.mainScreen is SettingsPage,
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "settings") builder: (context) => SettingsPage()
) )
); );
}, },

View File

@@ -1,4 +1,5 @@
import 'package:anyway/constants.dart'; import 'package:anyway/constants.dart';
import 'package:anyway/pages/base_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart';
@@ -10,7 +11,7 @@ final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 20
TextStyle greeterStyle = TextStyle( TextStyle greeterStyle = TextStyle(
foreground: Paint()..shader = textGradient, foreground: Paint()..shader = textGradient,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 26 fontSize: 25
); );
@@ -31,7 +32,8 @@ class _TripPageState extends State<TripPage> {
@override @override
Widget build(BuildContext context) { 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 // use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview
panelBuilder: (scrollcontroller) => CurrentTripPanel(controller: scrollcontroller, trip: widget.trip), 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 // using collapsed and panelBuilder seems to show both at the same time, so we include the greeter in the panelBuilder
@@ -41,7 +43,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)),
@@ -52,6 +54,13 @@ class _TripPageState extends State<TripPage> {
color: Colors.black, color: Colors.black,
) )
], ],
),
title: FutureBuilder(
future: widget.trip.cityName,
builder: (context, snapshot) => Text(
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
)
),
); );
} }
} }

View File

@@ -1,5 +1,5 @@
import 'package:anyway/modules/new_trip_button.dart';
import 'package:anyway/modules/new_trip_options_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:flutter/material.dart';
import "package:anyway/structs/trip.dart"; import "package:anyway/structs/trip.dart";
@@ -19,13 +19,12 @@ class _NewTripPageState extends State<NewTripPage> {
final TextEditingController lonController = TextEditingController(); final TextEditingController lonController = TextEditingController();
Trip trip = Trip(); Trip trip = Trip();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// floating search bar and map as a background // floating search bar and map as a background
return Scaffold( return BasePage(
appBar: AppBar( mainScreen: Scaffold(
title: const Text('New Trip'),
),
body: Stack( body: Stack(
children: [ children: [
NewTripMap(trip), NewTripMap(trip),
@@ -36,6 +35,12 @@ class _NewTripPageState extends State<NewTripPage> {
], ],
), ),
floatingActionButton: NewTripOptionsButton(trip: 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."
],
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:anyway/modules/new_trip_button.dart'; 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/preferences.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -19,7 +20,8 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return BasePage(
mainScreen: Scaffold(
body: ListView( body: ListView(
children: [ children: [
// Center( // Center(
@@ -28,16 +30,16 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
// child: Icon(Icons.person, size: 100), // child: Icon(Icons.person, size: 100),
// ) // )
// ), // ),
Padding(padding: EdgeInsets.only(top: 30)), // Padding(padding: EdgeInsets.only(top: 30)),
Center( // Center(
child: FutureBuilder( // child: FutureBuilder(
future: widget.trip.cityName, // future: widget.trip.cityName,
builder: (context, snapshot) => Text( // builder: (context, snapshot) => Text(
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}', // 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold) // style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)
) // )
) // )
), // ),
Center( Center(
child: Padding( child: Padding(
@@ -54,6 +56,18 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
] ]
), ),
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.'
],
); );
} }

View File

@@ -1,7 +1,33 @@
import 'dart:ui';
import 'package:anyway/constants.dart';
import 'package:anyway/modules/onboarding_card.dart'; import 'package:anyway/modules/onboarding_card.dart';
import 'package:anyway/pages/new_trip_location.dart'; import 'package:anyway/pages/new_trip_location.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const List<Widget> 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"
),
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 { class OnboardingPage extends StatefulWidget {
const OnboardingPage({super.key}); const OnboardingPage({super.key});
@@ -10,27 +36,58 @@ class OnboardingPage extends StatefulWidget {
} }
class _OnboardingPageState extends State<OnboardingPage> { class _OnboardingPageState extends State<OnboardingPage> {
final PageController _controller = PageController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final PageController _controller = PageController();
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
PageView( AnimatedBuilder(
// horizontally scrollable list of pages animation: _controller,
controller: _controller, builder: (context, child) {
return Stack(
children: [ children: [
OnboardingCard(index: 1, title: "Welcome to anyway!", description: "Anyway helps you plan a city trip that suits your wishes.", imagePath: "assets/city.svg"), Container(
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"), decoration: BoxDecoration(
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"), 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],
);
}
),
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton.extended(
onPressed: () { onPressed: () {
if (_controller.page == 2) { if (_controller.page == onboardingCards.length - 1) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const NewTripPage() builder: (context) => const NewTripPage()
@@ -40,7 +97,22 @@ class _OnboardingPageState extends State<OnboardingPage> {
_controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease); _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
} }
}, },
child: Icon(Icons.arrow_forward), label: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
if ((_controller.page ?? _controller.initialPage) == onboardingCards.length - 1) {
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);
}
}
)
), ),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'package:anyway/constants.dart'; import 'package:anyway/constants.dart';
import 'package:anyway/main.dart'; import 'package:anyway/main.dart';
import 'package:anyway/pages/base_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -16,7 +17,8 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return BasePage(
mainScreen: ListView(
padding: EdgeInsets.all(15), padding: EdgeInsets.all(15),
children: [ children: [
// First a round, centered image // First a round, centered image
@@ -40,6 +42,12 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
return Center( return Center(
child: Column( child: Column(
children: [ 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( TextButton.icon(
icon: Icon(Icons.info), icon: Icon(Icons.info),

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,
@@ -130,7 +125,7 @@ class LandmarkType {
LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) { LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) {
switch (name) { switch (name) {
case 'sightseeing': case 'sightseeing':
icon = const Icon(Icons.church); icon = const Icon(Icons.castle);
break; break;
case 'nature': case 'nature':
icon = const Icon(Icons.eco); icon = const Icon(Icons.eco);

View File

@@ -113,10 +113,3 @@ LinkedList<Landmark> readLandmarks(SharedPreferences prefs, String? firstUUID) {
} }
return landmarks; return landmarks;
} }
void removeAllTripsFromPrefs () async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.clear();
}

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,20 @@ 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;
}
} 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;
}
}
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 +116,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,41 @@
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() {
SavedTrips trips = SavedTrips();
trips.loadTrips();
return ListenableBuilder(
listenable: trips,
builder: (BuildContext context, Widget? child) {
List<Trip> items = trips.trips;
if (items.isNotEmpty) {
return TripPage(trip: items[0]);
} else {
return OnboardingPage();
}
}
);
// Future<List<Trip>> 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<Trip> availableTrips = snapshot.data!;
// if (availableTrips.isNotEmpty) {
// return TripPage(trip: availableTrips[0]);
// } else {
// return OnboardingPage();
// }
// } else {
// return CircularProgressIndicator();
// }
// }
// );
}

View File

@@ -0,0 +1,71 @@
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);
}
Future<String?> 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);
}

View File

@@ -1,10 +1,14 @@
import 'dart:collection';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
Future<List<Trip>> loadTrips() async { import 'package:flutter/foundation.dart';
class SavedTrips extends ChangeNotifier {
List<Trip> _trips = [];
List<Trip> get trips => _trips;
void loadTrips() async {
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
List<Trip> trips = []; List<Trip> trips = [];
@@ -15,5 +19,21 @@ Future<List<Trip>> loadTrips() async {
trips.add(Trip.fromPrefs(prefs, uuid)); trips.add(Trip.fromPrefs(prefs, uuid));
} }
} }
return trips; _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();
}
} }

View File

@@ -101,10 +101,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.0"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -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:
@@ -404,18 +412,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.5" version: "10.0.7"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.8"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@@ -700,7 +708,7 @@ packages:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.0"
sliding_up_panel: sliding_up_panel:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -745,10 +753,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.12.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -769,10 +777,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -793,10 +801,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.3"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -913,10 +921,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.5" version: "14.3.0"
web: web:
dependency: transitive dependency: transitive
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:

View File

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

48
status Normal file
View File

@@ -0,0 +1,48 @@
error: wrong number of arguments, should be from 1 to 2
usage: git config [<options>]
Config file location
--[no-]global use global config file
--[no-]system use system config file
--[no-]local use repository config file
--[no-]worktree use per-worktree config file
-f, --[no-]file <file>
use given config file
--[no-]blob <blob-id> read config from given blob object
Action
--[no-]get get value: name [value-pattern]
--[no-]get-all get all values: key [value-pattern]
--[no-]get-regexp get values for regexp: name-regex [value-pattern]
--[no-]get-urlmatch get value specific for the URL: section[.var] URL
--[no-]replace-all replace all matching variables: name value [value-pattern]
--[no-]add add a new variable: name value
--[no-]unset remove a variable: name [value-pattern]
--[no-]unset-all remove all matches: name [value-pattern]
--[no-]rename-section rename section: old-name new-name
--[no-]remove-section remove a section: name
-l, --[no-]list list all
--[no-]fixed-value use string equality when comparing values to 'value-pattern'
-e, --[no-]edit open an editor
--[no-]get-color find the color configured: slot [default]
--[no-]get-colorbool find the color setting: slot [stdout-is-tty]
Type
-t, --[no-]type <type>
value is given this type
--bool value is "true" or "false"
--int value is decimal number
--bool-or-int value is --bool or --int
--bool-or-str value is --bool or string
--path value is a path (file or directory name)
--expiry-date value is an expiry date
Other
-z, --[no-]null terminate values with NUL byte
--[no-]name-only show variable names only
--[no-]includes respect include directives on lookup
--[no-]show-origin show origin of config (file, standard input, blob, command line)
--[no-]show-scope show scope of config (worktree, local, global, system, command)
--[no-]default <value>
with --get, use default value when missing entry