feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI

This commit is contained in:
2025-12-30 00:51:40 +01:00
parent 81ed2fd8c3
commit 014b48591e
28 changed files with 2395 additions and 387 deletions

View File

@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import 'package:your_project/models/trip_request.dart';
import 'package:your_project/presentation/widgets/new_trip_map.dart';
import 'package:your_project/presentation/widgets/new_trip_location_search.dart';
class CreateTripLocation extends StatelessWidget {
final TripRequest tripRequest;
const CreateTripLocation(this.tripRequest, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
NewTripMap(tripRequest),
Padding(
padding: const EdgeInsets.all(15),
child: NewTripLocationSearch(tripRequest),
),
],
);
}
}

View File

@@ -1,105 +0,0 @@
// 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 {
@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

@@ -1,124 +0,0 @@
// A search bar that allow the user to enter a city name
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:geocoding/geocoding.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,62 @@
import 'package:flutter/material.dart';
import 'package:anyway/core/constants.dart';
import 'package:anyway/presentation/utils/trip_location_utils.dart';
class TripHeroHeader extends StatelessWidget {
const TripHeroHeader({super.key, this.localeInfo});
final TripLocaleInfo? localeInfo;
@override
Widget build(BuildContext context) {
final resolvedCity = localeInfo?.hasResolvedCity == true ? localeInfo!.cityName : null;
final title = resolvedCity == null ? 'Welcome to your trip!' : 'Welcome to $resolvedCity!';
final flag = localeInfo?.flagEmoji ?? '🏁';
return SizedBox(
height: 70,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
DecoratedBox(
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0x11000000)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Text(flag, style: const TextStyle(fontSize: 26)),
),
),
const SizedBox(width: 12),
_GradientText(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800, letterSpacing: -0.2)),
],
),
),
),
);
}
}
class _GradientText extends StatelessWidget {
const _GradientText(this.text, {this.style});
final String text;
final TextStyle? style;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback: (bounds) => APP_GRADIENT.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
blendMode: BlendMode.srcIn,
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: (style ?? const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)).copyWith(color: Colors.white),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
class TripHeroMetaCard extends StatelessWidget {
const TripHeroMetaCard({super.key, required this.subtitle, required this.totalStops, required this.totalMinutes, this.countryName, this.isLoading = false});
final String subtitle;
final int totalStops;
final int? totalMinutes;
final String? countryName;
final bool isLoading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalTimeLabel = _formatTripDuration(totalMinutes);
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
elevation: 6,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(subtitle, style: theme.textTheme.titleSmall?.copyWith(height: 1.3)),
if (countryName != null && countryName!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.public, size: 16),
const SizedBox(width: 6),
Text(countryName!, style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.2)),
],
),
),
if (isLoading) const Padding(padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator(minHeight: 3)),
],
),
),
const SizedBox(width: 16),
_HeroStat(icon: Icons.flag, label: 'Stops', value: '$totalStops'),
const SizedBox(width: 14),
_HeroStat(icon: Icons.hourglass_bottom_rounded, label: 'Total time', value: totalTimeLabel),
],
),
),
);
}
}
class _HeroStat extends StatelessWidget {
const _HeroStat({required this.icon, required this.label, required this.value});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.grey.shade600),
const SizedBox(width: 4),
Text(label, style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey.shade600)),
],
),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
],
);
}
}
String _formatTripDuration(int? totalMinutes) {
if (totalMinutes == null) return '--';
final hours = totalMinutes ~/ 60;
final minutes = totalMinutes % 60;
if (hours == 0) return '${minutes}m';
if (minutes == 0) return '${hours}h';
return '${hours}h ${minutes}m';
}

View File

@@ -0,0 +1,292 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:anyway/domain/entities/landmark.dart';
import 'package:anyway/domain/entities/landmark_type.dart';
class TripLandmarkCard extends StatelessWidget {
const TripLandmarkCard({super.key, required this.landmark, required this.position, required this.onToggleVisited, this.onOpenWebsite});
final Landmark landmark;
final int position;
final VoidCallback onToggleVisited;
final VoidCallback? onOpenWebsite;
@override
Widget build(BuildContext context) {
final visited = landmark.isVisited;
final metaItems = _buildMetaItems(
duration: landmark.durationMinutes,
reachTime: landmark.timeToReachNextMinutes,
tags: landmark.description?.tags ?? const [],
tagCount: landmark.tagCount,
attractiveness: landmark.attractiveness,
isSecondary: landmark.isSecondary ?? false,
websiteLabel: landmark.websiteUrl,
onOpenWebsite: onOpenWebsite,
);
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
clipBehavior: Clip.antiAlias,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_LandmarkMedia(landmark: landmark, position: position, type: landmark.type.type),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
landmark.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
IconButton(
iconSize: 22,
padding: EdgeInsets.zero,
splashRadius: 18,
tooltip: visited ? 'Mark as pending' : 'Mark visited',
onPressed: onToggleVisited,
icon: Icon(visited ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: visited ? Colors.green : Colors.grey),
),
],
),
if (landmark.nameEn != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
landmark.nameEn!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.grey.shade600),
),
),
if (landmark.description?.description != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(landmark.description!.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.3)),
),
if (metaItems.isNotEmpty) ...[const SizedBox(height: 10), _MetaScroller(items: metaItems)],
],
),
),
),
],
),
),
);
}
List<_MetaItem> _buildMetaItems({
int? duration,
int? reachTime,
List<String> tags = const [],
int? tagCount,
int? attractiveness,
bool isSecondary = false,
String? websiteLabel,
VoidCallback? onOpenWebsite,
}) {
final items = <_MetaItem>[];
if (duration != null) {
items.add(_MetaItem(icon: Icons.timer_outlined, label: '$duration min stay'));
}
if (reachTime != null) {
items.add(_MetaItem(icon: Icons.directions_walk, label: '$reachTime min walk'));
}
if (attractiveness != null) {
items.add(_MetaItem(icon: Icons.star, label: 'Score $attractiveness'));
}
if (tagCount != null) {
items.add(_MetaItem(icon: Icons.label, label: '$tagCount tags'));
}
if (isSecondary) {
items.add(_MetaItem(icon: Icons.layers, label: 'Secondary stop'));
}
for (final tag in tags.where((tag) => tag.trim().isNotEmpty).take(4)) {
items.add(_MetaItem(icon: Icons.local_offer, label: tag));
}
if (websiteLabel != null && websiteLabel.isNotEmpty && onOpenWebsite != null) {
items.add(_MetaItem(icon: Icons.link, label: 'Website', onTap: onOpenWebsite));
}
return items;
}
}
class _LandmarkMedia extends StatelessWidget {
const _LandmarkMedia({required this.landmark, required this.position, required this.type});
final Landmark landmark;
final int position;
final LandmarkTypeEnum type;
@override
Widget build(BuildContext context) {
const width = 116.0;
const mediaHeight = 140.0;
final label = _typeLabel(type);
final icon = _typeIcon(type);
return SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: mediaHeight,
child: Stack(
children: [
Positioned.fill(child: _buildMedia()),
Positioned(
left: 8,
bottom: 8,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: const TextStyle(color: Colors.white, fontSize: 12)),
],
),
),
),
),
],
),
),
Container(
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(vertical: 5),
child: Center(
child: Text(
'#$position',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}
Widget _buildMedia() {
if (landmark.imageUrl != null && landmark.imageUrl!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: landmark.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
errorWidget: (context, url, error) => _placeholder(),
);
}
return _placeholder();
}
Widget _placeholder() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF9B208), Color(0xFFE72E77)]),
),
child: const Center(child: Icon(Icons.photo, color: Colors.white, size: 32)),
);
}
String _typeLabel(LandmarkTypeEnum type) {
switch (type) {
case LandmarkTypeEnum.start:
return 'Start';
case LandmarkTypeEnum.finish:
return 'Finish';
case LandmarkTypeEnum.shopping:
return 'Shopping';
case LandmarkTypeEnum.nature:
return 'Nature';
case LandmarkTypeEnum.sightseeing:
return 'Sight';
}
}
IconData _typeIcon(LandmarkTypeEnum type) {
switch (type) {
case LandmarkTypeEnum.start:
return Icons.flag;
case LandmarkTypeEnum.finish:
return Icons.flag_circle;
case LandmarkTypeEnum.shopping:
return Icons.shopping_bag;
case LandmarkTypeEnum.nature:
return Icons.park;
case LandmarkTypeEnum.sightseeing:
return Icons.place;
}
}
}
class _MetaScroller extends StatelessWidget {
const _MetaScroller({required this.items});
final List<_MetaItem> items;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 34,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final item = items[index];
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, size: 14, color: Colors.grey.shade800),
const SizedBox(width: 4),
Text(item.label, style: const TextStyle(fontSize: 12)),
],
),
);
if (item.onTap == null) return chip;
return Material(
color: Colors.transparent,
child: InkWell(borderRadius: BorderRadius.circular(14), onTap: item.onTap, child: chip),
);
},
),
);
}
}
class _MetaItem {
const _MetaItem({required this.icon, required this.label, this.onTap});
final IconData icon;
final String label;
final VoidCallback? onTap;
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:anyway/domain/entities/landmark.dart';
class TripStepBetweenLandmarks extends StatelessWidget {
const TripStepBetweenLandmarks({super.key, required this.current, required this.next, this.onRequestDirections});
final Landmark current;
final Landmark next;
final VoidCallback? onRequestDirections;
@override
Widget build(BuildContext context) {
final minutes = current.timeToReachNextMinutes;
final text = minutes == null ? 'Continue to ${next.name}' : '$minutes min to ${next.name}';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const Icon(Icons.directions_walk, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: const TextStyle(fontWeight: FontWeight.w600)),
),
if (onRequestDirections != null) TextButton.icon(onPressed: onRequestDirections, icon: const Icon(Icons.navigation), label: const Text('Navigate')),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:map_launcher/map_launcher.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:anyway/domain/entities/landmark.dart';
import 'package:anyway/domain/entities/trip.dart';
import 'package:anyway/presentation/utils/trip_location_utils.dart';
import 'package:anyway/presentation/widgets/trip_details/trip_hero_header.dart';
import 'package:anyway/presentation/widgets/trip_details/trip_hero_meta_card.dart';
import 'package:anyway/presentation/widgets/trip_details/trip_landmark_card.dart';
import 'package:anyway/presentation/widgets/trip_details/trip_step_between_landmarks.dart';
class TripDetailsPanel extends StatefulWidget {
const TripDetailsPanel({super.key, required this.trip, required this.controller, this.onLandmarkUpdated});
final Trip trip;
final ScrollController controller;
final VoidCallback? onLandmarkUpdated;
@override
State<TripDetailsPanel> createState() => _TripDetailsPanelState();
}
class _TripDetailsPanelState extends State<TripDetailsPanel> {
late Future<TripLocaleInfo> _localeFuture;
@override
void initState() {
super.initState();
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
}
@override
void didUpdateWidget(covariant TripDetailsPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trip.uuid != widget.trip.uuid) {
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
}
}
@override
Widget build(BuildContext context) {
final landmarks = widget.trip.landmarks;
if (landmarks.isEmpty) {
return const Center(child: Text('No landmarks in this trip yet.'));
}
return CustomScrollView(
controller: widget.controller,
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: 10),
Container(
width: 42,
height: 5,
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(12)),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FutureBuilder<TripLocaleInfo>(
future: _localeFuture,
builder: (context, snapshot) {
final info = snapshot.data;
final isLoading = snapshot.connectionState == ConnectionState.waiting;
final subtitle = isLoading ? 'Pinpointing your destination...' : 'Here is a recommended route for your trip in ${info?.cityName ?? 'your area'}.';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TripHeroHeader(localeInfo: info),
const SizedBox(height: 10),
TripHeroMetaCard(
subtitle: subtitle,
totalStops: widget.trip.landmarks.length,
totalMinutes: widget.trip.totalTimeMinutes,
countryName: info?.countryName,
isLoading: isLoading,
),
],
);
},
),
),
const SizedBox(height: 14),
],
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final landmark = landmarks[index];
return Column(
children: [
TripLandmarkCard(
landmark: landmark,
position: index + 1,
onToggleVisited: () => _toggleVisited(landmark),
onOpenWebsite: landmark.websiteUrl == null ? null : () => _openWebsite(landmark.websiteUrl!),
),
if (index < landmarks.length - 1)
TripStepBetweenLandmarks(current: landmark, next: landmarks[index + 1], onRequestDirections: () => _showNavigationOptions(context, landmark, landmarks[index + 1])),
],
);
}, childCount: landmarks.length),
),
),
],
);
}
void _toggleVisited(Landmark landmark) {
setState(() => landmark.isVisited = !landmark.isVisited);
widget.onLandmarkUpdated?.call();
}
Future<void> _openWebsite(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _showNavigationOptions(BuildContext context, Landmark current, Landmark next) async {
if (!_hasValidLocation(current) || !_hasValidLocation(next)) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Coordinates unavailable for directions.')));
return;
}
List<AvailableMap> availableMaps = [];
try {
availableMaps = await MapLauncher.installedMaps;
} catch (error) {
debugPrint('Unable to load maps: $error');
}
if (!context.mounted) return;
if (availableMaps.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No navigation apps detected.')));
return;
}
final origin = Coords(current.location[0], current.location[1]);
final destination = Coords(next.location[0], next.location[1]);
await showModalBottomSheet<void>(
context: context,
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Navigate to ${next.name}', style: Theme.of(sheetContext).textTheme.titleMedium),
const SizedBox(height: 4),
Text('Choose your preferred maps app.', style: Theme.of(sheetContext).textTheme.bodySmall),
],
),
),
for (final map in availableMaps)
ListTile(
leading: ClipRRect(borderRadius: BorderRadius.circular(8), child: SvgPicture.asset(map.icon, height: 30, width: 30)),
title: Text(map.mapName),
onTap: () async {
await map.showDirections(origin: origin, originTitle: current.name, destination: destination, destinationTitle: next.name, directionsMode: DirectionsMode.walking);
if (sheetContext.mounted) Navigator.of(sheetContext).pop();
},
),
],
),
);
},
);
}
bool _hasValidLocation(Landmark landmark) => landmark.location.length >= 2;
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
import 'package:anyway/core/constants.dart';
import 'package:anyway/domain/entities/landmark.dart';
import 'package:anyway/domain/entities/trip.dart';
import 'package:anyway/presentation/utils/trip_location_utils.dart';
import 'package:anyway/presentation/widgets/trip_marker_graphic.dart';
class TripMap extends StatefulWidget {
const TripMap({
super.key,
required this.trip,
this.showRoute = false,
this.interactive = false,
this.height,
this.borderRadius = 12,
this.enableMyLocation = false,
});
final Trip trip;
final bool showRoute;
final bool interactive;
final double? height;
final double borderRadius;
final bool enableMyLocation;
@override
State<TripMap> createState() => _TripMapState();
}
class _TripMapState extends State<TripMap> {
GoogleMapController? _controller;
LatLng? _startLatLng;
Set<Marker> _markers = const {};
@override
void initState() {
super.initState();
_startLatLng = _extractStartLatLng();
_refreshMarkers();
}
@override
void didUpdateWidget(covariant TripMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trip.uuid != widget.trip.uuid) {
_startLatLng = _extractStartLatLng();
_animateToStart();
}
if (oldWidget.trip.landmarks != widget.trip.landmarks ||
oldWidget.showRoute != widget.showRoute ||
oldWidget.interactive != widget.interactive) {
_startLatLng = _extractStartLatLng();
}
_refreshMarkers();
}
LatLng? _extractStartLatLng() {
final coords = TripLocationUtils.startCoordinates(widget.trip);
if (coords == null) {
return null;
}
return LatLng(coords[0], coords[1]);
}
void _animateToStart() {
// TODO - required?
if (_controller == null || _startLatLng == null) {
return;
}
_controller!.animateCamera(CameraUpdate.newLatLng(_startLatLng!));
}
Future<void> _refreshMarkers() async {
if (_startLatLng == null) {
setState(() => _markers = const {});
return;
}
final targets = widget.showRoute
? widget.trip.landmarks
: widget.trip.landmarks.isEmpty
? const <Landmark>[]
: <Landmark>[widget.trip.landmarks.first];
if (targets.isEmpty) {
setState(() => _markers = const {});
return;
}
final markerSet = <Marker>{};
for (var i = 0; i < targets.length; i++) {
final landmark = targets[i];
final latLng = _latLngFromLandmark(landmark);
if (latLng == null) continue;
final descriptor = await TripMarkerGraphic(
landmark: landmark,
position: i + 1,
compact: !widget.showRoute,
).toBitmapDescriptor(
// use sizes based on font size to keep markers consistent:
imageSize: Size(500, 500),
);
markerSet.add(
Marker(
markerId: MarkerId('landmark-${landmark.uuid}'),
// since the marker is a cirlce (not a pin), we center it
anchor: const Offset(0, 0),
position: latLng,
icon: descriptor,
// TODO - don't use info window but bind taps to the bottom panel
// infoWindow: InfoWindow(title: landmark.name),
),
);
}
if (!mounted) return;
setState(() => _markers = markerSet);
}
LatLng? _latLngFromLandmark(Landmark landmark) {
if (landmark.location.length < 2) {
return null;
}
return LatLng(landmark.location[0], landmark.location[1]);
}
Set<Polyline> _buildPolylines() {
if (!widget.showRoute) {
return const <Polyline>{};
}
final points = widget.trip.landmarks;
if (points.length < 2) {
return const <Polyline>{};
}
final polylines = <Polyline>{};
for (var i = 0; i < points.length - 1; i++) {
final current = points[i];
final next = points[i + 1];
if (current.location.length < 2 || next.location.length < 2) {
continue;
}
polylines.add(
Polyline(
polylineId: PolylineId('segment-${current.uuid}-${next.uuid}'),
points: [
LatLng(current.location[0], current.location[1]),
LatLng(next.location[0], next.location[1]),
],
width: 4,
color: points[i].isVisited && next.isVisited
? Colors.grey
: PRIMARY_COLOR,
),
);
}
return polylines;
}
@override
Widget build(BuildContext context) {
if (_startLatLng == null) {
return Container(
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(widget.borderRadius),
),
alignment: Alignment.center,
child: const Text('No landmarks available yet'),
);
}
final map = GoogleMap(
key: ValueKey(
'trip-map-${widget.trip.uuid}-${widget.showRoute}-${widget.interactive}',
),
onMapCreated: (controller) {
_controller = controller;
_animateToStart();
},
initialCameraPosition: CameraPosition(
target: _startLatLng!,
zoom: widget.interactive ? 12.5 : 13.5,
),
markers: _markers,
polylines: _buildPolylines(),
mapToolbarEnabled: widget.interactive,
zoomControlsEnabled: widget.interactive,
zoomGesturesEnabled: widget.interactive,
scrollGesturesEnabled: widget.interactive,
rotateGesturesEnabled: widget.interactive,
tiltGesturesEnabled: widget.interactive,
liteModeEnabled: !widget.interactive,
myLocationEnabled: widget.enableMyLocation && widget.interactive,
myLocationButtonEnabled: widget.enableMyLocation && widget.interactive,
compassEnabled: widget.interactive,
cloudMapId: MAP_ID,
);
Widget decoratedMap = map;
if (widget.height != null) {
decoratedMap = SizedBox(height: widget.height, child: decoratedMap);
}
if (widget.borderRadius > 0) {
decoratedMap = ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: decoratedMap,
);
}
return decoratedMap;
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:anyway/core/constants.dart';
import 'package:anyway/domain/entities/landmark.dart';
import 'package:anyway/domain/entities/landmark_type.dart';
class TripMarkerGraphic extends StatelessWidget {
const TripMarkerGraphic({
super.key,
required this.landmark,
required this.position,
this.compact = false,
});
final Landmark landmark;
final int position;
final bool compact;
@override
Widget build(BuildContext context) {
final gradient = landmark.isVisited
? const LinearGradient(colors: [Colors.grey, Colors.white])
: APP_GRADIENT;
final showPosition = landmark.type.type != LandmarkTypeEnum.start &&
landmark.type.type != LandmarkTypeEnum.finish;
final markerDiameter = compact ? 68.0 : 84.0;
final borderWidth = compact ? 3.0 : 4.5;
return Material(
color: Colors.transparent,
child: SizedBox(
width: markerDiameter,
height: markerDiameter,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
decoration: BoxDecoration(
gradient: gradient,
shape: BoxShape.circle,
border: Border.all(color: Colors.black87, width: borderWidth),
boxShadow: const [
BoxShadow(
blurRadius: 6,
color: Colors.black26,
offset: Offset(0, 3),
),
],
),
padding: EdgeInsets.all(compact ? 8 : 10),
child: Icon(
_iconForType(landmark.type.type),
color: Colors.black87,
size: compact ? 30 : 38,
),
),
if (showPosition)
Positioned(
top: -2,
right: -2,
child: Container(
padding: EdgeInsets.all(compact ? 4 : 5),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
child: Text(
position.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: compact ? 12 : 14,
color: Colors.black87,
),
),
),
),
],
),
),
);
}
// TODO - should this be a landmark property?
IconData _iconForType(LandmarkTypeEnum type) {
switch (type) {
case LandmarkTypeEnum.start:
return Icons.flag;
case LandmarkTypeEnum.finish:
return Icons.flag_circle;
case LandmarkTypeEnum.shopping:
return Icons.shopping_bag;
case LandmarkTypeEnum.nature:
return Icons.park;
case LandmarkTypeEnum.sightseeing:
return Icons.place;
}
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:anyway/domain/entities/trip.dart';
import 'package:anyway/presentation/utils/trip_location_utils.dart';
import 'package:anyway/presentation/widgets/trip_map.dart';
class TripSummaryCard extends StatefulWidget {
const TripSummaryCard({super.key, required this.trip, required this.onTap});
final Trip trip;
final VoidCallback onTap;
@override
State<TripSummaryCard> createState() => _TripSummaryCardState();
}
class _TripSummaryCardState extends State<TripSummaryCard> {
late Future<String> _cityFuture;
@override
void initState() {
super.initState();
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
}
@override
void didUpdateWidget(covariant TripSummaryCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.trip.uuid != widget.trip.uuid) {
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
}
}
@override
Widget build(BuildContext context) {
final landmarksCount = widget.trip.landmarks.length;
final startCoords = TripLocationUtils.startCoordinates(widget.trip);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TripMap(
trip: widget.trip,
showRoute: false,
interactive: false,
height: 180,
borderRadius: 0,
),
// TODO - a more useful information to include will be the duration and the time of creation. But we are not there yet.
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: FutureBuilder<String>(
future: _cityFuture,
builder: (context, snapshot) {
final title = snapshot.data ?? 'Trip ${widget.trip.uuid}';
return Text(
title,
style: Theme.of(context).textTheme.titleLarge,
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
// TODO - start should be more descriptive. or omitted
child: Text(
startCoords == null
? 'Start: unknown'
: 'Start: ${startCoords[0].toStringAsFixed(4)}, '
'${startCoords[1].toStringAsFixed(4)}',
style: Theme.of(context).textTheme.bodySmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
const Icon(Icons.route, size: 16),
const SizedBox(width: 4),
Text('$landmarksCount stops'),
const Spacer(),
const Icon(Icons.chevron_right),
],
),
),
],
),
),
);
}
}