feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI
This commit is contained in:
@@ -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<NewTripPage> createState() => _NewTripPageState();
|
||||
}
|
||||
|
||||
|
||||
class _NewTripPageState extends State<NewTripPage> {
|
||||
class _NewTripPageState extends ConsumerState<NewTripPage> {
|
||||
int _currentStep = 0;
|
||||
|
||||
bool _isCreating = false;
|
||||
List<double>? _selectedStartLocation;
|
||||
|
||||
bool get _hasSelectedLocation => _selectedStartLocation != null;
|
||||
|
||||
Future<void> _pickLocation() async {
|
||||
final result = await Navigator.of(context).push<List<double>>(
|
||||
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<Landmark> 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
161
frontend/lib/presentation/pages/new_trip_preferences.dart
Normal file
161
frontend/lib/presentation/pages/new_trip_preferences.dart
Normal file
@@ -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<double> startLocation;
|
||||
|
||||
@override
|
||||
ConsumerState<NewTripPreferencesPage> createState() =>
|
||||
_NewTripPreferencesPageState();
|
||||
}
|
||||
|
||||
class _NewTripPreferencesPageState
|
||||
extends ConsumerState<NewTripPreferencesPage> {
|
||||
int _sightseeing = 3;
|
||||
int _shopping = 1;
|
||||
int _nature = 2;
|
||||
int _maxTimeMinutes = 90;
|
||||
bool _isCreating = false;
|
||||
|
||||
Widget _preferenceSlider(
|
||||
String name,
|
||||
int value,
|
||||
ValueChanged<int> 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<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<bool>((ref) => true); // Replace with actual onboarding state logic
|
||||
final authStateProvider = FutureProvider<bool>((ref) async => true); // Replace with actual auth state logic
|
||||
final tripsAvailableProvider = FutureProvider<bool>((ref) async => false); // Replace with actual trips availability logic
|
||||
// TODO - Replace with actual auth state logic
|
||||
final authStateProvider = FutureProvider<bool>(
|
||||
(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<Trip> 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
287
frontend/lib/presentation/pages/trip_creation_flow.dart
Normal file
287
frontend/lib/presentation/pages/trip_creation_flow.dart
Normal file
@@ -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<TripLocationSelectionPage> createState() =>
|
||||
_TripLocationSelectionPageState();
|
||||
}
|
||||
|
||||
class _TripLocationSelectionPageState extends State<TripLocationSelectionPage> {
|
||||
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<String, List<double>> _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<void> _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<void> _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<void> _searchForLocation(String rawQuery) async {
|
||||
final query = rawQuery.trim();
|
||||
if (query.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSearchingAddress = true);
|
||||
try {
|
||||
List<Location> 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<Marker> get _markers => _selectedLocation == null
|
||||
? <Marker>{}
|
||||
: {
|
||||
Marker(
|
||||
markerId: const MarkerId('new-trip-start'),
|
||||
position: _selectedLocation!,
|
||||
infoWindow: const InfoWindow(title: 'Trip start'),
|
||||
),
|
||||
};
|
||||
|
||||
void _confirmLocation() {
|
||||
if (_selectedLocation == null) return;
|
||||
final startLocation = <double>[
|
||||
_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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
frontend/lib/presentation/pages/trip_details_page.dart
Normal file
65
frontend/lib/presentation/pages/trip_details_page.dart
Normal file
@@ -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<TripDetailsPage> createState() => _TripDetailsPageState();
|
||||
}
|
||||
|
||||
class _TripDetailsPageState extends State<TripDetailsPage> {
|
||||
late Future<bool> _locationPrefFuture;
|
||||
late Future<TripLocaleInfo> _localeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_locationPrefFuture = _loadLocationPreference();
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
|
||||
Future<bool> _loadLocationPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool('useLocation') ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: FutureBuilder<TripLocaleInfo>(
|
||||
future: _localeFuture,
|
||||
builder: (context, snapshot) {
|
||||
final locale = snapshot.data;
|
||||
final title = locale?.cityName ?? 'Trip overview';
|
||||
return Text(title);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: FutureBuilder<bool>(
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user