7 Commits

Author SHA1 Message Date
dd48fda99f WIP: loading indicator
All checks were successful
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 4m9s
Build and release debug APK / Build APK (pull_request) Successful in 10m28s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 56s
2024-12-18 13:09:53 +01:00
d992b62533 tentatively shrink trip overview, nicer onboarding 2024-12-17 11:17:59 +01:00
e78bee4597 some more images 2024-12-17 10:28:33 +01:00
d186a51a87 WIP: ladnmark card adjustments 2024-12-15 16:30:17 +01:00
4baf045c8c better onboarding
All checks were successful
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 2m1s
Build and release debug APK / Build APK (pull_request) Successful in 10m54s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 14s
2024-12-02 10:43:42 +01:00
3f1fe463bf better help and onboarding
All checks were successful
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m40s
Build and release debug APK / Build APK (pull_request) Successful in 7m23s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 14s
2024-11-18 17:42:52 +01:00
d58ef2562d image querying from within the frontend
All checks were successful
Build and release debug APK / Build APK (pull_request) Successful in 7m40s
2024-11-06 14:45:43 +01:00
30 changed files with 1437 additions and 507 deletions

111
backend/test.py Normal file
View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
@@ -15,46 +17,116 @@ class CurrentTripLoadingIndicator extends StatefulWidget {
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
}
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override
Widget build(BuildContext context) => Center(
child: FutureBuilder(
future: widget.trip.cityName,
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;
Widget loadingIndicator = const Padding(
padding: EdgeInsets.only(top: 10),
child: CircularProgressIndicator()
);
if (snapshot.hasData) {
greeter = AutoSizeText(
maxLines: 1,
'Generating your trip to ${snapshot.data}...',
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 = AutoSizeText(
maxLines: 1,
'Error while loading trip.',
greeter = AnimatedGradientText(
text: 'Error while loading trip.',
style: greeterStyle,
);
} else {
greeter = AutoSizeText(
maxLines: 1,
'Generating your trip...',
greeter = AnimatedGradientText(
text: 'Generating your trip...',
style: greeterStyle,
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
greeter,
loadingIndicator,
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

@@ -36,7 +36,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
child: CurrentTripErrorMessage(trip: widget.trip)
),
);
@@ -46,19 +46,20 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
child: CurrentTripLoadingIndicator(trip: widget.trip),
),
);
} else {
return ListView(
controller: widget.controller,
padding: const EdgeInsets.only(bottom: 30),
padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30),
children: [
SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
// note that we need to account for the padding above
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
child: CurrentTripGreeter(trip: widget.trip),
),
@@ -72,7 +73,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
const Padding(padding: EdgeInsets.only(top: 10)),
Center(child: saveButton(widget.trip)),
Center(child: saveButton(trip: widget.trip)),
],
);
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -6,8 +8,12 @@ import 'package:anyway/structs/landmark.dart';
class LandmarkCard extends StatefulWidget {
final Landmark landmark;
final Trip parentTrip;
LandmarkCard(this.landmark);
LandmarkCard(
this.landmark,
this.parentTrip,
);
@override
_LandmarkCardState createState() => _LandmarkCardState();
@@ -17,34 +23,54 @@ class LandmarkCard extends StatefulWidget {
class _LandmarkCardState extends State<LandmarkCard> {
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
if (widget.landmark.type == typeStart || widget.landmark.type == typeFinish) {
return TextButton.icon(
onPressed: () {},
icon: widget.landmark.type.icon,
label: Text(widget.landmark.name),
);
}
// else:
return Container(
height: 160,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
elevation: 5,
clipBehavior: Clip.antiAliasWithSaveLayer,
child: Row(
// if the image is available, display it on the left side of the card, otherwise only display the text
child: widget.landmark.imageURL != null ? splitLayout() : textLayout(),
),
);
}
Widget splitLayout() {
// If an image is available, display it on the left side of the card
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container( // the image on the left
// inherit the height of the parent container
height: double.infinity,
// force a fixed width
Container(
// the image on the left
width: 160,
height: 160,
child: CachedNetworkImage(
imageUrl: widget.landmark.imageURL ?? '',
placeholder: (context, url) => Center(child: CircularProgressIndicator()),
errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined),
// TODO: make this a switch statement to load a placeholder if null
// cover the whole container meaning the image will be cropped
fit: BoxFit.cover,
),
),
Flexible(
child: Padding(
child: textLayout(),
),
],
);
}
Widget textLayout() {
return Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
@@ -76,7 +102,10 @@ class _LandmarkCardState extends State<LandmarkCard> {
)
],
),
SingleChildScrollView(
Padding(padding: EdgeInsets.only(top: 10)),
Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
// allows the buttons to be scrolled
scrollDirection: Axis.horizontal,
child: Wrap(
@@ -103,25 +132,41 @@ class _LandmarkCardState extends State<LandmarkCard> {
icon: Icon(Icons.link),
label: Text('Website'),
),
if (widget.landmark.wikipediaURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the wikipedia link
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!));
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"))
);
},
icon: Icon(Icons.book),
label: Text('Wikipedia'),
),
),
PopupMenuItem(
child: ListTile(
leading: Icon(Icons.star),
title: Text('Favorite'),
onTap: () async {
// delete the landmark
// await deleteLandmark(widget.landmark);
},
),
),
],
),
),
)
],
),
),
),
],
),
),
);
}
}

View File

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

View File

@@ -9,6 +9,15 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
const Map<String, List> debugLocations = {
'paris': [48.8575, 2.3514],
'london': [51.5074, -0.1278],
'new york': [40.7128, -74.0060],
'tokyo': [35.6895, 139.6917],
};
class NewTripLocationSearch extends StatefulWidget {
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
Trip trip;
@@ -27,26 +36,35 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
setTripLocation (String query) async {
List<Location> locations = [];
Location startLocation;
log('Searching for: $query');
try{
locations = await locationFromAddress(query);
} catch (e) {
log('No results found for: $query : $e');
if (GeocodingPlatform.instance != null) {
locations.addAll(await locationFromAddress(query));
}
if (locations.isNotEmpty) {
Location location = locations.first;
startLocation = locations.first;
} else {
log('No results found for: $query. Is geocoding available?');
log('Setting Fallback location');
List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514];
startLocation = Location(
latitude: coordinates[0],
longitude: coordinates[1],
timestamp: DateTime.now(),
);
}
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: query,
location: [location.latitude, location.longitude],
location: [startLocation.latitude, startLocation.longitude],
type: typeStart
)
);
}
}
late Widget locationSearchBar = SearchBar(

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:flutter/material.dart';
import 'package:anyway/layout.dart';
import 'package:anyway/structs/trip.dart';
class TripsOverview extends StatefulWidget {
final Future<List<Trip>> trips;
final SavedTrips trips;
const TripsOverview({
super.key,
required this.trips,
@@ -16,12 +17,11 @@ class TripsOverview extends StatefulWidget {
}
class _TripsOverviewState extends State<TripsOverview> {
Widget listBuild (BuildContext context, AsyncSnapshot<List<Trip>> snapshot) {
Widget listBuild (BuildContext context, SavedTrips trips) {
List<Widget> children;
if (snapshot.hasData) {
children = List<Widget>.generate(snapshot.data!.length, (index) {
Trip trip = snapshot.data![index];
List<Trip> items = trips.trips;
children = List<Widget>.generate(items.length, (index) {
Trip trip = items[index];
return ListTile(
title: FutureBuilder(
future: trip.cityName,
@@ -39,27 +39,12 @@ class _TripsOverviewState extends State<TripsOverview> {
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "map", trip: trip)
builder: (context) => TripPage(trip: trip)
)
);
},
);
});
} else if (snapshot.hasError) {
children = [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 60,
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Error: ${snapshot.error}'),
),
];
} else {
children = [Center(child: CircularProgressIndicator())];
}
return ListView(
children: children,
@@ -69,9 +54,11 @@ class _TripsOverviewState extends State<TripsOverview> {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: widget.trips,
builder: listBuild,
return ListenableBuilder(
listenable: widget.trips,
builder: (BuildContext context, Widget? child) {
return listBuild(context, widget.trips);
}
);
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import 'package:anyway/constants.dart';
import 'package:anyway/pages/base_page.dart';
import 'package:flutter/material.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
@@ -31,7 +32,8 @@ class _TripPageState extends State<TripPage> {
@override
Widget build(BuildContext context) {
return SlidingUpPanel(
return BasePage(
mainScreen: SlidingUpPanel(
// use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview
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
@@ -41,7 +43,7 @@ class _TripPageState extends State<TripPage> {
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
// padding in this context is annoying: it offsets the notion of vertical alignment.
// children that want to be centered vertically need to have their size adjusted by 2x the padding
padding: const EdgeInsets.all(10.0),
// padding: const EdgeInsets.all(10.0),
// Panel snapping should not be disabled because it significantly improves the user experience
// panelSnapping: false
borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)),
@@ -52,6 +54,13 @@ class _TripPageState extends State<TripPage> {
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/pages/base_page.dart';
import 'package:flutter/material.dart';
import "package:anyway/structs/trip.dart";
@@ -19,13 +19,12 @@ class _NewTripPageState extends State<NewTripPage> {
final TextEditingController lonController = TextEditingController();
Trip trip = Trip();
@override
Widget build(BuildContext context) {
// floating search bar and map as a background
return Scaffold(
appBar: AppBar(
title: const Text('New Trip'),
),
return BasePage(
mainScreen: Scaffold(
body: Stack(
children: [
NewTripMap(trip),
@@ -36,6 +35,12 @@ class _NewTripPageState extends State<NewTripPage> {
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
),
title: Text("New Trip"),
helpTexts: [
"Setting the start location",
"To set the starting point, type a city name in the search bar. You can also navigate the map like you're used to and long press anywhere to set a starting point."
],
);
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'package:anyway/constants.dart';
import 'package:anyway/main.dart';
import 'package:anyway/pages/base_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -16,7 +17,8 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
return ListView(
return BasePage(
mainScreen: ListView(
padding: EdgeInsets.all(15),
children: [
// First a round, centered image
@@ -40,6 +42,12 @@ class _SettingsPageState extends State<SettingsPage> {
privacyInfo(),
]
),
title: Text('Settings'),
helpTexts: [
'Settings',
'Preferences set in this page are global and will affect the entire application.'
],
);
}
@@ -169,7 +177,9 @@ class _SettingsPageState extends State<SettingsPage> {
return Center(
child: Column(
children: [
Text('Our privacy policy is available under:'),
Text('AnyWay does not collect or store any of the data that is submitted via the app. The location of your trip is not stored. The location feature is only used to show your current location on the map, it is not transmitted to our servers.', textAlign: TextAlign.center),
Padding(padding: EdgeInsets.only(top: 3)),
Text('Our full privacy policy is available under:', textAlign: TextAlign.center),
TextButton.icon(
icon: Icon(Icons.info),

View File

@@ -24,8 +24,7 @@ final class Landmark extends LinkedListEntry<Landmark>{
// description to be shown in the overview
final String? nameEN;
final String? websiteURL;
final String? wikipediaURL;
final String? imageURL;
String? imageURL; // not final because it can be patched
final String? description;
final Duration? duration;
final bool? visited;
@@ -44,7 +43,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
this.nameEN,
this.websiteURL,
this.wikipediaURL,
this.imageURL,
this.description,
this.duration,
@@ -70,7 +68,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
final isSecondary = json['is_secondary'] as bool?;
final nameEN = json['name_en'] as String?;
final websiteURL = json['website_url'] as String?;
final wikipediaURL = json['wikipedia_url'] as String?;
final imageURL = json['image_url'] as String?;
final description = json['description'] as String?;
var duration = Duration(minutes: json['duration'] ?? 0) as Duration?;
@@ -85,7 +82,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
isSecondary: isSecondary,
nameEN: nameEN,
websiteURL: websiteURL,
wikipediaURL: wikipediaURL,
imageURL: imageURL,
description: description,
duration: duration,
@@ -112,7 +108,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
'is_secondary': isSecondary,
'name_en': nameEN,
'website_url': websiteURL,
'wikipedia_url': wikipediaURL,
'image_url': imageURL,
'description': description,
'duration': duration?.inMinutes,
@@ -130,7 +125,7 @@ class LandmarkType {
LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) {
switch (name) {
case 'sightseeing':
icon = const Icon(Icons.church);
icon = const Icon(Icons.castle);
break;
case 'nature':
icon = const Icon(Icons.eco);

View File

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

View File

@@ -1,5 +1,6 @@
import "dart:convert";
import "dart:developer";
import "package:anyway/utils/load_landmark_image.dart";
import 'package:dio/dio.dart';
import 'package:anyway/constants.dart';
@@ -85,6 +86,20 @@ fetchTrip(
}
patchLandmarkImage(Landmark landmark) async {
// patch the landmark to include an image from an external source
if (landmark.imageURL == null) {
String? newUrl = await getImageUrlFromName(landmark.name);
if (newUrl != null) {
landmark.imageURL = newUrl;
}
} else if (landmark.imageURL!.contains("photos.app.goo.gl")) {
// the image is a google photos link, we should get the image behind the link
String? newUrl = await getImageUrlFromGooglePhotos(landmark.imageURL!);
// also set the new url if it is null
landmark.imageURL = newUrl;
}
}
Future<(Landmark, String?)> fetchLandmark(String uuid) async {
final response = await dio.get(
@@ -101,5 +116,7 @@ Future<(Landmark, String?)> fetchLandmark(String uuid) async {
log(response.data.toString());
Map<String, dynamic> json = response.data;
String? nextUUID = json["next_uuid"];
return (Landmark.fromJson(json), nextUUID);
Landmark landmark = Landmark.fromJson(json);
patchLandmarkImage(landmark);
return (landmark, nextUUID);
}

View File

@@ -0,0 +1,41 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/onboarding.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:flutter/material.dart';
Widget getFirstPage() {
SavedTrips trips = SavedTrips();
trips.loadTrips();
return ListenableBuilder(
listenable: trips,
builder: (BuildContext context, Widget? child) {
List<Trip> items = trips.trips;
if (items.isNotEmpty) {
return TripPage(trip: items[0]);
} else {
return OnboardingPage();
}
}
);
// Future<List<Trip>> trips = loadTrips();
// // test if there are any active trips
// // if there are, return the trip list
// // if there are not, return the onboarding page
// return FutureBuilder(
// future: trips,
// builder: (context, snapshot) {
// if (snapshot.hasData) {
// List<Trip> availableTrips = snapshot.data!;
// if (availableTrips.isNotEmpty) {
// return TripPage(trip: availableTrips[0]);
// } else {
// return OnboardingPage();
// }
// } else {
// return CircularProgressIndicator();
// }
// }
// );
}

View File

@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'dart:convert';
import 'package:fuzzywuzzy/model/extracted_result.dart';
const String baseUrl = "https://en.wikipedia.org/w/api.php";
final Dio dio = Dio();
Future<int?> bestPageMatch(String title) async {
final response = await dio.get(baseUrl, queryParameters: {
"action": "query",
"format": "json",
"list": "prefixsearch",
"pssearch": title,
});
final data = jsonDecode(response.toString());
log(data.toString());
final List<dynamic> results = data["query"]["prefixsearch"] ?? {};
final Map<String, int> titlesAndIds = {
for (var d in results) d["title"]: d["pageid"]
};
if (titlesAndIds.isEmpty) {
log("No pages found for $title");
return null;
}
// after the empty check, we can safely assume that there is a best match
final ExtractedResult<String> bestMatch = extractOne(
query: title,
choices: titlesAndIds.keys.toList(),
cutoff: 70,
);
return titlesAndIds[bestMatch.choice];
}
Future<String?> getImageUrl(int pageId) async {
final response = await dio.get(baseUrl, queryParameters: {
"action": "query",
"format": "json",
"prop": "pageimages",
"pageids": pageId,
"pithumbsize": 500,
});
final data = jsonDecode(response.toString());
final pageData = data["query"]["pages"][pageId.toString()];
return pageData["thumbnail"]?["source"];
}
Future<String?> getImageUrlFromName(String title) async {
int? pageId = await bestPageMatch(title);
if (pageId == null) {
return null;
}
return await getImageUrl(pageId);
}
Future<String?> getImageUrlFromGooglePhotos(String url) async {
// this is a very simple implementation that just gets the image behind the link
// it is not guaranteed to work for all google photos links
final response = await dio.get(url);
final data = response.toString();
final int start = data.indexOf("https://lh3.googleusercontent.com");
final int end = data.indexOf('"', start);
return data.substring(start, end);
}

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// import 'package:anyway/main.dart';
import 'package:anyway/layout.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(BasePage(mainScreen: "map",));
// Verfiy that the title is displayed
expect(find.text('City Nav'), findsOneWidget);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

50
testing_image_query.py Normal file
View File

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