From 014b48591e877921fb53addd578e7590f93a304a Mon Sep 17 00:00:00 2001 From: Remy Moll Date: Tue, 30 Dec 2025 00:51:40 +0100 Subject: [PATCH] feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI --- frontend/analysis_options.yaml | 3 + .../datasources/trip_local_datasource.dart | 83 +++++ .../repositories/backend_trip_repository.dart | 29 +- frontend/lib/domain/entities/landmark.dart | 6 + .../domain/repositories/trip_repository.dart | 10 + .../lib/presentation/pages/create_trip.dart | 249 +++++++++++++-- .../pages/new_trip_preferences.dart | 161 ++++++++++ frontend/lib/presentation/pages/start.dart | 162 +++++----- .../pages/trip_creation_flow.dart | 287 +++++++++++++++++ .../presentation/pages/trip_details_page.dart | 65 ++++ .../providers/core_providers.dart | 18 +- .../presentation/providers/trip_provider.dart | 86 +++++- .../utils/trip_location_utils.dart | 78 +++++ .../widgets/create_trip_location.dart | 23 -- .../widgets/create_trip_location_map.dart | 105 ------- .../widgets/create_trip_location_search.dart | 124 -------- .../trip_details/trip_hero_header.dart | 62 ++++ .../trip_details/trip_hero_meta_card.dart | 93 ++++++ .../trip_details/trip_landmark_card.dart | 292 ++++++++++++++++++ .../trip_step_between_landmarks.dart | 41 +++ .../widgets/trip_details_panel.dart | 185 +++++++++++ .../lib/presentation/widgets/trip_map.dart | 221 +++++++++++++ .../widgets/trip_marker_graphic.dart | 99 ++++++ .../widgets/trip_summary_card.dart | 95 ++++++ frontend/pubspec.lock | 10 +- frontend/pubspec.yaml | 2 +- .../test/current_trips_notifier_test.dart | 124 ++++++++ frontend/test/trip_local_datasource_test.dart | 69 +++++ 28 files changed, 2395 insertions(+), 387 deletions(-) create mode 100644 frontend/lib/presentation/pages/new_trip_preferences.dart create mode 100644 frontend/lib/presentation/pages/trip_creation_flow.dart create mode 100644 frontend/lib/presentation/pages/trip_details_page.dart create mode 100644 frontend/lib/presentation/utils/trip_location_utils.dart delete mode 100644 frontend/lib/presentation/widgets/create_trip_location.dart delete mode 100644 frontend/lib/presentation/widgets/create_trip_location_map.dart delete mode 100644 frontend/lib/presentation/widgets/create_trip_location_search.dart create mode 100644 frontend/lib/presentation/widgets/trip_details/trip_hero_header.dart create mode 100644 frontend/lib/presentation/widgets/trip_details/trip_hero_meta_card.dart create mode 100644 frontend/lib/presentation/widgets/trip_details/trip_landmark_card.dart create mode 100644 frontend/lib/presentation/widgets/trip_details/trip_step_between_landmarks.dart create mode 100644 frontend/lib/presentation/widgets/trip_details_panel.dart create mode 100644 frontend/lib/presentation/widgets/trip_map.dart create mode 100644 frontend/lib/presentation/widgets/trip_marker_graphic.dart create mode 100644 frontend/lib/presentation/widgets/trip_summary_card.dart create mode 100644 frontend/test/current_trips_notifier_test.dart create mode 100644 frontend/test/trip_local_datasource_test.dart diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml index c2542fc..998ca59 100644 --- a/frontend/analysis_options.yaml +++ b/frontend/analysis_options.yaml @@ -33,3 +33,6 @@ analyzer: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +formatter: + page_width: 200 diff --git a/frontend/lib/data/datasources/trip_local_datasource.dart b/frontend/lib/data/datasources/trip_local_datasource.dart index e69de29..dd1cf7b 100644 --- a/frontend/lib/data/datasources/trip_local_datasource.dart +++ b/frontend/lib/data/datasources/trip_local_datasource.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Defines the contract for persisting trip payloads locally. +abstract class TripLocalDataSource { + /// Returns all saved trip JSON payloads, newest first. + Future>> loadTrips(); + + /// Returns a single trip payload by uuid if present. + /// TODO - should directly return Trip? + Future?> getTrip(String uuid); + + /// Upserts the provided trip payload (also used for editing existing trips). + Future upsertTrip(Map tripJson); + + /// Removes the trip with the matching uuid. + Future deleteTrip(String uuid); +} + +class TripLocalDataSourceImpl implements TripLocalDataSource { + TripLocalDataSourceImpl({Future? preferences}) + : _prefsFuture = preferences ?? SharedPreferences.getInstance(); + + static const String _storageKey = 'savedTrips'; + final Future _prefsFuture; + + @override + Future>> loadTrips() async { + final prefs = await _prefsFuture; + final stored = prefs.getStringList(_storageKey); + if (stored == null) return []; + return stored.map(_decodeTrip).toList(); + } + + @override + Future?> getTrip(String uuid) async { + final trips = await loadTrips(); + for (final trip in trips) { + if (trip['uuid'] == uuid) { + return Map.from(trip); + } + } + return null; + } + + @override + Future upsertTrip(Map tripJson) async { + final uuid = tripJson['uuid']; + if (uuid is! String || uuid.isEmpty) { + throw ArgumentError('Trip JSON must contain a uuid string'); + } + + final trips = await loadTrips(); + trips.removeWhere((trip) => trip['uuid'] == uuid); + trips.insert(0, Map.from(tripJson)); + await _persistTrips(trips); + } + + @override + Future deleteTrip(String uuid) async { + final trips = await loadTrips(); + final updated = trips.where((trip) => trip['uuid'] != uuid).toList(); + if (updated.length == trips.length) { + return; + } + await _persistTrips(updated); + } + + Future _persistTrips(List> trips) async { + final prefs = await _prefsFuture; + final payload = trips.map(jsonEncode).toList(); + await prefs.setStringList(_storageKey, payload); + } + + Map _decodeTrip(String raw) { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Saved trip entry is not a JSON object'); + } + return Map.from(decoded); + } +} diff --git a/frontend/lib/data/repositories/backend_trip_repository.dart b/frontend/lib/data/repositories/backend_trip_repository.dart index ef8f425..c78d0f1 100644 --- a/frontend/lib/data/repositories/backend_trip_repository.dart +++ b/frontend/lib/data/repositories/backend_trip_repository.dart @@ -1,13 +1,15 @@ +import 'package:anyway/data/datasources/trip_local_datasource.dart'; +import 'package:anyway/data/datasources/trip_remote_datasource.dart'; import 'package:anyway/domain/entities/landmark.dart'; import 'package:anyway/domain/entities/preferences.dart'; import 'package:anyway/domain/entities/trip.dart'; import 'package:anyway/domain/repositories/trip_repository.dart'; -import 'package:anyway/data/datasources/trip_remote_datasource.dart'; class BackendTripRepository implements TripRepository { final TripRemoteDataSource remote; + final TripLocalDataSource local; - BackendTripRepository({required this.remote}); + BackendTripRepository({required this.remote, required this.local}); @override Future getTrip({Preferences? preferences, String? tripUUID, List? landmarks}) async { @@ -86,4 +88,27 @@ class BackendTripRepository implements TripRepository { prefsPayload['max_time_minute'] = preferences.maxTimeMinutes; return prefsPayload; } + + // TODO - should this be moved to a separate local repository? + @override + Future> getSavedTrips() async { + final rawTrips = await local.loadTrips(); + return rawTrips.map(Trip.fromJson).toList(growable: false); + } + + @override + Future getSavedTrip(String uuid) async { + final json = await local.getTrip(uuid); + return json == null ? null : Trip.fromJson(json); + } + + @override + Future saveTrip(Trip trip) async { + await local.upsertTrip(trip.toJson()); + } + + @override + Future deleteSavedTrip(String uuid) async { + await local.deleteTrip(uuid); + } } diff --git a/frontend/lib/domain/entities/landmark.dart b/frontend/lib/domain/entities/landmark.dart index 757371e..9fef869 100644 --- a/frontend/lib/domain/entities/landmark.dart +++ b/frontend/lib/domain/entities/landmark.dart @@ -48,3 +48,9 @@ abstract class Landmark with _$Landmark { factory Landmark.fromJson(Map json) => _$LandmarkFromJson(json); } + +extension LandmarkVisitX on Landmark { + bool get isVisited => visited ?? false; + + set isVisited(bool value) => visited = value; +} diff --git a/frontend/lib/domain/repositories/trip_repository.dart b/frontend/lib/domain/repositories/trip_repository.dart index a09036b..c7c5131 100644 --- a/frontend/lib/domain/repositories/trip_repository.dart +++ b/frontend/lib/domain/repositories/trip_repository.dart @@ -6,4 +6,14 @@ abstract class TripRepository { Future getTrip({Preferences? preferences, String? tripUUID, List? landmarks}); Future> searchLandmarks(Preferences preferences); + + // TODO - should these be moved to a separate local repository? + // not every TripRepository should have a concept of "all saved trips" + Future> getSavedTrips(); + + Future getSavedTrip(String uuid); + + Future saveTrip(Trip trip); + + Future deleteSavedTrip(String uuid); } diff --git a/frontend/lib/presentation/pages/create_trip.dart b/frontend/lib/presentation/pages/create_trip.dart index 078fa06..e7b606e 100644 --- a/frontend/lib/presentation/pages/create_trip.dart +++ b/frontend/lib/presentation/pages/create_trip.dart @@ -1,50 +1,241 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:anyway/domain/entities/preferences.dart'; +import 'package:anyway/presentation/providers/trip_provider.dart'; +import 'package:anyway/presentation/pages/new_trip_preferences.dart'; +import 'package:anyway/presentation/pages/trip_creation_flow.dart'; +import 'package:anyway/domain/entities/landmark.dart'; +import 'package:anyway/presentation/providers/landmark_providers.dart'; -class NewTripPage extends StatefulWidget { +class NewTripPage extends ConsumerStatefulWidget { const NewTripPage({super.key}); @override - _NewTripPageState createState() => _NewTripPageState(); + ConsumerState createState() => _NewTripPageState(); } - -class _NewTripPageState extends State { +class _NewTripPageState extends ConsumerState { int _currentStep = 0; + bool _isCreating = false; + List? _selectedStartLocation; + + bool get _hasSelectedLocation => _selectedStartLocation != null; + + Future _pickLocation() async { + final result = await Navigator.of(context).push>( + MaterialPageRoute( + builder: (_) => + const TripLocationSelectionPage(autoNavigateToPreferences: false), + ), + ); + + if (!mounted) return; + if (result != null) { + setState(() => _selectedStartLocation = result); + } + } + + void _openPreferencesPage() { + if (!_hasSelectedLocation) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Select a start location first.')), + ); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + NewTripPreferencesPage(startLocation: _selectedStartLocation!), + ), + ); + } + + void _showSelectLocationReminder() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Choose a start location to continue.')), + ); + } + + Widget _buildIntermediateLandmarks(List landmarks) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Fetched landmarks', + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (landmarks.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'No landmarks fetched yet. Create a trip to load candidates.', + ), + ) + else + SizedBox( + height: 160, + child: ListView.separated( + itemCount: landmarks.length, + itemBuilder: (context, index) { + final lm = landmarks[index]; + final coords = lm.location; + final subtitle = coords.length >= 2 + ? 'Lat ${coords[0].toStringAsFixed(4)}, Lon ${coords[1].toStringAsFixed(4)}' + : 'Coordinates unavailable'; + return ListTile( + leading: const Icon(Icons.place), + title: Text(lm.name), + subtitle: Text(subtitle), + ); + }, + separatorBuilder: (context, index) => + const Divider(height: 0), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { + final intermediateLandmarks = ref.watch(intermediateLandmarksProvider); return Scaffold( appBar: AppBar( title: const Text('Create New Trip'), + actions: [ + IconButton( + tooltip: 'Preferences', + icon: const Icon(Icons.tune), + onPressed: _openPreferencesPage, + ), + ], ), - body: Stepper( - currentStep: _currentStep, - onStepContinue: () { - if (_currentStep < 1) { - setState(() { - _currentStep += 1; - }); - } - }, - onStepCancel: () { - if (_currentStep > 0) { - setState(() { - _currentStep -= 1; - }); - } - }, - steps: [ - Step( - title: const Text('Select Location'), - content: const MapWithSearchField(), // Replace with your map module - isActive: _currentStep >= 0, + body: Column( + children: [ + Expanded( + child: Stepper( + currentStep: _currentStep, + onStepContinue: () async { + if (_currentStep == 0) { + if (!_hasSelectedLocation) { + _showSelectLocationReminder(); + return; + } + setState(() => _currentStep = 1); + return; + } + + // final step: create trip with current preferences + if (_isCreating) return; + setState(() => _isCreating = true); + + if (!_hasSelectedLocation) { + _showSelectLocationReminder(); + setState(() { + _isCreating = false; + _currentStep = 0; + }); + return; + } + + // For now use a minimal Preferences object; UI should supply real values later. + final prefs = Preferences( + scores: {'sightseeing': 3, 'shopping': 1, 'nature': 2}, + maxTimeMinutes: 120, + startLocation: _selectedStartLocation!, + ); + final createTrip = ref.read(createTripProvider); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + final trip = await createTrip(prefs); + // Show success and (later) navigate to trip viewer + messenger.showSnackBar( + SnackBar(content: Text('Trip created: ${trip.uuid}')), + ); + navigator.pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to create trip: $e')), + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + }, + onStepCancel: () { + if (_currentStep > 0) { + setState(() { + _currentStep -= 1; + }); + } + }, + steps: [ + Step( + title: const Text('Select Location'), + state: _hasSelectedLocation + ? StepState.complete + : StepState.indexed, + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _hasSelectedLocation + ? 'Selected: ${_selectedStartLocation![0].toStringAsFixed(4)}, ${_selectedStartLocation![1].toStringAsFixed(4)}' + : 'Pick the starting point for your trip.', + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.map), + label: Text( + _hasSelectedLocation + ? 'Change location' + : 'Pick on map', + ), + onPressed: _pickLocation, + ), + ), + ], + ), + isActive: _currentStep >= 0, + ), + Step( + title: const Text('Choose Options'), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SizedBox( + height: 200, + child: Center(child: Text('Options placeholder')), + ), + SizedBox(height: 8), + Text('Tap the tuner icon to fine-tune preferences.'), + ], + ), + isActive: _currentStep >= 1, + ), + ], + ), ), - Step( - title: const Text('Choose Options'), - content: const OptionsList(), // Replace with your options module - isActive: _currentStep >= 1, + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _buildIntermediateLandmarks(intermediateLandmarks), ), + const SizedBox(height: 16), ], ), ); } +} diff --git a/frontend/lib/presentation/pages/new_trip_preferences.dart b/frontend/lib/presentation/pages/new_trip_preferences.dart new file mode 100644 index 0000000..eda0772 --- /dev/null +++ b/frontend/lib/presentation/pages/new_trip_preferences.dart @@ -0,0 +1,161 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:anyway/domain/entities/preferences.dart'; +import 'package:anyway/presentation/pages/trip_details_page.dart'; +import 'package:anyway/presentation/providers/trip_provider.dart'; + +class NewTripPreferencesPage extends ConsumerStatefulWidget { + const NewTripPreferencesPage({super.key, required this.startLocation}); + + final List startLocation; + + @override + ConsumerState createState() => + _NewTripPreferencesPageState(); +} + +class _NewTripPreferencesPageState + extends ConsumerState { + int _sightseeing = 3; + int _shopping = 1; + int _nature = 2; + int _maxTimeMinutes = 90; + bool _isCreating = false; + + Widget _preferenceSlider( + String name, + int value, + ValueChanged onChanged, + Icon icon, + ) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ListTile( + leading: icon, + title: Text(name), + subtitle: Slider( + value: value.toDouble(), + min: 0, + max: 5, + divisions: 5, + label: value.toString(), + onChanged: (v) => onChanged(v.toInt()), + ), + ), + ); + } + + Widget _durationPicker() { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ListTile( + leading: const Icon(Icons.access_time), + title: const Text('Maximum trip duration'), + subtitle: CupertinoTimerPicker( + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: Duration(minutes: _maxTimeMinutes), + minuteInterval: 15, + onTimerDurationChanged: (Duration newDuration) { + setState(() { + _maxTimeMinutes = newDuration.inMinutes; + }); + }, + ), + ), + ); + } + + Future _onCreatePressed() async { + if (_isCreating) return; + setState(() => _isCreating = true); + final createTrip = ref.read(createTripProvider); + + final prefs = Preferences( + scores: { + 'sightseeing': _sightseeing, + 'shopping': _shopping, + 'nature': _nature, + }, + maxTimeMinutes: _maxTimeMinutes, + startLocation: widget.startLocation, + ); + + try { + final trip = await createTrip(prefs); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Trip created: ${trip.uuid}'))); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => TripDetailsPage(trip: trip)), + (route) => route.isFirst, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to create trip: $e'))); + } finally { + if (mounted) setState(() => _isCreating = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Trip Preferences')), + body: ListView( + children: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Tell us about your ideal trip.', + style: TextStyle(fontSize: 18), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + const Icon(Icons.place), + const SizedBox(width: 8), + Text( + 'Start: ${widget.startLocation[0].toStringAsFixed(4)}, ${widget.startLocation[1].toStringAsFixed(4)}', + ), + ], + ), + ), + _durationPicker(), + _preferenceSlider( + 'Sightseeing', + _sightseeing, + (v) => setState(() => _sightseeing = v), + const Icon(Icons.camera_alt), + ), + _preferenceSlider( + 'Shopping', + _shopping, + (v) => setState(() => _shopping = v), + const Icon(Icons.shopping_bag), + ), + _preferenceSlider( + 'Nature', + _nature, + (v) => setState(() => _nature = v), + const Icon(Icons.park), + ), + const SizedBox(height: 120), // padding for floating button + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _isCreating ? null : _onCreatePressed, + label: _isCreating + ? const Text('Creating...') + : const Text('Create Trip'), + icon: const Icon(Icons.playlist_add), + ), + ); + } +} diff --git a/frontend/lib/presentation/pages/start.dart b/frontend/lib/presentation/pages/start.dart index b269e70..6f7f097 100644 --- a/frontend/lib/presentation/pages/start.dart +++ b/frontend/lib/presentation/pages/start.dart @@ -1,107 +1,103 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:anyway/presentation/providers/onboarding_state_provider.dart'; +import 'package:anyway/domain/entities/trip.dart'; import 'package:anyway/presentation/pages/login.dart'; import 'package:anyway/presentation/pages/onboarding.dart'; -import 'package:anyway/presentation/pages/new_trip_preferences.dart'; +import 'package:anyway/presentation/pages/trip_creation_flow.dart'; +import 'package:anyway/presentation/pages/trip_details_page.dart'; +import 'package:anyway/presentation/providers/trip_provider.dart'; +import 'package:anyway/presentation/widgets/trip_summary_card.dart'; -// Example providers (replace these with your actual providers) -// final onboardingStateProvider = Provider((ref) => true); // Replace with actual onboarding state logic -final authStateProvider = FutureProvider((ref) async => true); // Replace with actual auth state logic -final tripsAvailableProvider = FutureProvider((ref) async => false); // Replace with actual trips availability logic +// TODO - Replace with actual auth state logic +final authStateProvider = FutureProvider( + (ref) async => true, +); class StartPage extends ConsumerWidget { - const StartPage({Key? key}) : super(key: key); + const StartPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + // the home page is dependent on the state of the providers: + // - if the user is not onboarded, show the onboarding flow + // - if the user is not logged in, show the login page + // - if there are no trips available, show the trip creation page + // - else: show the overview page that shows the last trip - // the home page is dependent on the state of the providers: - // - if the user is not onboarded, show the onboarding flow - // - if the user is not logged in, show the login page - // - if there are no trips available, show the trip creation page - // - else: show the overview page that shows the last trip + final onboardingState = ref.watch(onboardingStateProvider); + final authState = ref.watch(authStateProvider); + final currentTrips = ref.watch(currentTripsProvider); - final onboardingState = ref.watch(onboardingStateProvider); - final authState = ref.watch(authStateProvider); - final tripsAvailable = ref.watch(tripsAvailableProvider); + return onboardingState.when( + data: (isOnboarded) { + if (!isOnboarded) { + return const OnboardingPage(); + } - return onboardingState.when( - data: (isOnboarded) { - if (!isOnboarded) { - return const OnboardingPage(); - } + return authState.when( + data: (isLoggedIn) { + if (!isLoggedIn) { + return const LoginPage(); + } - return authState.when( - data: (isLoggedIn) { - if (!isLoggedIn) { - return const LoginPage(); - } - return tripsAvailable.when( - data: (hasTrips) { - if (!hasTrips) { - return const TripCreationPage(); - } - return const OverviewPage(); - }, - loading: () => const Scaffold( - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - body: Center(child: Text('Error: $error')), - ), + return currentTrips.when( + data: (trips) { + if (trips.isEmpty) { + return const TripLocationSelectionPage(); + } + return TripsOverviewPage(trips: trips); + }, + loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), + error: (error, stack) => Scaffold(body: Center(child: Text('Error loading trips: $error'))), + ); + }, + loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), + error: (error, stack) => Scaffold(body: Center(child: Text('Error: $error'))), + ); + }, + loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), + error: (error, stack) => Scaffold(body: Center(child: Text('Error: $error'))), + ); + } +} + +// TODO - move to separate file +class TripsOverviewPage extends StatelessWidget { + const TripsOverviewPage({super.key, required this.trips}); + + final List trips; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Your trips')), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: trips.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final trip = trips[index]; + return TripSummaryCard( + trip: trip, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => TripDetailsPage(trip: trip)), ); }, - loading: () => const Scaffold( - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - body: Center(child: Text('Error: $error')), - ), ); }, - loading: () => const Scaffold( - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - body: Center(child: Text('Error: $error') - ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const TripLocationSelectionPage())); + }, + icon: const Icon(Icons.map), + label: const Text('Plan your next trip'), ), ); } } - - - - -class TripCreationPage extends StatelessWidget { - const TripCreationPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Create a Trip')), - body: Center( - child: ElevatedButton.icon( - icon: const Icon(Icons.tune), - label: const Text('Set Preferences'), - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => const NewTripPreferencesPage())); - }, - ), - ), - ); - } -} - -class OverviewPage extends StatelessWidget { - const OverviewPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center(child: Text('Overview Page')), - ); - } -} diff --git a/frontend/lib/presentation/pages/trip_creation_flow.dart b/frontend/lib/presentation/pages/trip_creation_flow.dart new file mode 100644 index 0000000..4904cc9 --- /dev/null +++ b/frontend/lib/presentation/pages/trip_creation_flow.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:anyway/core/constants.dart'; +import 'package:anyway/presentation/pages/new_trip_preferences.dart'; + +class TripLocationSelectionPage extends StatefulWidget { + const TripLocationSelectionPage({ + super.key, + this.autoNavigateToPreferences = true, + }); + + final bool autoNavigateToPreferences; + + @override + State createState() => + _TripLocationSelectionPageState(); +} + +class _TripLocationSelectionPageState extends State { + final CameraPosition _initialCameraPosition = const CameraPosition( + // TODO - maybe Paris is not the best default? + target: LatLng(48.8566, 2.3522), + zoom: 11.0, + ); + + GoogleMapController? _mapController; + LatLng? _selectedLocation; + bool _useLocation = true; + bool _loadingPreferences = true; + bool _isSearchingAddress = false; + final TextEditingController _searchController = TextEditingController(); + + static const Map> _debugLocations = { + 'paris': [48.8575, 2.3514], + 'london': [51.5074, -0.1278], + 'new york': [40.7128, -74.006], + 'tokyo': [35.6895, 139.6917], + }; + + @override + void initState() { + super.initState(); + _loadLocationPreference(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadLocationPreference() async { + final prefs = await SharedPreferences.getInstance(); + final useLocation = prefs.getBool('useLocation') ?? true; + if (!mounted) return; + setState(() { + _useLocation = useLocation; + _loadingPreferences = false; + }); + } + + void _onLongPress(LatLng location) { + setState(() { + _selectedLocation = location; + }); + _mapController?.animateCamera(CameraUpdate.newLatLng(location)); + } + + void _setSelectedLocationFromCoords(double lat, double lng) { + final latLng = LatLng(lat, lng); + setState(() { + _selectedLocation = latLng; + }); + _mapController?.animateCamera(CameraUpdate.newLatLng(latLng)); + } + + Future _useCurrentLocation() async { + try { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Location permission denied.')), + ); + return; + } + + if (permission == LocationPermission.deniedForever) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission permanently denied.'), + ), + ); + return; + } + + final position = await Geolocator.getCurrentPosition(); + if (!mounted) return; + _setSelectedLocationFromCoords(position.latitude, position.longitude); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Unable to get current location: $e')), + ); + } + } + + Future _searchForLocation(String rawQuery) async { + final query = rawQuery.trim(); + if (query.isEmpty) { + return; + } + + setState(() => _isSearchingAddress = true); + try { + List locations = []; + if (GeocodingPlatform.instance != null) { + locations = await locationFromAddress(query); + } + + Location? selected; + if (locations.isNotEmpty) { + selected = locations.first; + } else { + final fallback = _debugLocations[query.toLowerCase()]; + if (fallback != null) { + selected = Location( + latitude: fallback[0], + longitude: fallback[1], + timestamp: DateTime.now(), + ); + } + } + + if (selected == null) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('No results for "$query".'))); + return; + } + + if (!mounted) return; + _setSelectedLocationFromCoords(selected.latitude, selected.longitude); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Search failed: $e'))); + } finally { + if (mounted) { + setState(() => _isSearchingAddress = false); + } + } + } + + Set get _markers => _selectedLocation == null + ? {} + : { + Marker( + markerId: const MarkerId('new-trip-start'), + position: _selectedLocation!, + infoWindow: const InfoWindow(title: 'Trip start'), + ), + }; + + void _confirmLocation() { + if (_selectedLocation == null) return; + final startLocation = [ + _selectedLocation!.latitude, + _selectedLocation!.longitude, + ]; + + if (!widget.autoNavigateToPreferences) { + Navigator.of(context).pop(startLocation); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NewTripPreferencesPage(startLocation: startLocation), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_loadingPreferences) { + return Scaffold( + appBar: AppBar(title: const Text('Choose Start Location')), + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Choose Start Location')), + body: Stack( + children: [ + GoogleMap( + onMapCreated: (controller) => _mapController = controller, + initialCameraPosition: _initialCameraPosition, + onLongPress: _onLongPress, + markers: _markers, + cloudMapId: MAP_ID, + mapToolbarEnabled: false, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + myLocationEnabled: _useLocation, + ), + Positioned( + top: 16, + left: 16, + right: 16, + child: Column( + children: [ + SearchBar( + controller: _searchController, + hintText: 'Enter a city or long-press on the map', + leading: const Icon(Icons.search), + onSubmitted: _searchForLocation, + trailing: [ + IconButton( + icon: _isSearchingAddress + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + onPressed: _isSearchingAddress + ? null + : () => _searchForLocation(_searchController.text), + ), + ], + ), + const SizedBox(height: 8), + if (_useLocation) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.my_location), + label: const Text('Use current location'), + onPressed: _useCurrentLocation, + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _selectedLocation == null + ? 'Long-press anywhere to drop the starting point.' + : 'Selected: ${_selectedLocation!.latitude.toStringAsFixed(4)}, ${_selectedLocation!.longitude.toStringAsFixed(4)}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ], + ), + ), + ], + ), + floatingActionButton: _selectedLocation == null + ? null + : FloatingActionButton.extended( + onPressed: _confirmLocation, + icon: widget.autoNavigateToPreferences + ? const Icon(Icons.tune) + : const Icon(Icons.check), + label: Text( + widget.autoNavigateToPreferences + ? 'Select Preferences' + : 'Use this location', + ), + ), + ); + } +} diff --git a/frontend/lib/presentation/pages/trip_details_page.dart b/frontend/lib/presentation/pages/trip_details_page.dart new file mode 100644 index 0000000..6215fcd --- /dev/null +++ b/frontend/lib/presentation/pages/trip_details_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; + +import 'package:anyway/core/constants.dart'; +import 'package:anyway/domain/entities/trip.dart'; +import 'package:anyway/presentation/utils/trip_location_utils.dart'; +import 'package:anyway/presentation/widgets/trip_details_panel.dart'; +import 'package:anyway/presentation/widgets/trip_map.dart'; + +class TripDetailsPage extends StatefulWidget { + const TripDetailsPage({super.key, required this.trip}); + + final Trip trip; + + @override + State createState() => _TripDetailsPageState(); +} + +class _TripDetailsPageState extends State { + late Future _locationPrefFuture; + late Future _localeFuture; + + @override + void initState() { + super.initState(); + _locationPrefFuture = _loadLocationPreference(); + _localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip); + } + + Future _loadLocationPreference() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool('useLocation') ?? true; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: FutureBuilder( + future: _localeFuture, + builder: (context, snapshot) { + final locale = snapshot.data; + final title = locale?.cityName ?? 'Trip overview'; + return Text(title); + }, + ), + ), + body: FutureBuilder( + future: _locationPrefFuture, + builder: (context, snapshot) { + final enableLocation = snapshot.data ?? false; + return SlidingUpPanel( + borderRadius: const BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)), + parallaxEnabled: true, + maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT, + minHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT, + panelBuilder: (controller) => TripDetailsPanel(trip: widget.trip, controller: controller), + body: TripMap(trip: widget.trip, showRoute: true, interactive: true, borderRadius: 0, enableMyLocation: enableLocation), + ); + }, + ), + ); + } +} diff --git a/frontend/lib/presentation/providers/core_providers.dart b/frontend/lib/presentation/providers/core_providers.dart index 8c2d603..88f6604 100644 --- a/frontend/lib/presentation/providers/core_providers.dart +++ b/frontend/lib/presentation/providers/core_providers.dart @@ -1,11 +1,12 @@ import 'package:anyway/core/dio_client.dart'; -import 'package:anyway/data/repositories/local_onboarding_repository.dart'; -import 'package:anyway/domain/repositories/onboarding_repository.dart'; -import 'package:anyway/domain/repositories/trip_repository.dart'; -import 'package:anyway/data/repositories/backend_trip_repository.dart'; +import 'package:anyway/data/datasources/trip_local_datasource.dart'; import 'package:anyway/data/datasources/trip_remote_datasource.dart'; +import 'package:anyway/data/repositories/backend_trip_repository.dart'; +import 'package:anyway/data/repositories/local_onboarding_repository.dart'; import 'package:anyway/data/repositories/preferences_repository_impl.dart'; +import 'package:anyway/domain/repositories/onboarding_repository.dart'; import 'package:anyway/domain/repositories/preferences_repository.dart'; +import 'package:anyway/domain/repositories/trip_repository.dart'; import 'dart:io'; import 'package:dio/dio.dart'; @@ -25,12 +26,6 @@ final dioProvider = Provider((ref) { )); }); -/// Provide a simple wrapper client if needed elsewhere -final dioClientProvider = Provider((ref) { - final dio = ref.read(dioProvider); - return DioClient(baseUrl: dio.options.baseUrl); -}); - /// Onboarding repository backed by SharedPreferences final onboardingRepositoryProvider = Provider((ref) { return LocalOnboardingRepository(); @@ -45,5 +40,6 @@ final preferencesRepositoryProvider = Provider((ref) { final tripRepositoryProvider = Provider((ref) { final dio = ref.read(dioProvider); final remote = TripRemoteDataSourceImpl(dio: dio); - return BackendTripRepository(remote: remote); + final local = TripLocalDataSourceImpl(); + return BackendTripRepository(remote: remote, local: local); }); diff --git a/frontend/lib/presentation/providers/trip_provider.dart b/frontend/lib/presentation/providers/trip_provider.dart index 020f690..e07f09e 100644 --- a/frontend/lib/presentation/providers/trip_provider.dart +++ b/frontend/lib/presentation/providers/trip_provider.dart @@ -1,16 +1,86 @@ +import 'dart:async'; + import 'package:anyway/domain/entities/preferences.dart'; import 'package:anyway/domain/entities/trip.dart'; +import 'package:anyway/domain/repositories/trip_repository.dart'; import 'package:anyway/presentation/providers/landmark_providers.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:anyway/presentation/providers/core_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; // Provides a function that creates a trip given preferences. final createTripProvider = Provider Function(Preferences)>((ref) { - return (Preferences prefs) async { - final repo = ref.read(tripRepositoryProvider); - final landmarks = await repo.searchLandmarks(prefs); - ref.read(intermediateLandmarksProvider.notifier).setLandmarks(landmarks); - return repo.getTrip(preferences: prefs, landmarks: landmarks); - }; + return (Preferences prefs) async { + final repo = ref.read(tripRepositoryProvider); + final landmarks = await repo.searchLandmarks(prefs); + ref.read(intermediateLandmarksProvider.notifier).setLandmarks(landmarks); + final trip = await repo.getTrip(preferences: prefs, landmarks: landmarks); + await ref.read(currentTripsProvider.notifier).saveTrip(trip); + return trip; + }; }); + +class CurrentTripsNotifier extends AsyncNotifier> { + TripRepository get _repository => ref.read(tripRepositoryProvider); + Future>? _pendingLoad; + + List _currentTrips() => state.asData?.value ?? const []; + + @override + Future> build() async { + final load = _repository.getSavedTrips(); + _pendingLoad = load; + try { + final trips = await load; + return List.unmodifiable(trips); + } finally { + if (identical(_pendingLoad, load)) { + _pendingLoad = null; + } + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + final load = _repository.getSavedTrips(); + _pendingLoad = load; + state = await AsyncValue.guard(() async { + final trips = await load; + return List.unmodifiable(trips); + }); + if (identical(_pendingLoad, load)) { + _pendingLoad = null; + } + } + + Future saveTrip(Trip trip) async { + final previous = state; + final updatedTrips = [..._currentTrips()] + ..removeWhere((t) => t.uuid == trip.uuid) + ..insert(0, trip); + state = AsyncValue.data(List.unmodifiable(updatedTrips)); + + try { + await _repository.saveTrip(trip); + } catch (error) { + state = previous; + rethrow; + } + } + + Future deleteTrip(String uuid) async { + final previous = state; + final updatedTrips = _currentTrips() + .where((trip) => trip.uuid != uuid) + .toList(growable: false); + state = AsyncValue.data(List.unmodifiable(updatedTrips)); + + try { + await _repository.deleteSavedTrip(uuid); + } catch (error) { + state = previous; + rethrow; + } + } +} + +final currentTripsProvider = AsyncNotifierProvider>(CurrentTripsNotifier.new); diff --git a/frontend/lib/presentation/utils/trip_location_utils.dart b/frontend/lib/presentation/utils/trip_location_utils.dart new file mode 100644 index 0000000..9bb5b60 --- /dev/null +++ b/frontend/lib/presentation/utils/trip_location_utils.dart @@ -0,0 +1,78 @@ +import 'package:anyway/domain/entities/trip.dart'; +import 'package:geocoding/geocoding.dart'; + +class TripLocationUtils { + const TripLocationUtils._(); + + static List? startCoordinates(Trip trip) { + if (trip.landmarks.isEmpty) { + return null; + } + final coords = trip.landmarks.first.location; + if (coords.length < 2) { + return null; + } + return coords; + } + + static Future resolveLocaleInfo(Trip trip) async { + final coords = startCoordinates(trip); + if (coords == null) { + return const TripLocaleInfo(); + } + + if (GeocodingPlatform.instance == null) { + final fallbackCity = '${coords[0].toStringAsFixed(2)}, ${coords[1].toStringAsFixed(2)}'; + return TripLocaleInfo(cityName: fallbackCity, coordinates: coords); + } + + try { + final placemarks = await placemarkFromCoordinates(coords[0], coords[1]); + if (placemarks.isEmpty) { + return TripLocaleInfo(coordinates: coords); + } + final placemark = placemarks.first; + final city = placemark.locality ?? placemark.subAdministrativeArea; + final country = placemark.country; + final isoCountryCode = placemark.isoCountryCode; + return TripLocaleInfo(cityName: city, countryName: country, countryCode: isoCountryCode, flagEmoji: _flagEmoji(isoCountryCode), coordinates: coords); + } catch (_) { + return TripLocaleInfo(coordinates: coords); + } + } + + static Future resolveCityName(Trip trip) async { + final localeInfo = await resolveLocaleInfo(trip); + return localeInfo.cityName ?? 'Unknown'; + } + + static String? _flagEmoji(String? countryCode) { + if (countryCode == null || countryCode.length != 2) { + return null; + } + final upper = countryCode.toUpperCase(); + final first = upper.codeUnitAt(0); + final second = upper.codeUnitAt(1); + if (!_isAsciiLetter(first) || !_isAsciiLetter(second)) { + return null; + } + const base = 0x1F1E6; + final firstFlag = base + (first - 0x41); + final secondFlag = base + (second - 0x41); + return String.fromCharCodes([firstFlag, secondFlag]); + } + + static bool _isAsciiLetter(int codeUnit) => codeUnit >= 0x41 && codeUnit <= 0x5A; +} + +class TripLocaleInfo { + const TripLocaleInfo({this.cityName, this.countryName, this.countryCode, this.flagEmoji, this.coordinates}); + + final String? cityName; + final String? countryName; + final String? countryCode; + final String? flagEmoji; + final List? coordinates; + + bool get hasResolvedCity => cityName != null && cityName!.trim().isNotEmpty && cityName != 'Unknown'; +} diff --git a/frontend/lib/presentation/widgets/create_trip_location.dart b/frontend/lib/presentation/widgets/create_trip_location.dart deleted file mode 100644 index cd8dbf4..0000000 --- a/frontend/lib/presentation/widgets/create_trip_location.dart +++ /dev/null @@ -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), - ), - ], - ); - } -} diff --git a/frontend/lib/presentation/widgets/create_trip_location_map.dart b/frontend/lib/presentation/widgets/create_trip_location_map.dart deleted file mode 100644 index cd203be..0000000 --- a/frontend/lib/presentation/widgets/create_trip_location_map.dart +++ /dev/null @@ -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 createState() => _NewTripMapState(); -} - -class _NewTripMapState extends State { - final CameraPosition _cameraPosition = const CameraPosition( - target: LatLng(48.8566, 2.3522), - zoom: 11.0, - ); - GoogleMapController? _mapController; - final Set _markers = {}; - - _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 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, - ); - } -} diff --git a/frontend/lib/presentation/widgets/create_trip_location_search.dart b/frontend/lib/presentation/widgets/create_trip_location_search.dart deleted file mode 100644 index ef29194..0000000 --- a/frontend/lib/presentation/widgets/create_trip_location_search.dart +++ /dev/null @@ -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 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 prefs = SharedPreferences.getInstance(); - Trip trip; - - NewTripLocationSearch( - this.trip, - ); - - - @override - State createState() => _NewTripLocationSearchState(); -} - -class _NewTripLocationSearchState extends State { - final TextEditingController _controller = TextEditingController(); - - setTripLocation (String query) async { - List 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; - } - }, - ); - } -} diff --git a/frontend/lib/presentation/widgets/trip_details/trip_hero_header.dart b/frontend/lib/presentation/widgets/trip_details/trip_hero_header.dart new file mode 100644 index 0000000..afff1e6 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_details/trip_hero_header.dart @@ -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), + ), + ); + } +} diff --git a/frontend/lib/presentation/widgets/trip_details/trip_hero_meta_card.dart b/frontend/lib/presentation/widgets/trip_details/trip_hero_meta_card.dart new file mode 100644 index 0000000..2a0fa8a --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_details/trip_hero_meta_card.dart @@ -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'; +} diff --git a/frontend/lib/presentation/widgets/trip_details/trip_landmark_card.dart b/frontend/lib/presentation/widgets/trip_details/trip_landmark_card.dart new file mode 100644 index 0000000..0055bce --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_details/trip_landmark_card.dart @@ -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 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; +} diff --git a/frontend/lib/presentation/widgets/trip_details/trip_step_between_landmarks.dart b/frontend/lib/presentation/widgets/trip_details/trip_step_between_landmarks.dart new file mode 100644 index 0000000..27279a5 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_details/trip_step_between_landmarks.dart @@ -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')), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/presentation/widgets/trip_details_panel.dart b/frontend/lib/presentation/widgets/trip_details_panel.dart new file mode 100644 index 0000000..94b23d9 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_details_panel.dart @@ -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 createState() => _TripDetailsPanelState(); +} + +class _TripDetailsPanelState extends State { + late Future _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( + 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 _openWebsite(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + Future _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 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( + 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; +} diff --git a/frontend/lib/presentation/widgets/trip_map.dart b/frontend/lib/presentation/widgets/trip_map.dart new file mode 100644 index 0000000..3726370 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_map.dart @@ -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 createState() => _TripMapState(); +} + +class _TripMapState extends State { + GoogleMapController? _controller; + LatLng? _startLatLng; + Set _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 _refreshMarkers() async { + if (_startLatLng == null) { + setState(() => _markers = const {}); + return; + } + + final targets = widget.showRoute + ? widget.trip.landmarks + : widget.trip.landmarks.isEmpty + ? const [] + : [widget.trip.landmarks.first]; + + if (targets.isEmpty) { + setState(() => _markers = const {}); + return; + } + + final markerSet = {}; + 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 _buildPolylines() { + if (!widget.showRoute) { + return const {}; + } + final points = widget.trip.landmarks; + if (points.length < 2) { + return const {}; + } + + final polylines = {}; + 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; + } +} + diff --git a/frontend/lib/presentation/widgets/trip_marker_graphic.dart b/frontend/lib/presentation/widgets/trip_marker_graphic.dart new file mode 100644 index 0000000..75546e5 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_marker_graphic.dart @@ -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; + } + } +} diff --git a/frontend/lib/presentation/widgets/trip_summary_card.dart b/frontend/lib/presentation/widgets/trip_summary_card.dart new file mode 100644 index 0000000..8689fa8 --- /dev/null +++ b/frontend/lib/presentation/widgets/trip_summary_card.dart @@ -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 createState() => _TripSummaryCardState(); +} + +class _TripSummaryCardState extends State { + late Future _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( + 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), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index bc3b06c..42b5098 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -744,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + map_launcher: + dependency: "direct main" + description: + name: map_launcher + sha256: "39af937533f3d9af306357c28546ded909a0f81c76097e118724df4d0713d2f2" + url: "https://pub.dev" + source: hosted + version: "4.4.2" markdown: dependency: transitive description: @@ -1502,5 +1510,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.8.1 <4.0.0" flutter: ">=3.32.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 59bad47..3aa4c90 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: geocoding: ^4.0.0 widget_to_marker: ^1.0.6 auto_size_text: ^3.0.0 - # map_launcher: ^4.4.2 + map_launcher: ^4.4.2 flutter_svg: ^2.0.10+1 url_launcher: ^6.3.0 flutter_launcher_icons: ^0.14.4 diff --git a/frontend/test/current_trips_notifier_test.dart b/frontend/test/current_trips_notifier_test.dart new file mode 100644 index 0000000..ec7610f --- /dev/null +++ b/frontend/test/current_trips_notifier_test.dart @@ -0,0 +1,124 @@ +import 'package:anyway/domain/entities/landmark.dart'; +import 'package:anyway/domain/entities/landmark_type.dart'; +import 'package:anyway/domain/entities/preferences.dart'; +import 'package:anyway/domain/entities/trip.dart'; +import 'package:anyway/domain/repositories/trip_repository.dart'; +import 'package:anyway/presentation/providers/core_providers.dart'; +import 'package:anyway/presentation/providers/trip_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeTripRepository implements TripRepository { + _FakeTripRepository(this._trips); + + final List _trips; + + @override + Future deleteSavedTrip(String uuid) async { + _trips.removeWhere((trip) => trip.uuid == uuid); + } + + @override + Future getSavedTrip(String uuid) async { + try { + return _trips.firstWhere((trip) => trip.uuid == uuid); + } on StateError { + return null; + } + } + + @override + Future> getSavedTrips() async { + return List.unmodifiable(_trips); + } + + @override + Future saveTrip(Trip trip) async { + _trips.removeWhere((t) => t.uuid == trip.uuid); + _trips.insert(0, trip); + } + + // The following methods are not used in these tests. + @override + Future getTrip({Preferences? preferences, String? tripUUID, List? landmarks}) { + throw UnimplementedError(); + } + + @override + Future> searchLandmarks(Preferences preferences) { + throw UnimplementedError(); + } +} + +Trip _buildTrip(String uuid) { + return Trip( + uuid: uuid, + totalTimeMinutes: 60, + landmarks: [ + Landmark( + uuid: 'lm-$uuid', + name: 'Landmark $uuid', + location: const [0.0, 0.0], + type: const LandmarkType(type: LandmarkTypeEnum.sightseeing), + ), + ], + ); +} + +void main() { + test('currentTripsProvider loads saved trips on build', () async { + final repo = _FakeTripRepository([_buildTrip('trip-1')]); + final container = ProviderContainer(overrides: [ + tripRepositoryProvider.overrideWithValue(repo), + ]); + addTearDown(container.dispose); + + final trips = await container.read(currentTripsProvider.future); + expect(trips, hasLength(1)); + expect(trips.first.uuid, 'trip-1'); + }); + + test('saveTrip persists trips locally and updates provider state', () async { + final repo = _FakeTripRepository([_buildTrip('existing')]); + final container = ProviderContainer(overrides: [ + tripRepositoryProvider.overrideWithValue(repo), + ]); + addTearDown(container.dispose); + + await container.read(currentTripsProvider.future); + final notifier = container.read(currentTripsProvider.notifier); + await notifier.saveTrip(_buildTrip('fresh')); + + final tripsState = container.read(currentTripsProvider); + expect(tripsState.value, isNotNull); + expect(tripsState.value!.first.uuid, 'fresh'); + + final repoTrips = await repo.getSavedTrips(); + expect(repoTrips, hasLength(2)); + expect(repoTrips.first.uuid, 'fresh'); + }); + + test('deleteTrip removes trips from both provider state and repository', () async { + final repo = _FakeTripRepository([ + _buildTrip('trip-1'), + _buildTrip('trip-2'), + ]); + final container = ProviderContainer(overrides: [ + tripRepositoryProvider.overrideWithValue(repo), + ]); + addTearDown(container.dispose); + + await container.read(currentTripsProvider.future); + final notifier = container.read(currentTripsProvider.notifier); + await notifier.deleteTrip('trip-1'); + + final tripsState = container.read(currentTripsProvider); + expect(tripsState.value, isNotNull); + expect(tripsState.value, hasLength(1)); + expect(tripsState.value!.single.uuid, 'trip-2'); + + final repoTrips = await repo.getSavedTrips(); + expect(repoTrips, hasLength(1)); + expect(repoTrips.single.uuid, 'trip-2'); + }); +} diff --git a/frontend/test/trip_local_datasource_test.dart b/frontend/test/trip_local_datasource_test.dart new file mode 100644 index 0000000..59c688b --- /dev/null +++ b/frontend/test/trip_local_datasource_test.dart @@ -0,0 +1,69 @@ +import 'package:anyway/data/datasources/trip_local_datasource.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late TripLocalDataSource dataSource; + + Map buildTripJson(String uuid) { + return { + 'uuid': uuid, + 'total_time_minute': 90, + 'landmarks': [ + { + 'uuid': 'lm-$uuid', + 'name': 'Landmark $uuid', + 'location': [0.0, 0.0], + 'type': {'type': 'sightseeing'}, + }, + ], + }; + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + dataSource = TripLocalDataSourceImpl(); + }); + + test('loadTrips returns empty list when storage is empty', () async { + final trips = await dataSource.loadTrips(); + expect(trips, isEmpty); + }); + + test('upsertTrip stores and retrieves a trip', () async { + await dataSource.upsertTrip(buildTripJson('trip-1')); + final trips = await dataSource.loadTrips(); + expect(trips, hasLength(1)); + expect(trips.first['uuid'], 'trip-1'); + + final fetched = await dataSource.getTrip('trip-1'); + expect(fetched, isNotNull); + expect(fetched!['uuid'], 'trip-1'); + }); + + test('deleteTrip removes the persisted entry', () async { + await dataSource.upsertTrip(buildTripJson('trip-1')); + await dataSource.upsertTrip(buildTripJson('trip-2')); + + await dataSource.deleteTrip('trip-1'); + final remaining = await dataSource.loadTrips(); + expect(remaining, hasLength(1)); + expect(remaining.single['uuid'], 'trip-2'); + }); + + test('upsertTrip overwrites an existing trip and keeps latest first', () async { + await dataSource.upsertTrip(buildTripJson('trip-1')); + await dataSource.upsertTrip(buildTripJson('trip-2')); + + final updatedTrip1 = buildTripJson('trip-1')..['total_time_minute'] = 120; + await dataSource.upsertTrip(updatedTrip1); + + final trips = await dataSource.loadTrips(); + expect(trips, hasLength(2)); + expect(trips.first['uuid'], 'trip-1'); + expect(trips.first['total_time_minute'], 120); + expect(trips[1]['uuid'], 'trip-2'); + }); +}