1 Commits

Author SHA1 Message Date
0493eba332 remove gradle again
Some checks failed
/ push-to-remote (push) Successful in 12s
Build and deploy the backend to production / Deploy to production (push) Has been cancelled
Build and deploy the backend to production / Build and push image (push) Has been cancelled
2024-09-25 17:09:21 +02:00
69 changed files with 1928 additions and 2976 deletions

View File

@@ -6,7 +6,7 @@ on:
- frontend/** - frontend/**
name: Build and release debug APK name: Build and release APK
jobs: jobs:
build: build:
@@ -55,7 +55,7 @@ jobs:
ls -lah android ls -lah android
working-directory: ./frontend working-directory: ./frontend
- run: flutter build apk --debug --split-per-abi --build-number=${{ gitea.run_number }} - run: flutter build apk --release --split-per-abi --build-number=${{ gitea.run_number }}
working-directory: ./frontend working-directory: ./frontend
- name: Upload APKs to artifacts - name: Upload APKs to artifacts

View File

@@ -32,4 +32,4 @@ jobs:
- name: Deploy to k8s - name: Deploy to k8s
run: | run: |
kubectl apply -k backend/deployment/overlays/${{ inputs.overlay }} --kubeconfig=kubeconfig kubectl apply -k backend/deployment/overlays/${{ inputs.overlay }} --kubeconfig=kubeconfig
kubectl -n anyway-backend rollout restart deployment/anyway-backend-${{ inputs.overlay }} --kubeconfig=kubeconfig kubectl -n anyway-backend rollout restart deployment/anyway-backend-${{ inputs.overlay }}

View File

@@ -1,3 +0,0 @@
{
"cmake.ignoreCMakeListsMissing": true
}

View File

@@ -9,9 +9,9 @@ name = "pypi"
numpy = "*" numpy = "*"
fastapi = "*" fastapi = "*"
pydantic = "*" pydantic = "*"
geopy = "*"
shapely = "*" shapely = "*"
scipy = "*" scipy = "*"
osmpythontools = "*" osmpythontools = "*"
pywikibot = "*" pywikibot = "*"
pymemcache = "*" pymemcache = "*"
fastapi-cli = "*"

2208
backend/Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,12 @@
# Backend # Backend
This repository contains the backend code for the application. It utilizes **FastAPI** to quickly create a RESTful API that exposes the endpoints of the route optimizer. This repository contains the backend code for the application. It utilizes FastAPI that allows to quickly create a RESTful API that exposes the endpoints of the route optimizer.
## Getting Started ## Getting Started
- The code of the python application is located in the `src` directory.
### Directory Structure - Package management is handled with `pipenv` and the dependencies are listed in the `Pipfile`.
- The code for the Python application is located in the `src` directory. - Since the application is aimed to be deployed in a container, the `Dockerfile` is provided to build the image.
- Package management is handled with **pipenv**, and the dependencies are listed in the `Pipfile`.
- Since the application is designed to be deployed in a container, the `Dockerfile` is provided to build the image.
### Setting Up the Development Environment
To set up your development environment using **pipenv**, follow these steps:
1. Install `pipenv` by running:
```bash
sudo apt install pipenv
```
2. Create and activate a virtual environment:
```bash
pipenv shell
```
3. Install the dependencies listed in the `Pipfile`:
```bash
pipenv install
```
4. The virtual environment will be created under:
```bash
~/.local/share/virtualenvs/...
```
### Deployment ### Deployment
To deploy the backend docker container, we use kubernetes. Modifications to the backend are automatically pushed to a two-stage environment through the CI pipeline. See [deployment/README](deployment/README.md] for further information. To deploy the backend docker container, we use kubernetes. Modifications to the backend are automatically pushed to a two-stage environment through the CI pipeline. See [deployment/README](deployment/README.md] for further information.

View File

@@ -16,7 +16,7 @@ OSM_CACHE_DIR = Path(cache_dir_string)
import logging import logging
# if we are in a debug session, set verbose and rich logging # if we are in a debug session, set verbose and rich logging
if os.getenv('DEBUG', "false") == "true": if os.getenv('DEBUG', False):
from rich.logging import RichHandler from rich.logging import RichHandler
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,

View File

@@ -63,7 +63,7 @@ def new_trip(preferences: Preferences, start: tuple[float, float], end: tuple[fl
refined_tour = refiner.refine_optimization(landmarks, base_tour, preferences.max_time_minute, preferences.detour_tolerance_minute) refined_tour = refiner.refine_optimization(landmarks, base_tour, preferences.max_time_minute, preferences.detour_tolerance_minute)
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. Ca
trip = Trip.from_linked_landmarks(linked_tour, cache_client) trip = Trip.from_linked_landmarks(linked_tour, cache_client)
return trip return trip

View File

@@ -1,6 +1,3 @@
# Tags were picked mostly arbitrarily, based on the OSM wiki and the OSM tags page.
# See https://taginfo.openstreetmap.org for more inspiration.
nature: nature:
leisure: park leisure: park
geological: '' geological: ''
@@ -14,24 +11,7 @@ nature:
- alpine_hut - alpine_hut
- viewpoint - viewpoint
- zoo - zoo
- resort waterway: waterfall
- picnic_site
water:
- pond
- lake
- river
- basin
- stream
- lagoon
- rapids
waterway:
- waterfall
- river
- canal
- dam
- dock
- boatyard
shopping: shopping:
shop: shop:
@@ -43,47 +23,10 @@ sightseeing:
- museum - museum
- attraction - attraction
- gallery - gallery
- artwork
- aquarium
historic: '' historic: ''
amenity: amenity:
- planetarium - planetarium
- place_of_worship - place_of_worship
- fountain - fountain
- townhall
water: water:
- reflecting_pool - reflecting_pool
bridge:
- aqueduct
- viaduct
- boardwalk
- cantilever
- abandoned
building:
- church
- chapel
- mosque
- synagogue
- ruins
- temple
- government
- cathedral
- castle
- museum
# to be used later on
restauration:
shop:
- coffee
- bakery
- restaurant
- pastry
amenity:
- restaurant
- cafe
- ice_cream
- food_court
- biergarten

View File

@@ -1,6 +1,6 @@
city_bbox_side: 7500 #m city_bbox_side: 7500 #m
radius_close_to: 50 radius_close_to: 50
church_coeff: 0.5 church_coeff: 0.75
nature_coeff: 1.25 nature_coeff: 1.25
overall_coeff: 10 overall_coeff: 10
tag_exponent: 1.15 tag_exponent: 1.15

View File

@@ -21,8 +21,8 @@ if constants.MEMCACHED_HOST_PATH is None:
else: else:
client = Client( client = Client(
constants.MEMCACHED_HOST_PATH, constants.MEMCACHED_HOST_PATH,
timeout = 1, timeout=1,
allow_unicode_keys = True, allow_unicode_keys=True,
encoding = 'utf-8', encoding='utf-8',
serde = serde.pickle_serde serde=serde.pickle_serde
) )

View File

@@ -14,22 +14,26 @@ class Landmark(BaseModel) :
osm_id : int osm_id : int
attractiveness : int attractiveness : int
n_tags : int n_tags : int
image_url : Optional[str] = None image_url : Optional[str] = None # TODO future
website_url : Optional[str] = None website_url : Optional[str] = None
wikipedia_url : Optional[str] = None
description : Optional[str] = None # TODO future description : Optional[str] = None # TODO future
duration : Optional[int] = 0 duration : Optional[int] = 0 # TODO future
name_en : Optional[str] = None name_en : Optional[str] = None
# Unique ID of a given landmark # Unique ID of a given landmark
uuid: str = Field(default_factory=uuid4) uuid: str = Field(default_factory=uuid4) # TODO implement this ASAP
# Additional properties depending on specific tour # Additional properties depending on specific tour
must_do : Optional[bool] = False must_do : Optional[bool] = False
must_avoid : Optional[bool] = False must_avoid : Optional[bool] = False
is_secondary : Optional[bool] = False # TODO future is_secondary : Optional[bool] = False # TODO future
time_to_reach_next : Optional[int] = 0 time_to_reach_next : Optional[int] = 0 # TODO fix this in existing code
next_uuid : Optional[str] = None next_uuid : Optional[str] = None # TODO implement this ASAP
def __hash__(self) -> int:
return self.uuid.int
def __str__(self) -> str: def __str__(self) -> str:
time_to_next_str = f", time_to_next={self.time_to_reach_next}" if self.time_to_reach_next else "" time_to_next_str = f", time_to_next={self.time_to_reach_next}" if self.time_to_reach_next else ""
@@ -38,15 +42,3 @@ class Landmark(BaseModel) :
if self.type in ["start", "finish", "nature", "shopping"] : type_str += '\t ' if self.type in ["start", "finish", "nature", "shopping"] : type_str += '\t '
return f'Landmark{type_str}: [{self.name} @{self.location}, score={self.attractiveness}{time_to_next_str}{is_secondary_str}]' return f'Landmark{type_str}: [{self.name} @{self.location}, score={self.attractiveness}{time_to_next_str}{is_secondary_str}]'
def distance(self, value: 'Landmark') -> float:
return (self.location[0] - value.location[0])**2 + (self.location[1] - value.location[1])**2
def __hash__(self) -> int:
return hash(self.name)
def __eq__(self, value: 'Landmark') -> bool:
# eq and hash must be consistent
# in particular, if two objects are equal, their hash must be equal
# uuid and osm_id are just shortcuts to avoid comparing all the properties
# if they are equal, we know that the name is also equal and in turn the hash is equal
return self.uuid == value.uuid or self.osm_id == value.osm_id or (self.name == value.name and self.distance(value) < 0.001)

View File

@@ -22,8 +22,7 @@ class Trip(BaseModel):
# Store the trip in the cache # Store the trip in the cache
cache_client.set(f"trip_{trip.uuid}", trip) cache_client.set(f"trip_{trip.uuid}", trip)
# make sure to await the result (noreply=False). Otherwise the cache might not be inplace when the trip is actually requested cache_client.set_many({f"landmark_{landmark.uuid}": landmark for landmark in landmarks}, expire=3600)
cache_client.set_many({f"landmark_{landmark.uuid}": landmark for landmark in landmarks}, expire=3600, noreply=False)
# is equivalent to: # is equivalent to:
# for landmark in landmarks: # for landmark in landmarks:
# cache_client.set(f"landmark_{landmark.uuid}", landmark, expire=3600) # cache_client.set(f"landmark_{landmark.uuid}", landmark, expire=3600)

View File

@@ -23,7 +23,7 @@ def test(start_coords: tuple[float, float], finish_coords: tuple[float, float] =
sightseeing=Preference(type='sightseeing', score = 5), sightseeing=Preference(type='sightseeing', score = 5),
nature=Preference(type='nature', score = 5), nature=Preference(type='nature', score = 5),
shopping=Preference(type='shopping', score = 5), shopping=Preference(type='shopping', score = 5),
max_time_minute=15, max_time_minute=100,
detour_tolerance_minute=0 detour_tolerance_minute=0
) )
@@ -74,7 +74,6 @@ def test(start_coords: tuple[float, float], finish_coords: tuple[float, float] =
# test(tuple((48.8344400, 2.3220540))) # Café Chez César # test(tuple((48.8344400, 2.3220540))) # Café Chez César
# test(tuple((48.8375946, 2.2949904))) # Point random # test(tuple((48.8375946, 2.2949904))) # Point random
# test(tuple((47.377859, 8.540585))) # Zurich HB # test(tuple((47.377859, 8.540585))) # Zurich HB
# test(tuple((45.758217, 4.831814))) # Lyon Bellecour # test(tuple((45.758217, 4.831814))) # Lyon Bellecour
# test(tuple((48.5848435, 7.7332974))) # Strasbourg Gare test(tuple((48.5848435, 7.7332974))) # Strasbourg Gare
# test(tuple((48.2067858, 16.3692340))) # Vienne # test(tuple((48.2067858, 16.3692340))) # Vienne
test(tuple((48.084588, 7.280405))) # Turckheim

View File

@@ -1,5 +1,5 @@
import yaml import yaml
from math import sin, cos, sqrt, atan2, radians from geopy.distance import geodesic
import constants import constants
@@ -8,7 +8,6 @@ with constants.OPTIMIZER_PARAMETERS_PATH.open('r') as f:
DETOUR_FACTOR = parameters['detour_factor'] DETOUR_FACTOR = parameters['detour_factor']
AVERAGE_WALKING_SPEED = parameters['average_walking_speed'] AVERAGE_WALKING_SPEED = parameters['average_walking_speed']
EARTH_RADIUS_KM = 6373
def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int: def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
""" """
@@ -17,34 +16,24 @@ def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
Args: Args:
p1 (Tuple[float, float]): Coordinates of the starting location. p1 (Tuple[float, float]): Coordinates of the starting location.
p2 (Tuple[float, float]): Coordinates of the destination. p2 (Tuple[float, float]): Coordinates of the destination.
detour (float): Detour factor affecting the distance.
speed (float): Walking speed in kilometers per hour.
Returns: Returns:
int: Time to travel from p1 to p2 in minutes. int: Time to travel from p1 to p2 in minutes.
""" """
if p1 == p2: # Compute the straight-line distance in km
if p1 == p2 :
return 0 return 0
else: else:
# Compute the distance in km along the surface of the Earth dist = geodesic(p1, p2).kilometers
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
dlon = lon2 - lon1 # Consider the detour factor for average cityto deterline walking distance (in km)
dlat = lat2 - lat1 walk_dist = dist*DETOUR_FACTOR
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
distance = EARTH_RADIUS_KM * c
# Consider the detour factor for average an average city
walk_distance = distance * DETOUR_FACTOR
# Time to walk this distance (in minutes) # Time to walk this distance (in minutes)
walk_time = walk_distance / AVERAGE_WALKING_SPEED * 60 walk_time = walk_dist/AVERAGE_WALKING_SPEED*60
return round(walk_time) return round(walk_time)

View File

@@ -1,17 +1,20 @@
import math import math as m
import yaml import yaml
import logging import logging
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from pywikibot import ItemPage, Site
from pywikibot import config
config.put_throttle = 0
config.maxlag = 0
from structs.preferences import Preferences from structs.preferences import Preferences, Preference
from structs.landmark import Landmark from structs.landmark import Landmark
from .take_most_important import take_most_important from .take_most_important import take_most_important
import constants import constants
# silence the overpass logger
logging.getLogger('OSMPythonTools').setLevel(level=logging.CRITICAL)
class LandmarkManager: class LandmarkManager:
@@ -66,44 +69,87 @@ class LandmarkManager:
preferences (Preferences): The user's preference settings that influence the landmark selection. preferences (Preferences): The user's preference settings that influence the landmark selection.
Returns: Returns:
tuple[list[Landmark], list[Landmark]]: tuple[list[Landmark], list[Landmark]]:
- A list of all existing landmarks. - A list of all existing landmarks.
- A list of the most important landmarks based on the user's preferences. - A list of the most important landmarks based on the user's preferences.
""" """
max_walk_dist = (preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor max_walk_dist = (preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor
reachable_bbox_side = min(max_walk_dist, self.max_bbox_side) reachable_bbox_side = min(max_walk_dist, self.max_bbox_side)
# use set to avoid duplicates, this requires some __methods__ to be set in Landmark L = []
all_landmarks = set()
bbox = self.create_bbox(center_coordinates, reachable_bbox_side) bbox = self.create_bbox(center_coordinates, reachable_bbox_side)
# list for sightseeing # list for sightseeing
if preferences.sightseeing.score != 0: if preferences.sightseeing.score != 0:
score_function = lambda score: score * 10 * preferences.sightseeing.score / 5 score_function = lambda score: int(score*10*preferences.sightseeing.score/5) # self.count_elements_close_to(loc) +
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, score_function) L1 = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, score_function)
all_landmarks.update(current_landmarks) L += L1
# list for nature # list for nature
if preferences.nature.score != 0: if preferences.nature.score != 0:
score_function = lambda score: score * 10 * self.nature_coeff * preferences.nature.score / 5 score_function = lambda score: int(score*10*self.nature_coeff*preferences.nature.score/5) # self.count_elements_close_to(loc) +
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, score_function) L2 = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, score_function)
all_landmarks.update(current_landmarks) L += L2
# list for shopping # list for shopping
if preferences.shopping.score != 0: if preferences.shopping.score != 0:
score_function = lambda score: score * 10 * preferences.shopping.score / 5 score_function = lambda score: int(score*10*preferences.shopping.score/5) # self.count_elements_close_to(loc) +
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, score_function) L3 = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, score_function)
# set time for all shopping activites : L += L3
for landmark in current_landmarks : landmark.duration = 45
all_landmarks.update(current_landmarks)
landmarks_constrained = take_most_important(all_landmarks, self.N_important) L = self.remove_duplicates(L)
self.logger.info(f'Generated {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.') # self.correct_score(L, preferences)
return all_landmarks, landmarks_constrained L_constrained = take_most_important(L, self.N_important)
self.logger.info(f'Generated {len(L)} landmarks around {center_coordinates}, and constrained to {len(L_constrained)} most important ones.')
return L, L_constrained
def remove_duplicates(self, landmarks: list[Landmark]) -> list[Landmark]:
"""
Removes duplicate landmarks based on their names from the given list. Only retains the landmark with highest score
Parameters:
landmarks (list[Landmark]): A list of Landmark objects.
Returns:
list[Landmark]: A list of unique Landmark objects based on their names.
"""
L_clean = []
names = []
for landmark in landmarks:
if landmark.name in names:
continue
else:
names.append(landmark.name)
L_clean.append(landmark)
return L_clean
def correct_score(self, landmarks: list[Landmark], preferences: Preferences) -> None:
"""
Adjust the attractiveness score of each landmark in the list based on user preferences.
This method updates the attractiveness of each landmark by scaling it according to the user's preference score.
The score adjustment is computed using a simple linear transformation based on the preference score.
Args:
landmarks (list[Landmark]): A list of landmarks whose scores need to be corrected.
preferences (Preferences): The user's preference settings that influence the attractiveness score adjustment.
"""
score_dict = {
preferences.sightseeing.type: preferences.sightseeing.score,
preferences.nature.type: preferences.nature.score,
preferences.shopping.type: preferences.shopping.score
}
for landmark in landmarks:
landmark.attractiveness = int(landmark.attractiveness * score_dict[landmark.type] / 5)
def count_elements_close_to(self, coordinates: tuple[float, float]) -> int: def count_elements_close_to(self, coordinates: tuple[float, float]) -> int:
@@ -126,7 +172,7 @@ class LandmarkManager:
radius = self.radius_close_to radius = self.radius_close_to
alpha = (180 * radius) / (6371000 * math.pi) alpha = (180*radius) / (6371000*m.pi)
bbox = {'latLower':lat-alpha,'lonLower':lon-alpha,'latHigher':lat+alpha,'lonHigher': lon+alpha} bbox = {'latLower':lat-alpha,'lonLower':lon-alpha,'latHigher':lat+alpha,'lonHigher': lon+alpha}
# Build the query to find elements within the radius # Build the query to find elements within the radius
@@ -170,7 +216,7 @@ class LandmarkManager:
# Convert distance to degrees # Convert distance to degrees
lat_diff = half_side_length_km / 111 # 1 degree latitude is approximately 111 km lat_diff = half_side_length_km / 111 # 1 degree latitude is approximately 111 km
lon_diff = half_side_length_km / (111 * math.cos(math.radians(lat))) # Adjust for longitude based on latitude lon_diff = half_side_length_km / (111 * m.cos(m.radians(lat))) # Adjust for longitude based on latitude
# Calculate bbox # Calculate bbox
min_lat = lat - lat_diff min_lat = lat - lat_diff
@@ -209,32 +255,32 @@ class LandmarkManager:
query = overpassQueryBuilder( query = overpassQueryBuilder(
bbox = bbox, bbox = bbox,
elementType = ['way', 'relation'], elementType = ['way', 'relation'],
# selector can in principle be a list already,
# but it generates the intersection of the queries
# we want the union
selector = sel, selector = sel,
conditions = ['count_tags()>5'], # conditions = [],
includeCenter = True, includeCenter = True,
out = 'body' out = 'body'
) )
self.logger.debug(f"Query: {query}")
try: try:
result = self.overpass.query(query) result = self.overpass.query(query)
except Exception as e: except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}") self.logger.error(f"Error fetching landmarks: {e}")
continue return
for elem in result.elements(): for elem in result.elements():
name = elem.tag('name') name = elem.tag('name') # Add name
location = (elem.centerLat(), elem.centerLon()) location = (elem.centerLat(), elem.centerLon()) # Add coordinates (lat, lon)
# TODO: exclude these from the get go # TODO: exclude these from the get go
# skip if unprecise location # skip if unprecise location
if name is None or location[0] is None: if name is None or location[0] is None:
continue continue
# skip if unused
# if 'disused:leisure' in elem.tags().keys():
# continue
# skip if part of another building # skip if part of another building
if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes': if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes':
continue continue
@@ -245,32 +291,36 @@ class LandmarkManager:
n_tags = len(elem.tags().keys()) # Add number of tags n_tags = len(elem.tags().keys()) # Add number of tags
score = n_tags**self.tag_exponent # Add score score = n_tags**self.tag_exponent # Add score
website_url = None website_url = None
wikpedia_url = None
image_url = None image_url = None
name_en = None name_en = None
# Adjust scoring # remove specific tags
skip = False skip = False
for tag in elem.tags().keys(): for tag in elem.tags().keys():
if "pay" in tag: if "pay" in tag:
# payment options are misleading and should not count for the scoring. score += self.pay_bonus # discard payment options for tags
score += self.pay_bonus
if "disused" in tag: if "disused" in tag:
# skip disused amenities skip = True # skip disused amenities
skip = True
break break
if "wiki" in tag: if "wiki" in tag:
# wikipedia entries count more score += self.wikipedia_bonus # wikipedia entries count more
score += self.wikipedia_bonus
# if tag == "wikidata":
# Q = elem.tag('wikidata')
# site = Site("wikidata", "wikidata")
# item = ItemPage(site, Q)
# item.get()
# n_languages = len(item.labels)
# n_tags += n_languages/10
if "viewpoint" in tag: if "viewpoint" in tag:
# viewpoints must count more
score += self.viewpoint_bonus score += self.viewpoint_bonus
duration = 10 duration = 10
if "image" in tag: if "image" in tag:
# images must count more
score += self.image_bonus score += self.image_bonus
if elem_type != "nature": if elem_type != "nature":
@@ -286,42 +336,46 @@ class LandmarkManager:
skip = True skip = True
break break
# Extract image, website and english name # Get additional information
if tag in ['website', 'contact:website']: # if tag == 'wikipedia' :
# wikpedia_url = elem.tag('wikipedia')
if tag in ['website', 'contact:website'] :
website_url = elem.tag(tag) website_url = elem.tag(tag)
if tag == 'image': if tag == 'image' :
image_url = elem.tag('image') image_url = elem.tag('image')
if tag =='name:en': if tag =='name:en' :
name_en = elem.tag('name:en') name_en = elem.tag('name:en')
if skip: if skip:
continue continue
score = score_function(score) score = score_function(score)
if "place_of_worship" in elem.tags().values(): if "place_of_worship" in elem.tags().values() :
score = score * self.church_coeff score = int(score*self.church_coeff)
duration = 10 duration = 15
elif "museum" in elem.tags().values() or "aquarium" in elem.tags().values() or "planetarium" in elem.tags().values(): elif "museum" in elem.tags().values() :
score = int(score*self.church_coeff)
duration = 60 duration = 60
else: else :
duration = 5 duration = 5
# finally create our own landmark object # Generate the landmark and append it to the list
landmark = Landmark( landmark = Landmark(
name = name, name=name,
type = elem_type, type=elem_type,
location = location, location=location,
osm_type = osm_type, osm_type=osm_type,
osm_id = osm_id, osm_id=osm_id,
attractiveness = int(score), attractiveness=score,
must_do = False, must_do=False,
n_tags = int(n_tags), n_tags=int(n_tags),
duration = int(duration), duration = duration,
name_en = name_en, name_en=name_en,
image_url = image_url, image_url=image_url,
website_url = website_url # wikipedia_url=wikpedia_url,
website_url=website_url
) )
return_list.append(landmark) return_list.append(landmark)
@@ -345,7 +399,7 @@ def dict_to_selector_list(d: dict) -> list:
for key, value in d.items(): for key, value in d.items():
if type(value) == list: if type(value) == list:
val = '|'.join(value) val = '|'.join(value)
return_list.append(f'{key}~"^({val})$"') return_list.append(f'{key}~"{val}"')
elif type(value) == str and len(value) == 0: elif type(value) == str and len(value) == 0:
return_list.append(f'{key}') return_list.append(f'{key}')
else: else:

View File

@@ -3,6 +3,7 @@ import numpy as np
from scipy.optimize import linprog from scipy.optimize import linprog
from collections import defaultdict, deque from collections import defaultdict, deque
from geopy.distance import geodesic
from structs.landmark import Landmark from structs.landmark import Landmark
from .get_time_separation import get_time from .get_time_separation import get_time
@@ -487,7 +488,7 @@ class Optimizer:
# Raise error if no solution is found # Raise error if no solution is found
if not res.success : if not res.success :
raise ArithmeticError("No solution could be found, the problem is overconstrained. Try with a longer trip (>30 minutes).") raise ArithmeticError("No solution could be found, the problem is overconstrained. Please adapt your must_dos")
# 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
order, circles = self.is_connected(res.x) order, circles = self.is_connected(res.x)

View File

@@ -2,7 +2,6 @@ import yaml, logging
from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
from math import pi from math import pi
from typing import List
from structs.landmark import Landmark from structs.landmark import Landmark
from . import take_most_important, get_time_separation from . import take_most_important, get_time_separation
@@ -135,21 +134,6 @@ class Refiner :
return tour return tour
def integrate_landmarks(self, sub_list: List[Landmark], main_list: List[Landmark]) :
"""
Inserts 'sub_list' of Landmarks inside the 'main_list' by leaving the ends untouched.
Args:
sub_list : the list of Landmarks to be inserted inside of the 'main_list'.
main_list : the original list with start and finish.
Returns:
the full list.
"""
sub_list.append(main_list[-1]) # add finish back
return main_list[:-1] + sub_list # create full set of possible landmarks
def find_shortest_path_through_all_landmarks(self, landmarks: list[Landmark]) -> tuple[list[Landmark], Polygon]: def find_shortest_path_through_all_landmarks(self, landmarks: list[Landmark]) -> tuple[list[Landmark], Polygon]:
""" """
@@ -269,11 +253,6 @@ class Refiner :
except : except :
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 :
Exception has occurred: AttributeError
'LineString' object has no attribute 'exterior'
"""
# reverse the xs and ys # reverse the xs and ys
@@ -336,30 +315,26 @@ class Refiner :
self.logger.info(f"Using {len(minor_landmarks)} minor landmarks around the predicted path") self.logger.info(f"Using {len(minor_landmarks)} minor landmarks around the predicted path")
# Full set of visitable landmarks. # full set of visitable landmarks
full_set = self.integrate_landmarks(minor_landmarks, base_tour) # could probably be optimized with less overhead full_set = base_tour[:-1] + minor_landmarks # create full set of possible landmarks (without finish)
full_set.append(base_tour[-1]) # add finish back
# Generate a new tour with the optimizer. # get a new tour
new_tour = self.optimizer.solve_optimization( new_tour = self.optimizer.solve_optimization(
max_time = max_time + detour, max_time = max_time + detour,
landmarks = full_set, landmarks = full_set,
max_landmarks = self.max_landmarks_refiner max_landmarks = self.max_landmarks_refiner
) )
# 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("No solution found for the refined tour. Returning the initial tour.")
new_tour = base_tour new_tour = base_tour
# If only one landmark, return it.
if len(new_tour) < 4 :
return new_tour
# Find shortest path using the nearest neighbor heuristic. # Find shortest path using the nearest neighbor heuristic
better_tour, better_poly = self.find_shortest_path_through_all_landmarks(new_tour) better_tour, better_poly = self.find_shortest_path_through_all_landmarks(new_tour)
# 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.
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 :
better_tour = self.fix_using_polygon(better_tour) better_tour = self.fix_using_polygon(better_tour)

View File

@@ -1,16 +1,38 @@
from structs.landmark import Landmark from structs.landmark import Landmark
def take_most_important(landmarks: list[Landmark], n_important) -> list[Landmark]: def take_most_important(landmarks: list[Landmark], N_important) -> list[Landmark] :
""" L = len(landmarks)
Given a list of landmarks, return the n_important most important landmarks L_copy = []
Parameters: L_clean = []
landmarks: list[Landmark] - list of landmarks scores = [0]*len(landmarks)
n_important: int - number of most important landmarks to return names = []
Returns: name_id = {}
list[Landmark] - list of the n_important most important landmarks
"""
# Sort landmarks by attractiveness (descending) for i, elem in enumerate(landmarks) :
sorted_landmarks = sorted(landmarks, key=lambda x: x.attractiveness, reverse=True) if elem.name not in names :
names.append(elem.name)
name_id[elem.name] = [i]
L_copy.append(elem)
else :
name_id[elem.name] += [i]
scores = []
for j in name_id[elem.name] :
scores.append(L[j].attractiveness)
best_id = max(range(len(scores)), key=scores.__getitem__)
t = name_id[elem.name][best_id]
if t == i :
for old in L_copy :
if old.name == elem.name :
old.attractiveness = L[t].attractiveness
return sorted_landmarks[:n_important] scores = [0]*len(L_copy)
for i, elem in enumerate(L_copy) :
scores[i] = elem.attractiveness
res = sorted(range(len(scores)), key = lambda sub: scores[sub])[-(N_important-L):]
for i, elem in enumerate(L_copy) :
if i in res :
L_clean.append(elem)
return L_clean

View File

@@ -1,111 +0,0 @@
import numpy as np
def euclidean_distance(p1, p2):
print(p1, p2)
return np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)
def maximize_score(places, max_distance, fixed_entry, top_k=3):
"""
Maximizes the total score of visited places while staying below the maximum distance.
Parameters:
places (list of tuples): Each tuple contains (score, (x, y), location).
max_distance (float): The maximum distance that can be traveled.
fixed_entry (tuple): The place that needs to be visited independently of its score.
top_k (int): Number of top candidates to consider in each iteration.
Returns:
list of tuples: The visited places.
float: The total score of the visited places.
"""
# Initialize total distance and score
total_distance = 0
total_score = 0
visited_places = []
# Add the fixed entry to the visited list
score, (x, y), _ = fixed_entry
visited_places.append(fixed_entry)
total_score += score
# Remove the fixed entry from the list of places
remaining_places = [place for place in places if place != fixed_entry]
# Sort remaining places by score-to-distance ratio
remaining_places.sort(key=lambda p: p[0] / euclidean_distance((x, y), (p[1][0], p[1][1])), reverse=True)
# Add places to the visited list if they don't exceed the maximum distance
current_location = (x, y)
while remaining_places and total_distance < max_distance:
# Consider top_k candidates
candidates = remaining_places[:top_k]
best_candidate = None
best_score_increase = -np.inf
for candidate in candidates:
score, (cx, cy), location = candidate
distance = euclidean_distance(current_location, (cx, cy))
if total_distance + distance <= max_distance:
score_increase = score / distance
if score_increase > best_score_increase:
best_score_increase = score_increase
best_candidate = candidate
if best_candidate:
visited_places.append(best_candidate)
total_distance += euclidean_distance(current_location, best_candidate[1])
total_score += best_candidate[0]
current_location = best_candidate[1]
remaining_places.remove(best_candidate)
else:
break
return visited_places, total_score
# Example usage
places = [
(10, (0, 0), 'A'),
(8, (4, 2), 'B'),
(15, (6, 4), 'C'),
(7, (5, 6), 'D'),
(12, (1, 8), 'E'),
(14, (34, 10), 'F'),
(15, (65, 12), 'G'),
(12, (3, 14), 'H'),
(12, (15, 1), 'I'),
(7, (17, 4), 'J'),
(12, (3, 3), 'K'),
(4, (21, 22), 'L'),
(12, (23, 24), 'M'),
(4, (25, 26), 'N'),
(2, (27, 28), 'O'),
]
fixed_entry = (10, (0, 0), 'A')
max_distance = 50
visited_places, total_score = maximize_score(places, max_distance, fixed_entry)
print("Visited Places:", visited_places)
print("Total Score:", total_score)
import matplotlib.pyplot as plt
# Plot the route
def plot_route(visited_places):
x_coords = [place[1][0] for place in visited_places]
y_coords = [place[1][1] for place in visited_places]
labels = [place[2] for place in visited_places]
plt.figure(figsize=(10, 6))
plt.plot(x_coords, y_coords, marker='o', linestyle='-', color='b')
for i, label in enumerate(labels):
plt.text(x_coords[i], y_coords[i], label, fontsize=12, ha='right')
plt.title('Route of Visited Places')
plt.xlabel('X Coordinate')
plt.ylabel('Y Coordinate')
plt.grid(True)
plt.savefig('route.png')
plot_route(visited_places)

View File

@@ -1,7 +1,7 @@
on: on:
push: push:
tags: branches:
- 'v*' - main
jobs: jobs:
build: build:
@@ -24,26 +24,19 @@ jobs:
- name: Setup android SDK - name: Setup android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.22.0
cache: true
- name: Infer version number from git tag - name: Infer version number from git tag
id: version id: version
env: env:
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
run: run:
# remove the 'v' prefix from the tag name # remove the 'v' prefix from the tag name
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV echo "VERSION_NAME=${REF_NAME//v}" >> $GITHUB_ENV
- name: Load secrets from github - name: Load secrets from github
run: | run: |
echo "${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}" | base64 -d > secrets.properties echo "${{ secrets.ANDROID_SECRET_PROPERTIES }}" > secrets.properties
echo "${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}" | base64 -d > google-key.json echo "${{ secrets.ANDROID_KEYSTORE }}" > release.keystore
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore echo "${{ secrets.ANDROID_GOOGLE_PLAY_JSON }}" > google-key.json
working-directory: android working-directory: android
- name: Install fastlane - name: Install fastlane
@@ -53,6 +46,4 @@ jobs:
- name: Run fastlane lane - name: Run fastlane lane
run: bundle exec fastlane deploy_testing run: bundle exec fastlane deploy_testing
working-directory: android working-directory: android
env: # the environment variable VERSION_NAME is implicitly available
BUILD_NUMBER: ${{ github.run_number }}
# BUILD_NAME is implicitly available

View File

@@ -30,19 +30,14 @@ if (flutterVersionName == null) {
def secretPropertiesFile = rootProject.file('secrets.properties') def secretPropertiesFile = rootProject.file('secrets.properties')
def fallbackPropertiesFile = rootProject.file('fallback.properties')
def secretProperties = new Properties() def secretProperties = new Properties()
if (secretPropertiesFile.exists()) { if (secretPropertiesFile.exists()) {
secretPropertiesFile.withReader('UTF-8') { reader -> secretPropertiesFile.withReader('UTF-8') { reader ->
secretProperties.load(reader) secretProperties.load(reader)
} }
} else if (fallbackPropertiesFile.exists()) {
fallbackPropertiesFile.withReader('UTF-8') { reader ->
secretProperties.load(reader)
}
} else { } else {
throw new GradleException("Secrets file (secrets.properties, fallback.properties) not found") throw new GradleException("Secrets file secrets.properties not found")
} }

View File

@@ -1,3 +1 @@
# This file mirrors the state of secrets.properties as a reference for the developer.
# And as a fallback for build.gradle
MAPS_API_KEY=Key MAPS_API_KEY=Key

View File

@@ -5,28 +5,22 @@ default_platform(:android)
platform :android do platform :android do
desc "Deploy a new version to closed testing" desc "Deploy a new version as a preview version"
lane :deploy_testing do lane :deploy_testing do
build_name = ENV["BUILD_NAME"] version_name = ENV["VERSION_NAME"]
build_number = ENV["BUILD_NUMBER"]
sh( sh(
"flutter", "flutter",
"build", "build",
"appbundle", "appbundle",
"--release", "--release",
"--build-name=#{build_name}", "--build-name=#{version_name}",
"--build-number=#{build_number}",
) )
upload_to_play_store( upload_to_play_store(
track: 'alpha', track: 'alpha',
skip_upload_apk: true, skip_upload_apk: true,
skip_upload_changelogs: true, skip_upload_changelogs: true,
aab: "../build/app/outputs/bundle/release/app-release.aab",
# this is the default output of flutter build ... --release
# in particular this the build folder lies in the flutter root folder
# this is the parent folder for the android folder
) )
end end
@@ -34,7 +28,6 @@ platform :android do
lane :deploy_release do lane :deploy_release do
gradle( gradle(
task: "clean assembleRelease", task: "clean assembleRelease",
# todo update to a flutter call
properties: { properties: {
# loaded from environment # loaded from environment
"android.injected.version.name" => ENV["VERSION_NAME"], "android.injected.version.name" => ENV["VERSION_NAME"],
@@ -44,10 +37,6 @@ platform :android do
track: "production", track: "production",
skip_upload_apk: true, skip_upload_apk: true,
skip_upload_changelogs: true, skip_upload_changelogs: true,
aab: "../build/app/outputs/bundle/release/app-release.aab",
# this is the default output of flutter build ... --release
# in particular this the build folder lies in the flutter root folder
# this is the parent folder for the android folder
) )
end end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
const String APP_NAME = 'AnyWay'; const String APP_NAME = 'AnyWay';
String API_URL_BASE = 'https://anyway.anydev.info'; String API_URL_BASE = 'https://anyway.anydev.info';
String API_URL_DEBUG = 'https://anyway.anydev.info';
String PRIVACY_URL = 'https://anydev.info/privacy'; String PRIVACY_URL = 'https://anydev.info/privacy';
const String MAP_ID = '41c21ac9b81dbfd8'; const String MAP_ID = '41c21ac9b81dbfd8';

View File

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

View File

@@ -1,12 +1,10 @@
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});
@@ -16,7 +14,7 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: APP_NAME, title: APP_NAME,
home: getFirstPage(), home: BasePage(mainScreen: "map"),
theme: APP_THEME, theme: APP_THEME,
scaffoldMessengerKey: rootScaffoldMessengerKey scaffoldMessengerKey: rootScaffoldMessengerKey
); );

View File

@@ -1,37 +0,0 @@
import 'package:anyway/structs/trip.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
class CurrentTripErrorMessage extends StatefulWidget {
final Trip trip;
const CurrentTripErrorMessage({
super.key,
required this.trip,
});
@override
State<CurrentTripErrorMessage> createState() => _CurrentTripErrorMessageState();
}
class _CurrentTripErrorMessageState extends State<CurrentTripErrorMessage> {
@override
Widget build(BuildContext context) => Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 50,
),
const Padding(
padding: EdgeInsets.only(left: 10),
),
AutoSizeText(
'Error: ${widget.trip.errorDescription}',
maxLines: 3,
),
],
)
);
}

View File

@@ -1,50 +1,111 @@
import 'package:flutter/material.dart'; import 'dart:developer';
import 'package:anyway/constants.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:anyway/pages/current_trip.dart'; import 'package:flutter/material.dart';
import 'package:anyway/structs/trip.dart';
class Greeter extends StatefulWidget {
class CurrentTripGreeter extends StatefulWidget {
final Trip trip; final Trip trip;
CurrentTripGreeter({ Greeter({
super.key,
required this.trip, required this.trip,
}); });
@override @override
State<CurrentTripGreeter> createState() => _CurrentTripGreeterState(); State<Greeter> createState() => _GreeterState();
} }
class _CurrentTripGreeterState extends State<CurrentTripGreeter> { class _GreeterState extends State<Greeter> {
@override
Widget build(BuildContext context) => Center( Widget greeterBuilder (BuildContext context, Widget? child) {
child: FutureBuilder( final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 200.0, 70.0));
future: widget.trip.cityName, TextStyle greeterStyle = TextStyle(
builder: (BuildContext context, AsyncSnapshot<String> snapshot) { foreground: Paint()..shader = textGradient,
if (snapshot.hasData) { fontWeight: FontWeight.bold,
return AutoSizeText( fontSize: 26
maxLines: 1, );
'Welcome to ${snapshot.data}!',
style: greeterStyle Widget topGreeter;
);
} else if (snapshot.hasError) { if (widget.trip.uuid != 'pending') {
return AutoSizeText( topGreeter = FutureBuilder(
maxLines: 1, future: widget.trip.cityName,
'Welcome to your trip!', builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
style: greeterStyle if (snapshot.hasData) {
); return AutoSizeText(
} else { maxLines: 1,
return AutoSizeText( 'Welcome to ${snapshot.data}!',
maxLines: 1, style: greeterStyle
'Welcome to ...', );
style: greeterStyle } else if (snapshot.hasError) {
); log('Error while fetching city name');
return AutoSizeText(
maxLines: 1,
'Welcome to your trip!',
style: greeterStyle
);
} else {
return AutoSizeText(
maxLines: 1,
'Welcome to ...',
style: greeterStyle
);
}
} }
} );
) } else {
); // still awaiting the trip
// We can hopefully infer the city name from the cityName future
// Show a linear loader at the bottom and an info message above
topGreeter = Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FutureBuilder(
future: widget.trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) {
return AutoSizeText(
maxLines: 1,
'Generating your trip to ${snapshot.data}...',
style: greeterStyle
);
} else if (snapshot.hasError) {
// the exact error is shown in the central part of the trip overview. No need to show it here
return AutoSizeText(
maxLines: 1,
'Error while loading trip.',
style: greeterStyle
);
}
return AutoSizeText(
maxLines: 1,
'Generating your trip...',
style: greeterStyle
);
}
),
Padding(
padding: EdgeInsets.all(5),
child: const LinearProgressIndicator()
)
]
);
}
return Center(
child: topGreeter,
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trip,
builder: greeterBuilder,
);
}
} }

View File

@@ -5,6 +5,7 @@ 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';
@@ -24,7 +25,30 @@ List<Widget> landmarksList(Trip trip) {
for (Landmark landmark in trip.landmarks) { for (Landmark landmark in trip.landmarks) {
children.add( children.add(
LandmarkCard(landmark, trip), Dismissible(
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,132 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/pages/current_trip.dart';
class CurrentTripLoadingIndicator extends StatefulWidget {
final Trip trip;
const CurrentTripLoadingIndicator({
super.key,
required this.trip,
});
@override
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
}
Widget bottomLoadingIndicator = Container(
height: 20.0, // Increase the height to take up more vertical space
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), // Apply blur effect
child: Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(),)
),
);
Widget loadingText(Trip trip) => FutureBuilder(
future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
Widget greeter;
if (snapshot.hasData) {
greeter = AnimatedGradientText(
text: 'Generating your trip to ${snapshot.data}...',
style: greeterStyle,
);
} else if (snapshot.hasError) {
// the exact error is shown in the central part of the trip overview. No need to show it here
greeter = AnimatedGradientText(
text: 'Error while loading trip.',
style: greeterStyle,
);
} else {
greeter = AnimatedGradientText(
text: 'Generating your trip...',
style: greeterStyle,
);
}
return greeter;
}
);
class AnimatedGradientText extends StatefulWidget {
final String text;
final TextStyle style;
const AnimatedGradientText({
Key? key,
required this.text,
required this.style,
}) : super(key: key);
@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: [Colors.blue, Colors.red, Colors.blue],
stops: [
_controller.value - 1.0,
_controller.value,
_controller.value + 1.0,
],
tileMode: TileMode.mirror,
).createShader(bounds);
},
child: Text(
widget.text,
style: widget.style,
),
);
},
);
}
}
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
Center(child: loadingText(widget.trip)),
Align(
alignment: Alignment.bottomCenter,
child: bottomLoadingIndicator,
)
],
);
}

View File

@@ -1,6 +1,4 @@
import 'package:anyway/constants.dart'; import 'package:anyway/constants.dart';
import 'package:anyway/modules/current_trip_error_message.dart';
import 'package:anyway/modules/current_trip_loading_indicator.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:anyway/structs/trip.dart'; import 'package:anyway/structs/trip.dart';
@@ -30,37 +28,16 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
return ListenableBuilder( return ListenableBuilder(
listenable: widget.trip, listenable: widget.trip,
builder: (context, child) { builder: (context, child) {
if (widget.trip.uuid == 'error') { if (widget.trip.uuid != 'pending' && widget.trip.uuid != 'error') {
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
child: CurrentTripErrorMessage(trip: widget.trip)
),
);
} else if (widget.trip.uuid == 'pending') {
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
child: CurrentTripLoadingIndicator(trip: widget.trip),
),
);
} else {
return ListView( return ListView(
controller: widget.controller, controller: widget.controller,
padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30), padding: const EdgeInsets.only(bottom: 30, left: 5, right: 5),
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
// note that we need to account for the padding above height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10, child: Greeter(trip: widget.trip),
child: CurrentTripGreeter(trip: widget.trip),
), ),
const Padding(padding: EdgeInsets.only(top: 10)), const Padding(padding: EdgeInsets.only(top: 10)),
@@ -73,7 +50,29 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
const Padding(padding: EdgeInsets.only(top: 10)), const Padding(padding: EdgeInsets.only(top: 10)),
Center(child: saveButton(trip: widget.trip)), Center(child: saveButton(widget.trip)),
],
);
} else if(widget.trip.uuid == 'pending') {
return SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
child: Greeter(trip: widget.trip)
);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 50,
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text('Error: ${widget.trip.errorDescription}'),
),
], ],
); );
} }

View File

@@ -3,53 +3,39 @@ 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 { onPressed: () async {
Trip trip; SharedPreferences prefs = await SharedPreferences.getInstance();
saveButton({super.key, required this.trip}); trip.toPrefs(prefs);
rootScaffoldMessengerKey.currentState!.showSnackBar(
@override SnackBar(
State<saveButton> createState() => _saveButtonState(); content: Text('Trip saved'),
} duration: Duration(seconds: 2),
dismissDirection: DismissDirection.horizontal
class _saveButtonState extends State<saveButton> {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
savedTrips.addTrip(widget.trip);
// SharedPreferences prefs = await SharedPreferences.getInstance();
// setState(() => widget.trip.toPrefs(prefs));
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Trip saved'),
duration: Duration(seconds: 2),
dismissDirection: DismissDirection.horizontal
)
);
},
child: SizedBox(
width: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.save,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5),
child: AutoSizeText(
'Save trip',
maxLines: 2,
),
),
),
],
),
) )
); );
} },
} child: SizedBox(
width: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.save,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5),
child: AutoSizeText(
'Save trip',
maxLines: 2,
),
),
),
],
),
)
);

View File

@@ -1,25 +0,0 @@
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,5 +1,3 @@
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';
@@ -8,12 +6,8 @@ import 'package:anyway/structs/landmark.dart';
class LandmarkCard extends StatefulWidget { class LandmarkCard extends StatefulWidget {
final Landmark landmark; final Landmark landmark;
final Trip parentTrip;
LandmarkCard( LandmarkCard(this.landmark);
this.landmark,
this.parentTrip,
);
@override @override
_LandmarkCardState createState() => _LandmarkCardState(); _LandmarkCardState createState() => _LandmarkCardState();
@@ -23,149 +17,110 @@ class LandmarkCard extends StatefulWidget {
class _LandmarkCardState extends State<LandmarkCard> { class _LandmarkCardState extends State<LandmarkCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.landmark.type == typeStart || widget.landmark.type == typeFinish) { ThemeData theme = Theme.of(context);
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,
// if the image is available, display it on the left side of the card, otherwise only display the text child: Row(
child: widget.landmark.imageURL != null ? splitLayout() : textLayout(), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
); Container( // the image on the left
} // inherit the height of the parent container
height: double.infinity,
Widget splitLayout() { // force a fixed width
// If an image is available, display it on the left side of the card width: 160,
return Row( child: CachedNetworkImage(
crossAxisAlignment: CrossAxisAlignment.start, imageUrl: widget.landmark.imageURL ?? '',
children: [ placeholder: (context, url) => Center(child: CircularProgressIndicator()),
Container( errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined),
// the image on the left // TODO: make this a switch statement to load a placeholder if null
width: 160, // cover the whole container meaning the image will be cropped
height: 160, fit: BoxFit.cover,
child: CachedNetworkImage(
imageUrl: widget.landmark.imageURL ?? '',
placeholder: (context, url) => Center(child: CircularProgressIndicator()),
errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined),
fit: BoxFit.cover,
),
),
Flexible(
child: textLayout(),
),
],
);
}
Widget textLayout() {
return Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Row(
children: [
Flexible(
child: Text(
widget.landmark.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
),
)
],
),
if (widget.landmark.nameEN != null)
Row(
children: [
Flexible(
child: Text(
widget.landmark.nameEN!,
style: const TextStyle(
fontSize: 16,
),
maxLines: 1,
),
)
],
),
Padding(padding: EdgeInsets.only(top: 10)),
Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
// allows the buttons to be scrolled
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 10,
// show the type, the website, and the wikipedia link as buttons/labels in a row
children: [
TextButton.icon(
onPressed: () {},
icon: widget.landmark.type.icon,
label: Text(widget.landmark.type.name),
),
if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0)
TextButton.icon(
onPressed: () {},
icon: Icon(Icons.hourglass_bottom),
label: Text('${widget.landmark.duration!.inMinutes} minutes'),
),
if (widget.landmark.websiteURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the website link
await launchUrl(Uri.parse(widget.landmark.websiteURL!));
},
icon: Icon(Icons.link),
label: Text('Website'),
),
PopupMenuButton(
icon: Icon(Icons.settings),
style: TextButtonTheme.of(context).style,
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
leading: Icon(Icons.delete),
title: Text('Delete'),
onTap: () async {
widget.parentTrip.removeLandmark(widget.landmark);
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(content: Text("We won't show ${widget.landmark.name} again"))
);
},
),
),
PopupMenuItem(
child: ListTile(
leading: Icon(Icons.star),
title: Text('Favorite'),
onTap: () async {
// delete the landmark
// await deleteLandmark(widget.landmark);
},
),
),
],
)
],
), ),
), ),
), Flexible(
], child: Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Row(
children: [
Flexible(
child: Text(
widget.landmark.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
),
)
],
),
if (widget.landmark.nameEN != null)
Row(
children: [
Flexible(
child: Text(
widget.landmark.nameEN!,
style: const TextStyle(
fontSize: 16,
),
maxLines: 1,
),
)
],
),
SingleChildScrollView(
// allows the buttons to be scrolled
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 10,
// show the type, the website, and the wikipedia link as buttons/labels in a row
children: [
TextButton.icon(
onPressed: () {},
icon: widget.landmark.type.icon,
label: Text(widget.landmark.type.name),
),
if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0)
TextButton.icon(
onPressed: () {},
icon: Icon(Icons.hourglass_bottom),
label: Text('${widget.landmark.duration!.inMinutes} minutes'),
),
if (widget.landmark.websiteURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the website link
await launchUrl(Uri.parse(widget.landmark.websiteURL!));
},
icon: Icon(Icons.link),
label: Text('Website'),
),
if (widget.landmark.wikipediaURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the wikipedia link
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!));
},
icon: Icon(Icons.book),
label: Text('Wikipedia'),
),
],
),
),
],
),
),
),
],
),
), ),
); );
} }

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';
@@ -34,7 +34,7 @@ class _NewTripButtonState extends State<NewTripButton> {
} }
return FloatingActionButton.extended( return FloatingActionButton.extended(
onPressed: onPressed, onPressed: onPressed,
icon: const Icon(Icons.directions), icon: const Icon(Icons.add),
label: AutoSizeText('Start planning!'), label: AutoSizeText('Start planning!'),
); );
} }
@@ -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) => TripPage(trip: trip) builder: (context) => BasePage(mainScreen: "map", trip: trip)
) )
); );
} }

View File

@@ -9,15 +9,6 @@ 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;
@@ -36,35 +27,26 @@ 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) {
locations.addAll(await locationFromAddress(query)); try{
locations = await locationFromAddress(query);
} catch (e) {
log('No results found for: $query : $e');
} }
if (locations.isNotEmpty) { if (locations.isNotEmpty) {
startLocation = locations.first; Location location = locations.first;
} else { widget.trip.landmarks.clear();
log('No results found for: $query. Is geocoding available?'); widget.trip.addLandmark(
log('Setting Fallback location'); Landmark(
List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514]; uuid: 'pending',
startLocation = Location( name: query,
latitude: coordinates[0], location: [location.latitude, location.longitude],
longitude: coordinates[1], type: typeStart
timestamp: DateTime.now(), )
); );
} }
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: query,
location: [startLocation.latitude, startLocation.longitude],
type: typeStart
)
);
} }
late Widget locationSearchBar = SearchBar( 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,
); );
GoogleMapController? _mapController; late GoogleMapController _mapController;
final Set<Marker> _markers = <Marker>{}; final Set<Marker> _markers = <Marker>{};
_onLongPress(LatLng location) { _onLongPress(LatLng location) {
@@ -56,15 +56,11 @@ class _NewTripMapState extends State<NewTripMap> {
), ),
) )
); );
// check if the controller is ready _mapController.moveCamera(
CameraUpdate.newLatLng(
if (_mapController != null) { LatLng(landmark.location[0], landmark.location[1])
_mapController!.animateCamera( )
CameraUpdate.newLatLng( );
LatLng(landmark.location[0], landmark.location[1])
)
);
}
setState(() {}); setState(() {});
} }
} }

View File

@@ -2,11 +2,13 @@ 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 {
final String title; int index;
final String description; String title;
final String imagePath; String description;
String imagePath;
const OnboardingCard({ OnboardingCard({
required this.index,
required this.title, required this.title,
required this.description, required this.description,
required this.imagePath, required this.imagePath,
@@ -14,35 +16,41 @@ class OnboardingCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Color baseColor = Theme.of(context).colorScheme.secondary;
return Padding( // have a different color for each card, incrementing the hue
padding: EdgeInsets.all(20), Color currentColor = baseColor.withAlpha(baseColor.alpha - index * 30);
child: Column( return Container(
mainAxisAlignment: MainAxisAlignment.center, color: currentColor,
children: [ alignment: Alignment.center,
Text( child: Padding(
title, padding: EdgeInsets.all(20),
style: TextStyle( child: Column(
fontSize: 24, mainAxisAlignment: MainAxisAlignment.center,
fontWeight: FontWeight.bold, children: [
color: Colors.white, Text(
title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
), ),
), Padding(padding: EdgeInsets.only(top: 20)),
Padding(padding: EdgeInsets.only(top: 20)), SvgPicture.asset(
SvgPicture.asset( imagePath,
imagePath, height: 200,
height: 200, ),
), Padding(padding: EdgeInsets.only(top: 20)),
Padding(padding: EdgeInsets.only(top: 20)), Text(
Text( description,
description, style: TextStyle(
style: TextStyle( fontSize: 16,
fontSize: 16, ),
), ),
),
] ]
), ),
)
); );
} }
} }

View File

@@ -1,12 +1,11 @@
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 SavedTrips trips; final Future<List<Trip>> trips;
const TripsOverview({ const TripsOverview({
super.key, super.key,
required this.trips, required this.trips,
@@ -17,34 +16,50 @@ 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;
List<Trip> items = trips.trips; if (snapshot.hasData) {
children = List<Widget>.generate(items.length, (index) { children = List<Widget>.generate(snapshot.data!.length, (index) {
Trip trip = items[index]; Trip trip = snapshot.data![index];
return ListTile( return ListTile(
title: FutureBuilder( title: FutureBuilder(
future: trip.cityName, future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) { builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return Text("Trip to ${snapshot.data}"); return Text("Trip to ${snapshot.data}");
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
return Text("Error: ${snapshot.error}"); return Text("Error: ${snapshot.error}");
} else { } else {
return const Text("Trip to ..."); return const Text("Trip to ...");
} }
},
),
leading: Icon(Icons.pin_drop),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "map", trip: trip)
)
);
}, },
);
});
} else if (snapshot.hasError) {
children = [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 60,
), ),
leading: Icon(Icons.pin_drop), Padding(
onTap: () { padding: const EdgeInsets.only(top: 16),
Navigator.of(context).push( child: Text('Error: ${snapshot.error}'),
MaterialPageRoute( ),
builder: (context) => TripPage(trip: trip) ];
) } else {
); children = [Center(child: CircularProgressIndicator())];
}, }
);
});
return ListView( return ListView(
children: children, children: children,
@@ -54,11 +69,9 @@ class _TripsOverviewState extends State<TripsOverview> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return FutureBuilder(
listenable: widget.trips, future: widget.trips,
builder: (BuildContext context, Widget? child) { builder: listBuild,
return listBuild(context, widget.trips);
}
); );
} }
} }

View File

@@ -1,5 +1,4 @@
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';
@@ -7,12 +6,6 @@ import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/current_trip_map.dart'; import 'package:anyway/modules/current_trip_map.dart';
import 'package:anyway/modules/current_trip_panel.dart'; import 'package:anyway/modules/current_trip_panel.dart';
final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 200.0, 70.0));
TextStyle greeterStyle = TextStyle(
foreground: Paint()..shader = textGradient,
fontWeight: FontWeight.bold,
fontSize: 26
);
class TripPage extends StatefulWidget { class TripPage extends StatefulWidget {
@@ -32,8 +25,7 @@ class _TripPageState extends State<TripPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BasePage( return SlidingUpPanel(
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
@@ -43,7 +35,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.only(top: 10),
// 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)),
@@ -54,13 +46,6 @@ 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,28 +19,23 @@ 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 BasePage( return Scaffold(
mainScreen: Scaffold( appBar: AppBar(
body: Stack( title: const Text('New Trip'),
children: [
NewTripMap(trip),
Padding(
padding: EdgeInsets.all(15),
child: NewTripLocationSearch(trip),
),
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
), ),
title: Text("New Trip"), body: Stack(
helpTexts: [ children: [
"Setting the start location", NewTripMap(trip),
"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." Padding(
], padding: EdgeInsets.all(15),
child: NewTripLocationSearch(trip),
),
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
); );
} }
} }

View File

@@ -1,5 +1,4 @@
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';
@@ -20,54 +19,41 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BasePage( return Scaffold(
mainScreen: Scaffold( body: ListView(
body: ListView( children: [
children: [ // Center(
// Center( // child: CircleAvatar(
// child: CircleAvatar( // radius: 100,
// radius: 100, // 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(
padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0), padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0),
child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18)) child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18))
),
), ),
),
Divider(indent: 25, endIndent: 25, height: 50), Divider(indent: 25, endIndent: 25, height: 50),
durationPicker(preferences.maxTime), durationPicker(preferences.maxTime),
preferenceSliders([preferences.sightseeing, preferences.shopping, preferences.nature]), preferenceSliders([preferences.sightseeing, preferences.shopping, preferences.nature]),
] ]
),
floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences),
), ),
floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences),
title: FutureBuilder(
future: widget.trip.cityName,
builder: (context, snapshot) => Text(
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
)
),
helpTexts: [
'Trip preferences',
'Set your preferences for this trip. These will be used to generate a custom itinerary.'
],
); );
} }
@@ -77,7 +63,7 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
margin: const EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 0), margin: const EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 0),
shadowColor: Colors.grey, shadowColor: Colors.grey,
child: ListTile( child: ListTile(
leading: preferences.maxTime.icon, leading: Icon(Icons.timer),
title: Text(preferences.maxTime.description), title: Text(preferences.maxTime.description),
subtitle: CupertinoTimerPicker( subtitle: CupertinoTimerPicker(
mode: CupertinoTimerPickerMode.hm, mode: CupertinoTimerPickerMode.hm,

View File

@@ -1,33 +1,7 @@
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});
@@ -36,83 +10,37 @@ 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: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Stack(
children: [
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: APP_GRADIENT.colors,
stops: [
(_controller.hasClients ? _controller.page ?? _controller.initialPage : _controller.initialPage) / onboardingCards.length,
(_controller.hasClients ? _controller.page ?? _controller.initialPage + 1 : _controller.initialPage + 1) / onboardingCards.length,
],
),
),
),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(
color: Colors.black.withOpacity(0),
),
),
],
);
},
),
PageView( PageView(
// horizontally scrollable list of pages
controller: _controller, controller: _controller,
children: List.generate(
onboardingCards.length, children: [
(index) { OnboardingCard(index: 1, title: "Welcome to anyway!", description: "Anyway helps you plan a city trip that suits your wishes.", imagePath: "assets/city.svg"),
return 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"),
alignment: Alignment.center, 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"),
child: onboardingCards[index], ],
);
}
),
), ),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () {
if (_controller.page == onboardingCards.length - 1) { if (_controller.page == 2) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const NewTripPage() builder: (context) => const NewTripPage()
) )
);
} else {
_controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
}
},
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 { } else {
return const Icon(Icons.arrow_forward); _controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
} }
} },
) child: Icon(Icons.arrow_forward),
), ),
); );
} }

View File

@@ -1,6 +1,5 @@
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';
@@ -17,37 +16,30 @@ 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 BasePage( return ListView(
mainScreen: ListView( padding: EdgeInsets.all(15),
padding: EdgeInsets.all(15), children: [
children: [ // First a round, centered image
// First a round, centered image Center(
Center( child: CircleAvatar(
child: CircleAvatar( radius: 75,
radius: 75, child: Icon(Icons.settings, size: 100),
child: Icon(Icons.settings, size: 100), )
) ),
), Center(
Center( child: Text('Global settings', style: TextStyle(fontSize: 24))
child: Text('Global settings', style: TextStyle(fontSize: 24)) ),
),
Divider(indent: 25, endIndent: 25, height: 50), Divider(indent: 25, endIndent: 25, height: 50),
darkMode(), darkMode(),
setLocationUsage(), setLocationUsage(),
setDebugMode(), setDebugMode(),
Divider(indent: 25, endIndent: 25, height: 50), Divider(indent: 25, endIndent: 25, height: 50),
privacyInfo(), privacyInfo(),
] ]
),
title: Text('Settings'),
helpTexts: [
'Settings',
'Preferences set in this page are global and will affect the entire application.'
],
); );
} }
@@ -69,7 +61,9 @@ class _SettingsPageState extends State<SettingsPage> {
return AlertDialog( return AlertDialog(
title: Text('Debug mode - use a custom API endpoint'), title: Text('Debug mode - use a custom API endpoint'),
content: TextField( content: TextField(
controller: TextEditingController(text: API_URL_DEBUG), decoration: InputDecoration(
hintText: 'https://anyway-stg.anydev.info'
),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
API_URL_BASE = value; API_URL_BASE = value;
@@ -177,9 +171,7 @@ class _SettingsPageState extends State<SettingsPage> {
return Center( return Center(
child: Column( child: Column(
children: [ children: [
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), Text('Our privacy policy is available under:'),
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,7 +24,8 @@ 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;
String? imageURL; // not final because it can be patched final String? wikipediaURL;
final String? imageURL;
final String? description; final String? description;
final Duration? duration; final Duration? duration;
final bool? visited; final bool? visited;
@@ -43,6 +44,7 @@ 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,
@@ -68,6 +70,7 @@ 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?;
@@ -82,6 +85,7 @@ 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,
@@ -108,6 +112,7 @@ 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,
@@ -125,7 +130,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.castle); icon = const Icon(Icons.church);
break; break;
case 'nature': case 'nature':
icon = const Icon(Icons.eco); icon = const Icon(Icons.eco);

View File

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

View File

@@ -1,6 +1,5 @@
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';
@@ -39,18 +38,10 @@ fetchTrip(
String dataString = jsonEncode(data); String dataString = jsonEncode(data);
log(dataString); log(dataString);
late Response response; final response = await dio.post(
try { "/trip/new",
response = await dio.post( data: data
"/trip/new", );
data: data
);
} catch (e) {
trip.updateUUID("error");
trip.updateError(e.toString());
log(e.toString());
return;
}
// handle errors // handle errors
if (response.statusCode != 200) { if (response.statusCode != 200) {
@@ -86,20 +77,6 @@ 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(
@@ -116,7 +93,5 @@ 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"];
Landmark landmark = Landmark.fromJson(json); return (Landmark.fromJson(json), nextUUID);
patchLandmarkImage(landmark);
return (landmark, nextUUID);
} }

View File

@@ -1,41 +0,0 @@
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

@@ -1,71 +0,0 @@
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,39 +1,19 @@
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';
import 'package:flutter/foundation.dart'; Future<List<Trip>> loadTrips() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
class SavedTrips extends ChangeNotifier { List<Trip> trips = [];
List<Trip> _trips = []; Set<String> keys = prefs.getKeys();
for (String key in keys) {
List<Trip> get trips => _trips; if (key.startsWith('trip_')) {
String uuid = key.replaceFirst('trip_', '');
void loadTrips() async { trips.add(Trip.fromPrefs(prefs, uuid));
SharedPreferences prefs = await SharedPreferences.getInstance();
List<Trip> trips = [];
Set<String> keys = prefs.getKeys();
for (String key in keys) {
if (key.startsWith('trip_')) {
String uuid = key.replaceFirst('trip_', '');
trips.add(Trip.fromPrefs(prefs, uuid));
}
} }
_trips = trips;
notifyListeners();
}
void addTrip(Trip trip) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
trip.toPrefs(prefs);
_trips.add(trip);
notifyListeners();
}
void clearTrips () async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.clear();
_trips = [];
notifyListeners();
} }
return trips;
} }

View File

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

View File

@@ -51,7 +51,6 @@ 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

@@ -0,0 +1,30 @@
// 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);
});
}

View File

@@ -1,50 +0,0 @@
import httpx
import json
base_url = "https://en.wikipedia.org/w/api.php"
def best_page_match(title) -> int:
params = {
"action": "query",
"format": "json",
"list": "prefixsearch",
"pssearch": title,
}
response = httpx.get(base_url, params=params)
data = response.json()
data = data.get("query", {}).get("prefixsearch", [])
titles_and_ids = {d["title"]: d["pageid"] for d in data}
for t in titles_and_ids:
if title.lower() == t.lower():
print("Matched")
return titles_and_ids[t]
def get_image_url(page_id) -> str:
# https://en.wikipedia.org/w/api.php?action=query&titles=K%C3%B6lner%20Dom&prop=imageinfo&iiprop=url&format=json
params = {
"action": "query",
"format": "json",
"prop": "pageimages",
"pageids": page_id,
"pithumbsize": 500,
}
response = httpx.get(base_url, params=params)
data = response.json()
data = data.get("query", {}).get("pages", {})
data = data.get(str(page_id), {})
return data.get("thumbnail", {}).get("source")
def get_image_url_from_title(title) -> str:
page_id = best_page_match(title)
if page_id is None:
return None
return get_image_url(page_id)
print(get_image_url_from_title("kölner dom"))
print(get_image_url_from_title("grossmünster"))
print(get_image_url_from_title("eiffel tower"))
print(get_image_url_from_title("taj mahal"))
print(get_image_url_from_title("big ben"))