chore(wip): upgrade dependencies, begin refactor

This commit is contained in:
2025-12-23 21:49:54 +01:00
parent 0070e57aec
commit 239b63ca81
82 changed files with 4028 additions and 195 deletions

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
const String APP_NAME = 'AnyWay';
String API_URL_BASE = 'https://anyway.anydev.info';
String API_URL_DEBUG = 'https://anyway.anydev.info';
String PRIVACY_URL = 'https://anydev.info/privacy';
const String MAP_ID = '41c21ac9b81dbfd8';
const Color GRADIENT_START = Color(0xFFF9B208);
const Color GRADIENT_END = Color(0xFFE72E77);
const Color PRIMARY_COLOR = Color(0xFFF38F1A);
const double TRIP_PANEL_MAX_HEIGHT = 0.8;
const double TRIP_PANEL_MIN_HEIGHT = 0.12;
ThemeData APP_THEME = ThemeData(
primaryColor: PRIMARY_COLOR,
scaffoldBackgroundColor: Colors.white,
cardColor: Colors.white,
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: PRIMARY_COLOR,
secondary: GRADIENT_END,
surface: Colors.white,
error: Colors.red,
onPrimary: Colors.white,
onSecondary: Color.fromARGB(255, 30, 22, 22),
onSurface: Colors.black,
onError: Colors.white,
brightness: Brightness.light,
),
textButtonTheme: const TextButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
side: WidgetStatePropertyAll(
BorderSide(
color: PRIMARY_COLOR,
width: 1,
),
),
)
),
elevatedButtonTheme: const ElevatedButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
)
),
outlinedButtonTheme: const OutlinedButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(PRIMARY_COLOR),
)
),
sliderTheme: const SliderThemeData(
trackHeight: 15,
inactiveTrackColor: Colors.grey,
thumbColor: PRIMARY_COLOR,
activeTrackColor: GRADIENT_END
)
);
const Gradient APP_GRADIENT = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [GRADIENT_START, GRADIENT_END],
);

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/main.dart';
import 'package:anyway/modules/help_dialog.dart';
import 'package:anyway/modules/trips_saved_list.dart';
import 'package:anyway/pages/onboarding.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/settings.dart';
import 'package:anyway/pages/new_trip_location.dart';
mixin ScaffoldLayout<T extends StatefulWidget> on State<T> {
Widget mainScaffold(
BuildContext context,
{
Widget child = const Text("emptiness"),
Widget title = const Text(APP_NAME),
List<String> helpTexts = const []
}
) {
return Scaffold(
appBar: AppBar(
title: title,
actions: [
IconButton(
icon: const Icon(Icons.help),
tooltip: 'Help',
onPressed: () {
if (helpTexts.isNotEmpty) {
helpDialog(context, helpTexts[0], helpTexts[1]);
}
}
),
],
),
body: Center(child: child),
drawer: Drawer(
child: Column(
children: [
Container(
decoration: const BoxDecoration(
gradient: APP_GRADIENT,
),
height: 150,
child: const Center(
child: Text(
APP_NAME,
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
ListTile(
title: const Text('Your Trips'),
leading: const Icon(Icons.map),
selected: widget is TripPage,
onTap: () {},
trailing: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NewTripPage()
)
);
},
child: const Text('New'),
),
),
// Adds a ListView to the drawer. This ensures the user can scroll
// through the options in the drawer if there isn't enough vertical
// space to fit everything.
Expanded(
child: TripsOverview(trips: savedTrips),
),
ElevatedButton(
onPressed: () async {
savedTrips.clearTrips();
},
child: const Text('Clear trips'),
),
const Divider(indent: 10, endIndent: 10),
ListTile(
title: const Text('How to use'),
leading: const Icon(Icons.help),
selected: widget is OnboardingPage,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const OnboardingPage()
)
);
},
),
// settings in the bottom of the drawer
ListTile(
title: const Text('Settings'),
leading: const Icon(Icons.settings),
selected: widget is SettingsPage,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsPage()
)
);
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,42 @@
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: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
"😢",
style: TextStyle(
fontSize: 40,
),
),
const SizedBox(height: 10),
AutoSizeText(
// at this point the trip is guaranteed to have an error message
widget.trip.errorDescription!,
maxLines: 30,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/structs/trip.dart';
class CurrentTripGreeter extends StatefulWidget {
final Trip trip;
const CurrentTripGreeter({
super.key,
required this.trip,
});
@override
State<CurrentTripGreeter> createState() => _CurrentTripGreeterState();
}
class _CurrentTripGreeterState extends State<CurrentTripGreeter> {
@override
Widget build(BuildContext context) => Center(
child: FutureBuilder(
future: widget.trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) {
return AutoSizeText(
maxLines: 1,
'Welcome to ${snapshot.data}!',
style: greeterStyle
);
} else if (snapshot.hasError) {
return AutoSizeText(
maxLines: 1,
'Welcome to your trip!',
style: greeterStyle
);
} else {
return AutoSizeText(
maxLines: 1,
'Welcome to ...',
style: greeterStyle
);
}
}
)
);
}

View File

@@ -0,0 +1,44 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/step_between_landmarks.dart';
import 'package:anyway/modules/landmark_card.dart';
// Returns a list of widgets that represent the landmarks matching the given selector
List<Widget> landmarksList(Trip trip, {required bool Function(Landmark) selector}) {
List<Widget> children = [];
if (trip.landmarks.isEmpty || trip.landmarks.length <= 1 && trip.landmarks.first.type == typeStart ) {
children.add(
const Text("No landmarks in this trip"),
);
return children;
}
for (Landmark landmark in trip.landmarks) {
if (selector(landmark)) {
children.add(
LandmarkCard(landmark, trip),
);
if (!landmark.visited) {
Landmark? nextLandmark = landmark.next;
while (nextLandmark != null && nextLandmark.visited) {
nextLandmark = nextLandmark.next;
}
if (nextLandmark != null) {
children.add(
StepBetweenLandmarks(current: landmark, next: nextLandmark)
);
}
}
}
}
return children;
}

View File

@@ -0,0 +1,149 @@
import 'dart:async';
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';
final List<String> statusTexts = [
'Parsing your preferences...',
'Finding the best places...',
'Crunching the numbers...',
'Calculating the best route...',
'Making sure you have a great time...',
];
class CurrentTripLoadingIndicator extends StatefulWidget {
final Trip trip;
const CurrentTripLoadingIndicator({
super.key,
required this.trip,
});
@override
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
}
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
children: [
// In the very center of the panel, show the greeter which tells the user that the trip is being generated
Center(child: loadingText(widget.trip)),
// As a gimmick, and a way to show that the app is still working, show a few loading dots
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 12),
child: StatusText(),
)
)
],
);
}
// automatically cycle through the greeter texts
class StatusText extends StatefulWidget {
const StatusText({super.key});
@override
_StatusTextState createState() => _StatusTextState();
}
class _StatusTextState extends State<StatusText> {
int statusIndex = 0;
@override
void initState() {
super.initState();
Future.delayed(const Duration(seconds: 5), () {
setState(() {
statusIndex = (statusIndex + 1) % statusTexts.length;
});
});
}
@override
Widget build(BuildContext context) {
return AutoSizeText(
statusTexts[statusIndex],
style: Theme.of(context).textTheme.labelSmall,
);
}
}
Widget loadingText(Trip trip) => FutureBuilder(
future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
Widget greeter;
if (snapshot.hasData) {
greeter = AnimatedDotsText(
baseText: 'Creating 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 = Text(
'Error while loading trip.',
style: greeterStyle,
);
} else {
greeter = AnimatedDotsText(
baseText: 'Creating your trip',
style: greeterStyle,
);
}
return greeter;
}
);
class AnimatedDotsText extends StatefulWidget {
final String baseText;
final TextStyle style;
const AnimatedDotsText({
super.key,
required this.baseText,
required this.style,
});
@override
_AnimatedDotsTextState createState() => _AnimatedDotsTextState();
}
class _AnimatedDotsTextState extends State<AnimatedDotsText> {
int dotCount = 0;
@override
void initState() {
super.initState();
Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {
dotCount = (dotCount + 1) % 4;
// show up to 3 dots
});
} else {
timer.cancel();
}
});
}
@override
Widget build(BuildContext context) {
String dots = '.' * dotCount;
return AutoSizeText(
'${widget.baseText}$dots',
style: widget.style,
maxLines: 2,
);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/structs/trip.dart';
List<Map<String, dynamic>> locationActions = [
{'name': 'Toilet', 'action': () {}},
{'name': 'Food', 'action': () {}},
{'name': 'Surrounding landmarks', 'action': () {}},
];
class CurrentTripLocations extends StatefulWidget {
final Trip? trip;
const CurrentTripLocations({super.key, this.trip});
@override
State<CurrentTripLocations> createState() => _CurrentTripLocationsState();
}
class _CurrentTripLocationsState extends State<CurrentTripLocations> {
@override
Widget build(BuildContext context) {
// A horizontally scrolling list of buttons with predefined actions
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0),
child: Row(
children: [
if (widget.trip != null)
for (Map action in locationActions)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 3.0),
child: ElevatedButton(
onPressed: action['action'],
child: AutoSizeText(
action['name'],
maxLines: 1,
minFontSize: 8,
maxFontSize: 16,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,150 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/landmark_map_marker.dart';
class CurrentTripMap extends StatefulWidget {
final Trip? trip;
const CurrentTripMap({super.key, this.trip});
@override
State<CurrentTripMap> createState() => _CurrentTripMapState();
}
class _CurrentTripMapState extends State<CurrentTripMap> {
late GoogleMapController mapController;
final CameraPosition _cameraPosition = const CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 11.0,
);
Set<Marker> mapMarkers = <Marker>{};
Set<Polyline> mapPolylines = <Polyline>{};
@override
void initState() {
super.initState();
widget.trip?.addListener(setMapMarkers);
widget.trip?.addListener(setMapRoute);
}
@override
void dispose() {
widget.trip?.removeListener(setMapMarkers);
widget.trip?.removeListener(setMapRoute);
super.dispose();
}
void _onMapCreated(GoogleMapController controller) async {
mapController = controller;
List<double>? newLocation = widget.trip?.landmarks.firstOrNull?.location;
if (newLocation != null) {
CameraUpdate update = CameraUpdate.newLatLng(LatLng(newLocation[0], newLocation[1]));
controller.moveCamera(update);
}
setMapMarkers();
setMapRoute();
}
void _onCameraIdle() {
// print(mapController.getLatLng(ScreenCoordinate(x: 0, y: 0)));
}
void setMapMarkers() async {
Iterator<(int, Landmark)> it = (widget.trip?.landmarks.toList() ?? []).indexed.iterator;
while (it.moveNext()) {
int i = it.current.$1;
Landmark landmark = it.current.$2;
MarkerId markerId = MarkerId("${landmark.uuid} - ${landmark.visited}");
List<double> location = landmark.location;
// only create a new marker, if there is no marker for this landmark
if (!mapMarkers.any((Marker marker) => marker.markerId == markerId)) {
Marker marker = Marker(
markerId: markerId,
position: LatLng(location[0], location[1]),
icon: await ThemedMarker(landmark: landmark, position: i).toBitmapDescriptor(
logicalSize: const Size(150, 150),
imageSize: const Size(150, 150),
)
);
setState(() {
mapMarkers.add(marker);
});
}
}
}
void setMapRoute() async {
List<Landmark> landmarks = widget.trip?.landmarks.toList() ?? [];
Set<Polyline> polyLines = <Polyline>{};
if (landmarks.length < 2) {
return;
}
for (Landmark landmark in landmarks) {
if (landmark.next != null) {
List<LatLng> step = [
LatLng(landmark.location[0], landmark.location[1]),
LatLng(landmark.next!.location[0], landmark.next!.location[1])
];
Polyline stepLine = Polyline(
polylineId: PolylineId('step-${landmark.uuid}'),
points: step,
color: landmark.visited || (landmark.next?.visited ?? false) ? Colors.grey : PRIMARY_COLOR,
width: 5
);
polyLines.add(stepLine);
}
}
setState(() {
mapPolylines = polyLines;
});
}
@override
Widget build(BuildContext context) {
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
return FutureBuilder(
future: preferences,
builder: (context, snapshot) {
if (snapshot.hasData) {
SharedPreferences prefs = snapshot.data as SharedPreferences;
bool useLocation = prefs.getBool('useLocation') ?? true;
return _buildMap(useLocation);
} else {
return const CircularProgressIndicator();
}
},
);
}
Widget _buildMap(bool useLocation) {
return GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: _cameraPosition,
onCameraIdle: _onCameraIdle,
markers: mapMarkers,
polylines: mapPolylines,
cloudMapId: MAP_ID,
mapToolbarEnabled: false,
zoomControlsEnabled: false,
myLocationEnabled: useLocation,
myLocationButtonEnabled: false,
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart';
import 'package:anyway/modules/current_trip_map.dart';
import 'package:anyway/modules/current_trip_locations.dart';
class CurrentTripOverview extends StatefulWidget {
final Trip? trip;
const CurrentTripOverview({super.key, this.trip});
@override
State<CurrentTripOverview> createState() => _CurrentTripOverviewState();
}
class _CurrentTripOverviewState extends State<CurrentTripOverview> {
@override
Widget build(BuildContext context) {
// The background map has a horizontally scrolling list of rounded buttons overlaid
return Stack(
alignment: Alignment.topLeft,
children: [
CurrentTripMap(trip: widget.trip),
CurrentTripLocations(trip: widget.trip),
],
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/current_trip_error_message.dart';
import 'package:anyway/modules/current_trip_loading_indicator.dart';
import 'package:anyway/modules/current_trip_summary.dart';
import 'package:anyway/modules/current_trip_save_button.dart';
import 'package:anyway/modules/current_trip_landmarks_list.dart';
import 'package:anyway/modules/current_trip_greeter.dart';
class CurrentTripPanel extends StatefulWidget {
final ScrollController controller;
final Trip trip;
const CurrentTripPanel({
super.key,
required this.controller,
required this.trip,
});
@override
State<CurrentTripPanel> createState() => _CurrentTripPanelState();
}
class _CurrentTripPanelState extends State<CurrentTripPanel> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trip,
builder: (context, child) {
if (widget.trip.uuid == 'error') {
return ListView(
controller: widget.controller,
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
// note that we need to account for the padding above
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
child: Center(child:
AutoSizeText(
maxLines: 1,
'Error',
style: greeterStyle
)
),
),
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(
controller: widget.controller,
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
// 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),
),
Padding(
padding: const EdgeInsets.all(10),
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
CurrentTripSummary(trip: widget.trip),
if (widget.trip.landmarks.where((Landmark landmark) => landmark.visited).isNotEmpty)
ExpansionTile(
leading: const Icon(Icons.location_on),
title: const Text('Visited Landmarks (tap to expand)'),
visualDensity: VisualDensity.compact,
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
children: [
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited),
],
),
],
),
),
),
const Padding(padding: EdgeInsets.only(top: 10)),
// upcoming landmarks
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited == false),
const Padding(padding: EdgeInsets.only(top: 10)),
Center(child: saveButton(trip: widget.trip)),
],
);
}
}
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
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 {
savedTrips.addTrip(widget.trip);
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(
content: Text('Trip saved'),
duration: Duration(seconds: 2),
dismissDirection: DismissDirection.horizontal
)
);
},
child: const 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

@@ -0,0 +1,43 @@
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart';
class CurrentTripSummary extends StatefulWidget {
final Trip trip;
const CurrentTripSummary({
super.key,
required this.trip,
});
@override
State<CurrentTripSummary> createState() => _CurrentTripSummaryState();
}
class _CurrentTripSummaryState extends State<CurrentTripSummary> {
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: widget.trip,
builder: (context, child) => Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.flag, size: 20),
const Padding(padding: EdgeInsets.only(right: 10)),
Text('${widget.trip.landmarks.length} stops', style: Theme.of(context).textTheme.bodyLarge),
]
),
Row(
children: [
const Icon(Icons.hourglass_bottom_rounded, size: 20),
const Padding(padding: EdgeInsets.only(right: 10)),
Text('${widget.trip.totalTime.inHours}h ${widget.trip.totalTime.inMinutes.remainder(60)}min', style: Theme.of(context).textTheme.bodyLarge),
]
),
],
)
)
);
}

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

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:anyway/structs/landmark.dart';
class LandmarkCard extends StatefulWidget {
final Landmark landmark;
final Trip parentTrip;
const LandmarkCard(
this.landmark,
this.parentTrip,
);
@override
_LandmarkCardState createState() => _LandmarkCardState();
}
class _LandmarkCardState extends State<LandmarkCard> {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
// express the max height in terms text lines
maxHeight: 7 * (Theme.of(context).textTheme.titleMedium!.fontSize! + 10),
),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
elevation: 5,
clipBehavior: Clip.antiAliasWithSaveLayer,
// if the image is available, display it on the left side of the card, otherwise only display the text
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image and landmark "type" on the left
AspectRatio(
aspectRatio: 3 / 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.landmark.imageURL != null && widget.landmark.imageURL!.isNotEmpty)
Expanded(
child: CachedNetworkImage(
imageUrl: widget.landmark.imageURL!,
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) => imagePlaceholder(widget.landmark),
fit: BoxFit.cover
)
)
else
imagePlaceholder(widget.landmark),
if (widget.landmark.type != typeStart && widget.landmark.type != typeFinish)
Container(
color: PRIMARY_COLOR,
child: Center(
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.timer_outlined, size: 16),
Text("${widget.landmark.duration?.inMinutes} minutes"),
],
)
)
),
)
],
)
),
// Main information, useful buttons on the right
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.landmark.name,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
if (widget.landmark.nameEN != null)
Text(
widget.landmark.nameEN!,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
]
),
),
// fill the vspace
const Spacer(),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 10),
// the scroll view should be flush once the buttons are scrolled to the left
// but initially there should be some padding
child: Wrap(
spacing: 10,
// show the type, the website, and the wikipedia link as buttons/labels in a row
children: [
doneToggleButton(),
if (widget.landmark.websiteURL != null)
websiteButton(),
optionsButton()
],
),
),
],
)
)
],
)
)
);
}
Widget doneToggleButton() {
return TextButton.icon(
onPressed: () async {
widget.landmark.visited = !widget.landmark.visited;
widget.parentTrip.notifyUpdate();
},
icon: Icon(widget.landmark.visited ? Icons.undo_sharp : Icons.check),
label: Text(widget.landmark.visited ? "Add to overview" : "Done!"),
);
}
Widget websiteButton () => TextButton.icon(
onPressed: () async {
// open a browser with the website link
await launchUrl(Uri.parse(widget.landmark.websiteURL!));
},
icon: const Icon(Icons.link),
label: const Text('Website'),
);
Widget optionsButton () => PopupMenuButton(
icon: const Icon(Icons.settings),
style: TextButtonTheme.of(context).style,
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
leading: const Icon(Icons.delete),
title: const Text('Delete'),
onTap: () async {
widget.parentTrip.removeLandmark(widget.landmark);
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(content: Text("${widget.landmark.name} won't be shown again"))
);
},
),
),
PopupMenuItem(
child: ListTile(
leading: const Icon(Icons.star),
title: const Text('Favorite'),
onTap: () async {
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text("Not implemented yet"))
);
},
),
),
],
);
}
Widget imagePlaceholder (Landmark landmark) => Expanded(
child:
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [GRADIENT_START, GRADIENT_END],
),
),
child: Center(
child: Icon(landmark.type.icon.icon, size: 50),
),
),
);

View File

@@ -0,0 +1,57 @@
import 'package:anyway/constants.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:flutter/material.dart';
class ThemedMarker extends StatelessWidget {
final Landmark landmark;
final int position;
const ThemedMarker({
super.key,
required this.landmark,
required this.position
});
@override
Widget build(BuildContext context) {
// This returns an outlined circle, with an icon corresponding to the landmark type
// As a small dot, the number of the landmark is displayed in the top right
Widget? positionIndicator;
if (landmark.type != typeStart && landmark.type != typeFinish) {
positionIndicator = Positioned(
top: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
),
child: Text('$position', style: const TextStyle(color: Colors.black, fontSize: 25)),
),
);
}
return RepaintBoundary(
child: Stack(
alignment: Alignment.topRight,
children: [
Container(
decoration: BoxDecoration(
gradient: landmark.visited ? const LinearGradient(colors: [Colors.grey, Colors.white]) : APP_GRADIENT,
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 5),
),
width: 70,
height: 70,
padding: const EdgeInsets.all(5),
child: Icon(landmark.type.icon.icon, size: 50),
),
if (positionIndicator != null) positionIndicator,
],
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:anyway/structs/landmark.dart';
import 'package:flutter/material.dart';
import 'package:map_launcher/map_launcher.dart';
import 'package:flutter_svg/flutter_svg.dart';
showMapChooser(BuildContext context, Landmark current, Landmark next) async {
List availableMaps = [];
try {
availableMaps = await MapLauncher.installedMaps;
} catch (e) {
print(e);
}
if (availableMaps.isEmpty) {
return;
}
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return SafeArea(
child: SingleChildScrollView(
child: Container(
child: Wrap(
children: <Widget>[
for (var map in availableMaps)
ListTile(
onTap: () => map.showDirections(
origin: Coords(current.location[0], current.location[1]),
originTitle: current.name,
destination: Coords(next.location[0], next.location[1]),
destinationTitle: current.name,
directionsMode: DirectionsMode.walking
),
title: Text(map.mapName),
// rounded corners
leading: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: SvgPicture.asset(
map.icon,
height: 30.0,
width: 30.0,
),
)
),
],
),
),
),
);
},
);
}

View File

@@ -0,0 +1,65 @@
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';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
class NewTripButton extends StatefulWidget {
final Trip trip;
final UserPreferences preferences;
const NewTripButton({super.key,
required this.trip,
required this.preferences,
});
@override
State<NewTripButton> createState() => _NewTripButtonState();
}
class _NewTripButtonState extends State<NewTripButton> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trip,
builder: (BuildContext context, Widget? child) {
if (widget.trip.landmarks.isEmpty){
// Fallback if the trip setup is lagging behind
// This should in theory never happen
return Container();
}
return FloatingActionButton.extended(
onPressed: onPressed,
icon: const Icon(Icons.directions),
label: const AutoSizeText('Start planning!'),
);
}
);
}
void onPressed() async {
// Check that the preferences are valid
UserPreferences preferences = widget.preferences;
if (preferences.nature.value == 0 && preferences.shopping.value == 0 && preferences.sightseeing.value == 0){
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text("Please specify at least one preference"))
);
} else if (preferences.maxTime.value == 0){
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text("Please choose a longer duration"))
);
} else {
Trip trip = widget.trip;
fetchTrip(trip, widget.preferences);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TripPage(trip: trip)
)
);
}
}
}

View File

@@ -0,0 +1,126 @@
// A search bar that allow the user to enter a city name
import 'package:anyway/structs/landmark.dart';
import 'package:geocoding/geocoding.dart';
import 'dart:developer';
import 'package:anyway/structs/trip.dart';
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;
NewTripLocationSearch(
this.trip,
);
@override
State<NewTripLocationSearch> createState() => _NewTripLocationSearchState();
}
class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
final TextEditingController _controller = TextEditingController();
setTripLocation (String query) async {
List<Location> locations = [];
Location startLocation;
log('Searching for: $query');
if (GeocodingPlatform.instance != null) {
locations.addAll(await locationFromAddress(query));
}
if (locations.isNotEmpty) {
startLocation = locations.first;
} else {
log('No results found for: $query. Is geocoding available?');
log('Setting Fallback location');
List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514];
startLocation = Location(
latitude: coordinates[0],
longitude: coordinates[1],
timestamp: DateTime.now(),
);
}
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: query,
location: [startLocation.latitude, startLocation.longitude],
type: typeStart
)
);
}
late Widget locationSearchBar = SearchBar(
hintText: 'Enter a city name or long press on the map.',
onSubmitted: setTripLocation,
controller: _controller,
leading: const Icon(Icons.search),
trailing: [
ElevatedButton(
onPressed: () {
setTripLocation(_controller.text);
},
child: const Text('Search'),
)
]
);
late Widget useCurrentLocationButton = ElevatedButton(
onPressed: () async {
// this widget is only shown if the user has already granted location permissions
Position position = await Geolocator.getCurrentPosition();
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: 'start',
location: [position.latitude, position.longitude],
type: typeStart
)
);
},
child: const Text('Use current location'),
);
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: widget.prefs,
builder: (context, snapshot) {
if (snapshot.hasData) {
final useLocation = snapshot.data!.getBool('useLocation') ?? false;
if (useLocation) {
return Column(
children: [
locationSearchBar,
useCurrentLocationButton,
],
);
} else {
return locationSearchBar;
}
} else {
return locationSearchBar;
}
},
);
}
}

View File

@@ -0,0 +1,109 @@
// A map that allows the user to select a location for a new trip.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/modules/landmark_map_marker.dart';
class NewTripMap extends StatefulWidget {
Trip trip;
NewTripMap(
this.trip,
);
@override
State<NewTripMap> createState() => _NewTripMapState();
}
class _NewTripMapState extends State<NewTripMap> {
final CameraPosition _cameraPosition = const CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 11.0,
);
GoogleMapController? _mapController;
final Set<Marker> _markers = <Marker>{};
_onLongPress(LatLng location) {
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: 'start',
location: [location.latitude, location.longitude],
type: typeStart
)
);
}
updateTripDetails() async {
_markers.clear();
if (widget.trip.landmarks.isNotEmpty) {
Landmark landmark = widget.trip.landmarks.first;
_markers.add(
Marker(
markerId: MarkerId(landmark.uuid),
position: LatLng(landmark.location[0], landmark.location[1]),
icon: await ThemedMarker(landmark: landmark, position: 0).toBitmapDescriptor(
logicalSize: const Size(150, 150),
imageSize: const Size(150, 150)
),
)
);
// check if the controller is ready
if (_mapController != null) {
_mapController!.animateCamera(
CameraUpdate.newLatLng(
LatLng(landmark.location[0], landmark.location[1])
)
);
}
setState(() {});
}
}
void _onMapCreated(GoogleMapController controller) async {
_mapController = controller;
}
@override
Widget build(BuildContext context) {
widget.trip.addListener(updateTripDetails);
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
return FutureBuilder(
future: preferences,
builder: (context, snapshot) {
if (snapshot.hasData) {
SharedPreferences prefs = snapshot.data as SharedPreferences;
bool useLocation = prefs.getBool('useLocation') ?? true;
return _buildMap(useLocation);
} else {
return const CircularProgressIndicator();
}
}
);
}
Widget _buildMap(bool useLocation) {
return GoogleMap(
onMapCreated: _onMapCreated,
initialCameraPosition: _cameraPosition,
onLongPress: _onLongPress,
markers: _markers,
cloudMapId: MAP_ID,
mapToolbarEnabled: false,
zoomControlsEnabled: false,
myLocationButtonEnabled: false,
myLocationEnabled: useLocation,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:anyway/pages/new_trip_preferences.dart';
import 'package:anyway/structs/trip.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
class NewTripOptionsButton extends StatefulWidget {
final Trip trip;
const NewTripOptionsButton({super.key, required this.trip});
@override
State<NewTripOptionsButton> createState() => _NewTripOptionsButtonState();
}
class _NewTripOptionsButtonState extends State<NewTripOptionsButton> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trip,
builder: (BuildContext context, Widget? child) {
if (widget.trip.landmarks.isEmpty){
return Container();
}
return FloatingActionButton.extended(
onPressed: () async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NewTripPreferencesPage(trip: widget.trip)
)
);
},
icon: const Icon(Icons.add),
label: const AutoSizeText('Set preferences')
);
}
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:anyway/structs/agreement.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:anyway/modules/onboarding_card.dart';
class OnboardingAgreementCard extends StatefulWidget {
final String title;
final String description;
final String imagePath;
final String agreementTextPath;
final ValueChanged<bool> onAgreementChanged;
const OnboardingAgreementCard({
super.key,
required this.title,
required this.description,
required this.imagePath,
required this.agreementTextPath,
required this.onAgreementChanged
});
@override
State<OnboardingAgreementCard> createState() => _OnboardingAgreementCardState();
}
class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OnboardingCard(title: widget.title, description: widget.description, imagePath: widget.imagePath),
const Padding(padding: EdgeInsets.only(top: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// The checkbox of the agreement
FutureBuilder(
future: getAgreement(),
builder: (context, snapshot) {
bool agreed = false;
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
Agreement agreement = snapshot.data!;
agreed = agreement.agreed;
} else {
agreed = false;
}
} else {
agreed = false;
}
return Checkbox(
value: agreed,
onChanged: (value) {
setState(() {
widget.onAgreementChanged(value!);
});
saveAgreement(value!);
},
);
},
),
// The text of the agreement
Text(
"I agree to the ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
),
),
// The clickable text of the agreement that shows the agreement text
GestureDetector(
onTap: () {
// show a dialog with the agreement text
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
scrollable: true,
content: FutureBuilder(
future: DefaultAssetBundle.of(context).loadString(widget.agreementTextPath),
builder: (context, snapshot) {
if (snapshot.hasData) {
return MarkdownBody(
data: snapshot.data.toString(),
);
} else {
return const CircularProgressIndicator();
}
},
)
);
}
);
},
child: Text(
"Terms of Service (click to view)",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class OnboardingCard extends StatelessWidget {
final String title;
final String description;
final String imagePath;
const OnboardingCard({super.key,
required this.title,
required this.description,
required this.imagePath,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge!.copyWith(
color: Colors.white,
),
),
const Padding(padding: EdgeInsets.only(top: 20)),
SvgPicture.asset(
imagePath,
height: 200,
),
const Padding(padding: EdgeInsets.only(top: 20)),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
),
),
]
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/modules/map_chooser.dart';
class StepBetweenLandmarks extends StatefulWidget {
final Landmark current;
final Landmark next;
const StepBetweenLandmarks({
super.key,
required this.current,
required this.next
});
@override
State<StepBetweenLandmarks> createState() => _StepBetweenLandmarksState();
}
class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
@override
Widget build(BuildContext context) {
int? time = widget.current.tripTime?.inMinutes;
// since landmarks might have been marked as visited, the next landmark might not be the immediate next in the list
// => the precomputed trip time is not valid anymore
if (widget.current.next != widget.next) {
time = null;
}
// round 0 travel time to 1 minute
if (time != null && time < 1) {
time = 1;
}
return Container(
margin: const EdgeInsets.all(10),
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
border: Border(
left: BorderSide(width: 3.0, color: Colors.black),
),
),
child: Row(
children: [
Column(
children: [
const Icon(Icons.directions_walk),
Text(
time == null ? "" : "$time min",
style: const TextStyle(fontSize: 10)
),
],
),
const Spacer(),
ElevatedButton.icon(
onPressed: () async {
showMapChooser(context, widget.current, widget.next);
},
icon: const Icon(Icons.directions),
label: const Text("Directions"),
)
],
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:flutter/material.dart';
import 'package:anyway/structs/trip.dart';
class TripsOverview extends StatefulWidget {
final SavedTrips trips;
const TripsOverview({
super.key,
required this.trips,
});
@override
State<TripsOverview> createState() => _TripsOverviewState();
}
class _TripsOverviewState extends State<TripsOverview> {
Widget tripListItemBuilder(BuildContext context, int index) {
Trip trip = widget.trips.trips[index];
return ListTile(
title: FutureBuilder(
future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) {
return Text("Trip to ${snapshot.data}");
} else if (snapshot.hasError) {
return Text("Error: ${snapshot.error}");
} else {
return const Text("Trip to ...");
}
},
),
// emoji of the country flag of the trip's country
leading: const Icon(Icons.pin_drop),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TripPage(trip: trip)
)
);
},
);
}
// Widget listBuild (BuildContext context, SavedTrips trips) {
// List<Widget> children;
// List<Trip> items = trips.trips;
// children = List<Widget>.generate(items.length, (index) {
// Trip trip = items[index];
// return ListTile(
// title: FutureBuilder(
// future: trip.cityName,
// builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
// if (snapshot.hasData) {
// return Text("Trip to ${snapshot.data}");
// } else if (snapshot.hasError) {
// return Text("Error: ${snapshot.error}");
// } else {
// return const Text("Trip to ...");
// }
// },
// ),
// leading: const Icon(Icons.pin_drop),
// onTap: () {
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => TripPage(trip: trip)
// )
// );
// },
// );
// });
// return ListView(
// padding: const EdgeInsets.only(top: 0),
// children: children,
// );
// }
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trips,
builder: (BuildContext context, Widget? child) => ListView.builder(
itemCount: widget.trips.trips.length,
itemBuilder: tripListItemBuilder,
)
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:anyway/constants.dart';
import 'package:anyway/layouts/scaffold.dart';
import 'package:flutter/material.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/current_trip_overview.dart';
import 'package:anyway/modules/current_trip_panel.dart';
final Shader textGradient = APP_GRADIENT.createShader(const Rect.fromLTWH(0.0, 0.0, 200.0, 70.0));
TextStyle greeterStyle = TextStyle(
foreground: Paint()..shader = textGradient,
fontWeight: FontWeight.bold,
fontSize: 25
);
class TripPage extends StatefulWidget {
final Trip trip;
const TripPage({super.key,
required this.trip,
});
@override
State<TripPage> createState() => _TripPageState();
}
class _TripPageState extends State<TripPage> with ScaffoldLayout{
@override
Widget build(BuildContext context) {
return mainScaffold(
context,
child: 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
// collapsed: Greeter(trip: widget.trip),
body: CurrentTripOverview(trip: widget.trip),
minHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
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),
// 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)),
parallaxEnabled: true,
boxShadow: const [
BoxShadow(
blurRadius: 20.0,
color: Colors.black,
)
],
),
title: FutureBuilder(
future: widget.trip.cityName,
builder: (context, snapshot) => Text(
'Trip to ${snapshot.hasData ? snapshot.data! : "..."}',
)
),
helpTexts: [
'Current trip',
'You can see and edit your current trip here. Swipe up from the bottom to see a detailed view of the recommendations.'
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:anyway/layouts/scaffold.dart';
import 'package:anyway/modules/new_trip_options_button.dart';
import 'package:flutter/material.dart';
import "package:anyway/structs/trip.dart";
import 'package:anyway/modules/new_trip_location_search.dart';
import 'package:anyway/modules/new_trip_map.dart';
class NewTripPage extends StatefulWidget {
const NewTripPage({super.key});
@override
_NewTripPageState createState() => _NewTripPageState();
}
class _NewTripPageState extends State<NewTripPage> with ScaffoldLayout {
final TextEditingController latController = TextEditingController();
final TextEditingController lonController = TextEditingController();
Trip trip = Trip();
@override
Widget build(BuildContext context) {
// floating search bar and map as a background
return mainScaffold(
context,
child: Scaffold(
body: Stack(
children: [
NewTripMap(trip),
Padding(
padding: const EdgeInsets.all(15),
child: NewTripLocationSearch(trip),
),
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
),
title: const 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

@@ -0,0 +1,124 @@
import 'package:anyway/layouts/scaffold.dart';
import 'package:anyway/modules/new_trip_button.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/preferences.dart';
import 'package:anyway/structs/trip.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class NewTripPreferencesPage extends StatefulWidget {
final Trip trip;
const NewTripPreferencesPage({super.key, required this.trip});
@override
_NewTripPreferencesPageState createState() => _NewTripPreferencesPageState();
}
class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> with ScaffoldLayout {
UserPreferences preferences = UserPreferences();
@override
Widget build(BuildContext context) {
// Ensure that the trip is "empty" save for the start landmark
// This is necessary because users can swipe back to this page even after the trip has been created
if (widget.trip.landmarks.length > 1) {
Landmark start = widget.trip.landmarks.first;
widget.trip.landmarks.clear();
widget.trip.addLandmark(start);
}
return mainScaffold(
context,
child: Scaffold(
body: ListView(
children: [
const Center(
child: Padding(
padding: EdgeInsets.only(left: 10, right: 10, top: 20, bottom: 0),
child: Text('Tell us about your ideal trip.', style: TextStyle(fontSize: 18))
),
),
const Divider(indent: 25, endIndent: 25, height: 50),
durationPicker(preferences.maxTime),
preferenceSliders([preferences.sightseeing, preferences.shopping, preferences.nature]),
// Add a conditional padding to avoid the floating button covering the last slider
Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 80),
),
]
),
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.'
],
);
}
Widget durationPicker(SinglePreference maxTime) {
return Card(
margin: const EdgeInsets.only(left: 10, right: 10, top: 10, bottom: 0),
shadowColor: Colors.grey,
child: ListTile(
leading: preferences.maxTime.icon,
title: Text(preferences.maxTime.description),
subtitle: CupertinoTimerPicker(
mode: CupertinoTimerPickerMode.hm,
initialTimerDuration: const Duration(minutes: 90),
minuteInterval: 15,
onTimerDurationChanged: (Duration newDuration) {
setState(() {
preferences.maxTime.value = newDuration.inMinutes;
});
},
)
),
);
}
Widget preferenceSliders(List<SinglePreference> prefs) {
List<Card> sliders = [];
for (SinglePreference pref in prefs) {
sliders.add(
Card(
child: ListTile(
leading: pref.icon,
title: Text(pref.name),
subtitle: Slider(
value: pref.value.toDouble(),
min: pref.minVal.toDouble(),
max: pref.maxVal.toDouble(),
divisions: pref.maxVal - pref.minVal,
label: pref.value.toString(),
onChanged: (double newValue) {
setState(() {
pref.value = newValue.toInt();
});
},
)
),
)
);
}
return Column(
children: sliders
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:anyway/pages/new_trip_location.dart';
import 'package:flutter/material.dart';
import 'package:anyway/layouts/scaffold.dart';
class NoTripsPage extends StatefulWidget {
const NoTripsPage({super.key});
@override
State<NoTripsPage> createState() => _NoTripsPageState();
}
class _NoTripsPageState extends State<NoTripsPage> with ScaffoldLayout {
@override
Widget build(BuildContext context) => mainScaffold(
context,
child: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"No trips yet",
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const Padding(padding: EdgeInsets.only(bottom: 10)),
Text(
"You can start a new trip by clicking the button below",
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NewTripPage()
)
);
},
label: const Row(
children: [
Text("Start planning!"),
Padding(padding: EdgeInsets.only(right: 8.0)),
Icon(Icons.map_outlined)
],
)
)
)
);
}

View File

@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:anyway/main.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/layouts/scaffold.dart';
bool debugMode = false;
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
_SettingsPageState createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> with ScaffoldLayout {
@override
Widget build (BuildContext context) => mainScaffold(
context,
child: ListView(
padding: const EdgeInsets.all(15),
children: [
// First a round, centered image
const Center(
child: CircleAvatar(
radius: 75,
child: Icon(Icons.settings, size: 100),
)
),
const Center(
child: Text('Global settings', style: TextStyle(fontSize: 24))
),
const Divider(indent: 25, endIndent: 25, height: 50),
darkMode(),
setLocationUsage(),
setDebugMode(),
const Divider(indent: 25, endIndent: 25, height: 50),
privacyInfo(),
]
),
title: const Text('Settings'),
helpTexts: [
'Settings',
'Preferences set in this page are global and will affect the entire application.'
],
);
Widget setDebugMode() {
return Row(
children: [
const Text('Debugging: use a custom API URL'),
// white space
const Spacer(),
Switch(
value: debugMode,
onChanged: (bool? newValue) {
setState(() {
debugMode = newValue!;
if (debugMode) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Debug mode - use a custom API endpoint'),
content: TextField(
controller: TextEditingController(text: API_URL_DEBUG),
onChanged: (value) {
setState(() {
API_URL_BASE = value;
});
},
),
actions: [
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
);
}
});
}
)
],
);
}
Widget darkMode() {
return Row(
children: [
const Text('Dark mode'),
const Spacer(),
Switch(
value: Theme.of(context).brightness == Brightness.dark,
onChanged: (bool? newValue) {
setState(() {
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text('Dark mode is not implemented yet'))
);
// if (newValue!) {
// // Dark mode
// Theme.of(context).brightness = Brightness.dark;
// } else {
// // Light mode
// Theme.of(context).brightness = Brightness.light;
// }
});
}
)
],
);
}
Widget setLocationUsage() {
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
return Row(
children: [
const Text('Use location services'),
// white space
const Spacer(),
FutureBuilder(
future: preferences,
builder: (context, snapshot) {
if (snapshot.hasData) {
bool useLocation = snapshot.data!.getBool('useLocation') ?? false;
return Switch(
value: useLocation,
onChanged: setUseLocation,
);
} else {
return const CircularProgressIndicator();
}
}
)
],
);
}
void setUseLocation(bool newValue) async {
await Permission.locationWhenInUse
.onDeniedCallback(() {
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text('Location services are required for this feature'))
);
})
.onGrantedCallback(() {
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text('Location services are now enabled'))
);
SharedPreferences.getInstance().then(
(SharedPreferences prefs) {
setState(() {
prefs.setBool('useLocation', newValue);
});
}
);
})
.onPermanentlyDeniedCallback(() {
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text('Location services are required for this feature'))
);
})
.request();
}
Widget privacyInfo() {
return Center(
child: Column(
children: [
const 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.', textAlign: TextAlign.center),
const Padding(padding: EdgeInsets.only(top: 3)),
const Text('Our full privacy policy is available under:', textAlign: TextAlign.center),
TextButton.icon(
icon: const Icon(Icons.info),
label: Text(PRIVACY_URL),
onPressed: () async{
await launchUrl(Uri.parse(PRIVACY_URL));
}
)
],
)
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:shared_preferences/shared_preferences.dart';
final class Agreement{
bool agreed;
Agreement({required this.agreed});
}
void saveAgreement(bool agreed) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool('agreed_to_terms_and_conditions', agreed);
}
Future<Agreement> getAgreement() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
return Agreement(agreed: prefs.getBool('agreed_to_terms_and_conditions') ?? false);
}

View File

@@ -0,0 +1,172 @@
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
LandmarkType typeSightseeing = LandmarkType(name: 'sightseeing');
LandmarkType typeNature = LandmarkType(name: 'nature');
LandmarkType typeShopping = LandmarkType(name: 'shopping');
// LandmarkType museum = LandmarkType(name: 'Museum');
// LandmarkType restaurant = LandmarkType(name: 'Restaurant');
LandmarkType typeStart = LandmarkType(name: 'start');
LandmarkType typeFinish = LandmarkType(name: 'finish');
final class Landmark extends LinkedListEntry<Landmark>{
// A linked node of a list of Landmarks
final String uuid;
final String name;
final List<double> location;
final LandmarkType type;
final bool? isSecondary;
// description to be shown in the overview
final String? nameEN;
final String? websiteURL;
String? imageURL; // not final because it can be patched
final String? description;
final Duration? duration;
bool visited;
// Next node is implicitly available through the LinkedListEntry mixin
// final Landmark? next;
Duration? tripTime;
// the trip time depends on the next landmark, so it is not final
Landmark({
required this.uuid,
required this.name,
required this.location,
required this.type,
this.isSecondary,
this.nameEN,
this.websiteURL,
this.imageURL,
this.description,
this.duration,
this.visited = false,
// this.next,
this.tripTime,
});
factory Landmark.fromJson(Map<String, dynamic> json) {
if (json
case { // automatically match all the non-optionals and cast them to the right type
'uuid': String uuid,
'name': String name,
'location': List<dynamic> location,
'type': String type,
}) {
// refine the parsing on a few fields
List<double> locationFixed = List<double>.from(location);
// parse the rest separately, they could be missing
LandmarkType typeFixed = LandmarkType(name: type);
final isSecondary = json['is_secondary'] as bool?;
final nameEN = json['name_en'] as String?;
final websiteURL = json['website_url'] as String?;
final imageURL = json['image_url'] as String?;
final description = json['description'] as String?;
var duration = Duration(minutes: json['duration']);
final visited = json['visited'] ?? false;
var tripTime = Duration(minutes: json['time_to_reach_next'] ?? 0) as Duration?;
return Landmark(
uuid: uuid,
name: name,
location: locationFixed,
type: typeFixed,
isSecondary: isSecondary,
nameEN: nameEN,
websiteURL: websiteURL,
imageURL: imageURL,
description: description,
duration: duration,
visited: visited,
tripTime: tripTime
);
} else {
throw FormatException('Invalid JSON: $json');
}
}
@override
bool operator ==(Object other) {
return other is Landmark && uuid == other.uuid;
}
Map<String, dynamic> toJson() => {
'uuid': uuid,
'name': name,
'location': location,
'type': type.name,
'is_secondary': isSecondary,
'name_en': nameEN,
'website_url': websiteURL,
'image_url': imageURL,
'description': description,
'duration': duration?.inMinutes,
'visited': visited,
'trip_time': tripTime?.inMinutes,
};
}
class LandmarkType {
final String name;
// final String description;
Icon icon;
LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) {
switch (name) {
case 'sightseeing':
icon = const Icon(Icons.castle);
break;
case 'nature':
icon = const Icon(Icons.eco);
break;
case 'shopping':
icon = const Icon(Icons.shopping_cart);
break;
case 'start':
icon = const Icon(Icons.play_arrow);
break;
case 'finish':
icon = const Icon(Icons.flag);
break;
default:
icon = const Icon(Icons.location_on);
}
}
@override
bool operator ==(Object other) {
if (other is LandmarkType) {
return name == other.name;
} else {
return false;
}
}
}
// Helpers
// Handling the landmarks requires a little bit of special care because the linked list is not directly representable in json
(Landmark, String?) getLandmarkFromPrefs(SharedPreferences prefs, String uuid) {
String? content = prefs.getString('landmark_$uuid');
Map<String, dynamic> json = jsonDecode(content!);
String? nextUUID = json['next_uuid'];
return (Landmark.fromJson(json), nextUUID);
}
void landmarkToPrefs(SharedPreferences prefs, Landmark current, Landmark? next) {
Map<String, dynamic> json = current.toJson();
json['next_uuid'] = next?.uuid;
prefs.setString('landmark_${current.uuid}', jsonEncode(json));
}

View File

@@ -0,0 +1,69 @@
import 'package:anyway/structs/landmark.dart';
import 'package:flutter/material.dart';
class SinglePreference {
String slug;
String name;
String description;
int value;
int minVal;
int maxVal;
Icon icon;
SinglePreference({
required this.slug,
required this.name,
required this.description,
required this.value,
required this.icon,
this.minVal = 0,
this.maxVal = 5,
});
}
class UserPreferences {
SinglePreference sightseeing = SinglePreference(
name: "Sightseeing",
slug: "sightseeing",
description: "How much do you like sightseeing?",
value: 0,
icon: typeSightseeing.icon,
);
SinglePreference shopping = SinglePreference(
name: "Shopping",
slug: "shopping",
description: "How much do you like shopping?",
value: 0,
icon: typeShopping.icon,
);
SinglePreference nature = SinglePreference(
name: "Nature",
slug: "nature",
description: "How much do you like nature?",
value: 0,
icon: typeNature.icon,
);
SinglePreference maxTime = SinglePreference(
name: "Trip duration",
slug: "duration",
description: "How long should your trip be?",
value: 30,
minVal: 30,
maxVal: 720,
icon: const Icon(Icons.timer),
);
Map<String, dynamic> toJson() {
// This is "opinionated" JSON, corresponding to the backend's expectations
return {
"sightseeing": {"type": "sightseeing", "score": sightseeing.value},
"shopping": {"type": "shopping", "score": shopping.value},
"nature": {"type": "nature", "score": nature.value},
"max_time_minute": maxTime.value
};
}
}

View File

@@ -0,0 +1,142 @@
// Represents a collection of landmarks that represent a journey
// Different instances of a Trip can be saved and loaded by the user
import 'dart:collection';
import 'dart:convert';
import 'dart:developer';
import 'package:anyway/structs/landmark.dart';
import 'package:flutter/foundation.dart';
import 'package:geocoding/geocoding.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Trip with ChangeNotifier {
String uuid;
Duration totalTime;
LinkedList<Landmark> landmarks;
// could be empty as well
String? errorDescription;
Future<String> get cityName async {
List<double>? location = landmarks.firstOrNull?.location;
if (GeocodingPlatform.instance == null) {
return '$location';
} else if (location == null) {
return 'Unknown';
} else{
List<Placemark> placemarks = await placemarkFromCoordinates(location[0], location[1]);
return placemarks.first.locality ?? 'Unknown';
}
}
Future<int> landmarkPosition (Landmark landmark) async {
int i = 0;
for (Landmark l in landmarks) {
if (l.uuid == landmark.uuid) {
return i;
} else if (l.type != typeStart && l.type != typeFinish) {
i++;
}
}
return -1;
}
Trip({
this.uuid = 'pending',
this.totalTime = Duration.zero,
LinkedList<Landmark>? landmarks
// a trip can be created with no landmarks, but the list should be initialized anyway
}) : landmarks = landmarks ?? LinkedList<Landmark>();
factory Trip.fromJson(Map<String, dynamic> json) {
Trip trip = Trip(
uuid: json['uuid'],
totalTime: Duration(minutes: json['total_time']),
);
return trip;
}
void loadFromJson(Map<String, dynamic> json) {
uuid = json['uuid'];
totalTime = Duration(minutes: json['total_time']);
notifyListeners();
}
void addLandmark(Landmark landmark) {
landmarks.add(landmark);
notifyListeners();
}
void updateUUID(String newUUID) {
uuid = newUUID;
notifyListeners();
}
void removeLandmark (Landmark landmark) async {
Landmark? previous = landmark.previous;
Landmark? next = landmark.next;
landmarks.remove(landmark);
// removing the landmark means we need to recompute the time between the two adjoined landmarks
if (previous != null && next != null) {
// previous.next = next happens automatically since we are using a LinkedList
totalTime -= previous.tripTime ?? Duration.zero;
previous.tripTime = null;
// TODO
}
totalTime -= landmark.tripTime ?? Duration.zero;
notifyListeners();
}
void updateError(String error) {
errorDescription = error;
notifyListeners();
}
void notifyUpdate(){
notifyListeners();
}
factory Trip.fromPrefs(SharedPreferences prefs, String uuid) {
String? content = prefs.getString('trip_$uuid');
Map<String, dynamic> json = jsonDecode(content!);
Trip trip = Trip.fromJson(json);
String? firstUUID = json['first_landmark_uuid'];
log('Loading trip $uuid with first landmark $firstUUID');
LinkedList<Landmark> landmarks = readLandmarks(prefs, firstUUID);
trip.landmarks = landmarks;
return trip;
}
Map<String, dynamic> toJson() => {
'uuid': uuid,
'total_time': totalTime.inMinutes,
'first_landmark_uuid': landmarks.first.uuid
};
void toPrefs(SharedPreferences prefs){
Map<String, dynamic> json = toJson();
log('Saving trip $uuid : $json');
prefs.setString('trip_$uuid', jsonEncode(json));
for (Landmark landmark in landmarks) {
landmarkToPrefs(prefs, landmark, landmark.next);
}
}
}
// Helper
LinkedList<Landmark> readLandmarks(SharedPreferences prefs, String? firstUUID) {
LinkedList<Landmark> landmarks = LinkedList<Landmark>();
while (firstUUID != null) {
var (head, nextUUID) = getLandmarkFromPrefs(prefs, firstUUID);
landmarks.add(head);
firstUUID = nextUUID;
}
return landmarks;
}

View File

@@ -0,0 +1,167 @@
import "dart:async";
import "dart:convert";
import "dart:developer";
import "dart:io";
import "package:anyway/main.dart";
import 'package:dio/dio.dart';
import 'package:anyway/constants.dart';
import "package:anyway/utils/load_landmark_image.dart";
import "package:anyway/structs/landmark.dart";
import "package:anyway/structs/trip.dart";
import "package:anyway/structs/preferences.dart";
Dio dio = Dio(
BaseOptions(
baseUrl: API_URL_BASE,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 120),
// also accept 500 errors, since we cannot rule out that the server is at fault. We still want to gracefully handle these errors
validateStatus: (status) => status! <= 500,
receiveDataWhenStatusError: true,
contentType: Headers.jsonContentType,
responseType: ResponseType.json,
),
);
fetchTrip(
Trip trip,
UserPreferences preferences,
) async {
Map<String, dynamic> data = {
"preferences": preferences.toJson(),
"start": trip.landmarks.first.location,
};
String dataString = jsonEncode(data);
log(dataString);
late Response response;
try {
response = await dio.post(
"/trip/new",
data: data
);
} catch (e) {
trip.updateUUID("error");
// Format the error message to be more user friendly
String errorDescription;
if (e is DioException) {
errorDescription = e.message ?? "Unknown error";
} else if (e is SocketException) {
errorDescription = "No internet connection";
} else if (e is TimeoutException) {
errorDescription = "Request timed out";
} else {
errorDescription = "Unknown error";
}
String errorMessage = """
We're sorry, the following error was generated:
${errorDescription.trim()}
""".trim();
trip.updateError(errorMessage);
log(e.toString());
log(errorMessage);
return;
}
// handle more specific errors
if (response.statusCode != 200) {
trip.updateUUID("error");
String errorDescription;
if (response.data.runtimeType == String) {
errorDescription = response.data;
} else if (response.data.runtimeType == Map<String, dynamic>) {
errorDescription = response.data["detail"] ?? "Unknown error";
} else {
errorDescription = "Unknown error";
}
String errorMessage = """
We're sorry, our servers generated the following error:
${errorDescription.trim()}
Please try again.
""".trim();
trip.updateError(errorMessage);
log(errorMessage);
// Actualy no need to throw an exception, we can just log the error and let the user retry
// throw Exception(errorDetail);
} else {
// if the response data is not json, throw an error
if (response.data is! Map<String, dynamic>) {
log("${response.data.runtimeType}");
trip.updateUUID("error");
String errorMessage = """
We're sorry, our servers generated the following error:
${response.data.trim()}
Please try again.
""".trim();
trip.updateError(errorMessage);
log(errorMessage);
return;
}
Map<String, dynamic> json = response.data;
// only fill in the trip "meta" data for now
trip.loadFromJson(json);
// now fill the trip with landmarks
// we are going to recreate ALL the landmarks from the information given by the api
trip.landmarks.remove(trip.landmarks.first);
String? nextUUID = json["first_landmark_uuid"];
while (nextUUID != null) {
var (landmark, newUUID) = await fetchLandmark(nextUUID);
trip.addLandmark(landmark);
nextUUID = newUUID;
}
log(response.data.toString());
// Also save the trip for the user's convenience
savedTrips.addTrip(trip);
}
}
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(
"/landmark/$uuid"
);
// handle errors
if (response.statusCode != 200) {
throw Exception('Failed to load landmark');
}
if (response.data["detail"] != null) {
throw Exception(response.data["detail"]);
}
// log(response.data.toString());
Map<String, dynamic> json = response.data;
String? nextUUID = json["next_uuid"];
Landmark landmark = Landmark.fromJson(json);
patchLandmarkImage(landmark);
return (landmark, nextUUID);
}

View File

@@ -0,0 +1,50 @@
import 'package:anyway/main.dart';
import 'package:anyway/pages/no_trips_page.dart';
import 'package:anyway/structs/agreement.dart';
import 'package:flutter/material.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/onboarding.dart';
Widget getFirstPage() {
// check if the user has already seen the onboarding and agreed to the terms of service
return FutureBuilder(
future: getAgreement(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
Agreement agrement = snapshot.data!;
if (agrement.agreed) {
return FutureBuilder(
future: savedTrips.loadTrips(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
List<Trip> trips = snapshot.data!;
if (trips.isNotEmpty) {
return TripPage(trip: trips[0]);
} else {
return const NoTripsPage();
}
} else {
return const Center(child: CircularProgressIndicator());
}
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
} else {
return const OnboardingPage();
}
} else {
return const OnboardingPage();
}
} else {
return const OnboardingPage();
}
},
);
}

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

@@ -0,0 +1,40 @@
import 'package:anyway/structs/trip.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
class SavedTrips extends ChangeNotifier {
List<Trip> _trips = [];
List<Trip> get trips => _trips;
Future<List<Trip>> loadTrips() async {
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();
return trips;
}
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();
}
}