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