feat(wip): implement trip persistence through a local repository. Include loaded trips in the start page UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<List<Map<String, dynamic>>> loadTrips();
|
||||
|
||||
/// Returns a single trip payload by uuid if present.
|
||||
/// TODO - should directly return Trip?
|
||||
Future<Map<String, dynamic>?> getTrip(String uuid);
|
||||
|
||||
/// Upserts the provided trip payload (also used for editing existing trips).
|
||||
Future<void> upsertTrip(Map<String, dynamic> tripJson);
|
||||
|
||||
/// Removes the trip with the matching uuid.
|
||||
Future<void> deleteTrip(String uuid);
|
||||
}
|
||||
|
||||
class TripLocalDataSourceImpl implements TripLocalDataSource {
|
||||
TripLocalDataSourceImpl({Future<SharedPreferences>? preferences})
|
||||
: _prefsFuture = preferences ?? SharedPreferences.getInstance();
|
||||
|
||||
static const String _storageKey = 'savedTrips';
|
||||
final Future<SharedPreferences> _prefsFuture;
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> loadTrips() async {
|
||||
final prefs = await _prefsFuture;
|
||||
final stored = prefs.getStringList(_storageKey);
|
||||
if (stored == null) return [];
|
||||
return stored.map(_decodeTrip).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> getTrip(String uuid) async {
|
||||
final trips = await loadTrips();
|
||||
for (final trip in trips) {
|
||||
if (trip['uuid'] == uuid) {
|
||||
return Map<String, dynamic>.from(trip);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertTrip(Map<String, dynamic> 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<String, dynamic>.from(tripJson));
|
||||
await _persistTrips(trips);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> _persistTrips(List<Map<String, dynamic>> trips) async {
|
||||
final prefs = await _prefsFuture;
|
||||
final payload = trips.map(jsonEncode).toList();
|
||||
await prefs.setStringList(_storageKey, payload);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeTrip(String raw) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Saved trip entry is not a JSON object');
|
||||
}
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Trip> getTrip({Preferences? preferences, String? tripUUID, List<Landmark>? 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<List<Trip>> getSavedTrips() async {
|
||||
final rawTrips = await local.loadTrips();
|
||||
return rawTrips.map(Trip.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Trip?> getSavedTrip(String uuid) async {
|
||||
final json = await local.getTrip(uuid);
|
||||
return json == null ? null : Trip.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveTrip(Trip trip) async {
|
||||
await local.upsertTrip(trip.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSavedTrip(String uuid) async {
|
||||
await local.deleteTrip(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,3 +48,9 @@ abstract class Landmark with _$Landmark {
|
||||
|
||||
factory Landmark.fromJson(Map<String, Object?> json) => _$LandmarkFromJson(json);
|
||||
}
|
||||
|
||||
extension LandmarkVisitX on Landmark {
|
||||
bool get isVisited => visited ?? false;
|
||||
|
||||
set isVisited(bool value) => visited = value;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,14 @@ abstract class TripRepository {
|
||||
Future<Trip> getTrip({Preferences? preferences, String? tripUUID, List<Landmark>? landmarks});
|
||||
|
||||
Future<List<Landmark>> 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<List<Trip>> getSavedTrips();
|
||||
|
||||
Future<Trip?> getSavedTrip(String uuid);
|
||||
|
||||
Future<void> saveTrip(Trip trip);
|
||||
|
||||
Future<void> deleteSavedTrip(String uuid);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Dio>((ref) {
|
||||
));
|
||||
});
|
||||
|
||||
/// Provide a simple wrapper client if needed elsewhere
|
||||
final dioClientProvider = Provider<DioClient>((ref) {
|
||||
final dio = ref.read(dioProvider);
|
||||
return DioClient(baseUrl: dio.options.baseUrl);
|
||||
});
|
||||
|
||||
/// Onboarding repository backed by SharedPreferences
|
||||
final onboardingRepositoryProvider = Provider<OnboardingRepository>((ref) {
|
||||
return LocalOnboardingRepository();
|
||||
@@ -45,5 +40,6 @@ final preferencesRepositoryProvider = Provider<PreferencesRepository>((ref) {
|
||||
final tripRepositoryProvider = Provider<TripRepository>((ref) {
|
||||
final dio = ref.read(dioProvider);
|
||||
final remote = TripRemoteDataSourceImpl(dio: dio);
|
||||
return BackendTripRepository(remote: remote);
|
||||
final local = TripLocalDataSourceImpl();
|
||||
return BackendTripRepository(remote: remote, local: local);
|
||||
});
|
||||
|
||||
@@ -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<Future<Trip> 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<List<Trip>> {
|
||||
TripRepository get _repository => ref.read(tripRepositoryProvider);
|
||||
Future<List<Trip>>? _pendingLoad;
|
||||
|
||||
List<Trip> _currentTrips() => state.asData?.value ?? const <Trip>[];
|
||||
|
||||
@override
|
||||
Future<List<Trip>> 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<void> 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<void> 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<void> 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, List<Trip>>(CurrentTripsNotifier.new);
|
||||
|
||||
78
frontend/lib/presentation/utils/trip_location_utils.dart
Normal file
78
frontend/lib/presentation/utils/trip_location_utils.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
|
||||
class TripLocationUtils {
|
||||
const TripLocationUtils._();
|
||||
|
||||
static List<double>? 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<TripLocaleInfo> 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<String> 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<double>? coordinates;
|
||||
|
||||
bool get hasResolvedCity => cityName != null && cityName!.trim().isNotEmpty && cityName != 'Unknown';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// A map that allows the user to select a location for a new trip.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:widget_to_marker/widget_to_marker.dart';
|
||||
|
||||
import 'package:anyway/constants.dart';
|
||||
|
||||
import 'package:anyway/structs/trip.dart';
|
||||
import 'package:anyway/structs/landmark.dart';
|
||||
import 'package:anyway/modules/landmark_map_marker.dart';
|
||||
|
||||
|
||||
class NewTripMap extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<NewTripMap> createState() => _NewTripMapState();
|
||||
}
|
||||
|
||||
class _NewTripMapState extends State<NewTripMap> {
|
||||
final CameraPosition _cameraPosition = const CameraPosition(
|
||||
target: LatLng(48.8566, 2.3522),
|
||||
zoom: 11.0,
|
||||
);
|
||||
GoogleMapController? _mapController;
|
||||
final Set<Marker> _markers = <Marker>{};
|
||||
|
||||
_onLongPress(LatLng location) {
|
||||
// widget.trip.landmarks.clear();
|
||||
// widget.trip.addLandmark(
|
||||
// Landmark(
|
||||
// uuid: 'pending',
|
||||
// name: 'start',
|
||||
// location: [location.latitude, location.longitude],
|
||||
// type: typeStart
|
||||
// )
|
||||
// );
|
||||
}
|
||||
|
||||
updateTripDetails() async {
|
||||
_markers.clear();
|
||||
if (widget.trip.landmarks.isNotEmpty) {
|
||||
Landmark landmark = widget.trip.landmarks.first;
|
||||
_markers.add(
|
||||
Marker(
|
||||
markerId: MarkerId(landmark.uuid),
|
||||
position: LatLng(landmark.location[0], landmark.location[1]),
|
||||
icon: await ThemedMarker(landmark: landmark, position: 0).toBitmapDescriptor(
|
||||
logicalSize: const Size(150, 150),
|
||||
imageSize: const Size(150, 150)
|
||||
),
|
||||
)
|
||||
);
|
||||
// check if the controller is ready
|
||||
|
||||
if (_mapController != null) {
|
||||
_mapController!.animateCamera(
|
||||
CameraUpdate.newLatLng(
|
||||
LatLng(landmark.location[0], landmark.location[1])
|
||||
)
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onMapCreated(GoogleMapController controller) async {
|
||||
_mapController = controller;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
widget.trip.addListener(updateTripDetails);
|
||||
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
|
||||
|
||||
|
||||
return FutureBuilder(
|
||||
future: preferences,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
SharedPreferences prefs = snapshot.data as SharedPreferences;
|
||||
bool useLocation = prefs.getBool('useLocation') ?? true;
|
||||
return _buildMap(useLocation);
|
||||
} else {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap(bool useLocation) {
|
||||
return GoogleMap(
|
||||
onMapCreated: _onMapCreated,
|
||||
initialCameraPosition: _cameraPosition,
|
||||
onLongPress: _onLongPress,
|
||||
markers: _markers,
|
||||
cloudMapId: MAP_ID,
|
||||
mapToolbarEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
myLocationEnabled: useLocation,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
// A search bar that allow the user to enter a city name
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const Map<String, List> debugLocations = {
|
||||
'paris': [48.8575, 2.3514],
|
||||
'london': [51.5074, -0.1278],
|
||||
'new york': [40.7128, -74.0060],
|
||||
'tokyo': [35.6895, 139.6917],
|
||||
};
|
||||
|
||||
|
||||
|
||||
class NewTripLocationSearch extends StatefulWidget {
|
||||
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
|
||||
Trip trip;
|
||||
|
||||
NewTripLocationSearch(
|
||||
this.trip,
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
State<NewTripLocationSearch> createState() => _NewTripLocationSearchState();
|
||||
}
|
||||
|
||||
class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
setTripLocation (String query) async {
|
||||
List<Location> locations = [];
|
||||
Location startLocation;
|
||||
log('Searching for: $query');
|
||||
if (GeocodingPlatform.instance != null) {
|
||||
locations.addAll(await locationFromAddress(query));
|
||||
}
|
||||
|
||||
if (locations.isNotEmpty) {
|
||||
startLocation = locations.first;
|
||||
} else {
|
||||
log('No results found for: $query. Is geocoding available?');
|
||||
log('Setting Fallback location');
|
||||
List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514];
|
||||
startLocation = Location(
|
||||
latitude: coordinates[0],
|
||||
longitude: coordinates[1],
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
widget.trip.landmarks.clear();
|
||||
widget.trip.addLandmark(
|
||||
Landmark(
|
||||
uuid: 'pending',
|
||||
name: query,
|
||||
location: [startLocation.latitude, startLocation.longitude],
|
||||
type: typeStart
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
late Widget locationSearchBar = SearchBar(
|
||||
hintText: 'Enter a city name or long press on the map.',
|
||||
onSubmitted: setTripLocation,
|
||||
controller: _controller,
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setTripLocation(_controller.text);
|
||||
},
|
||||
child: const Text('Search'),
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
late Widget useCurrentLocationButton = ElevatedButton(
|
||||
onPressed: () async {
|
||||
// this widget is only shown if the user has already granted location permissions
|
||||
Position position = await Geolocator.getCurrentPosition();
|
||||
widget.trip.landmarks.clear();
|
||||
widget.trip.addLandmark(
|
||||
Landmark(
|
||||
uuid: 'pending',
|
||||
name: 'start',
|
||||
location: [position.latitude, position.longitude],
|
||||
type: typeStart
|
||||
)
|
||||
);
|
||||
},
|
||||
child: const Text('Use current location'),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: widget.prefs,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final useLocation = snapshot.data!.getBool('useLocation') ?? false;
|
||||
if (useLocation) {
|
||||
return Column(
|
||||
children: [
|
||||
locationSearchBar,
|
||||
useCurrentLocationButton,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return locationSearchBar;
|
||||
}
|
||||
} else {
|
||||
return locationSearchBar;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
|
||||
class TripLandmarkCard extends StatelessWidget {
|
||||
const TripLandmarkCard({super.key, required this.landmark, required this.position, required this.onToggleVisited, this.onOpenWebsite});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final VoidCallback onToggleVisited;
|
||||
final VoidCallback? onOpenWebsite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visited = landmark.isVisited;
|
||||
final metaItems = _buildMetaItems(
|
||||
duration: landmark.durationMinutes,
|
||||
reachTime: landmark.timeToReachNextMinutes,
|
||||
tags: landmark.description?.tags ?? const [],
|
||||
tagCount: landmark.tagCount,
|
||||
attractiveness: landmark.attractiveness,
|
||||
isSecondary: landmark.isSecondary ?? false,
|
||||
websiteLabel: landmark.websiteUrl,
|
||||
onOpenWebsite: onOpenWebsite,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_LandmarkMedia(landmark: landmark, position: position, type: landmark.type.type),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
landmark.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 22,
|
||||
padding: EdgeInsets.zero,
|
||||
splashRadius: 18,
|
||||
tooltip: visited ? 'Mark as pending' : 'Mark visited',
|
||||
onPressed: onToggleVisited,
|
||||
icon: Icon(visited ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: visited ? Colors.green : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (landmark.nameEn != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
landmark.nameEn!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
if (landmark.description?.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(landmark.description!.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.3)),
|
||||
),
|
||||
if (metaItems.isNotEmpty) ...[const SizedBox(height: 10), _MetaScroller(items: metaItems)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_MetaItem> _buildMetaItems({
|
||||
int? duration,
|
||||
int? reachTime,
|
||||
List<String> tags = const [],
|
||||
int? tagCount,
|
||||
int? attractiveness,
|
||||
bool isSecondary = false,
|
||||
String? websiteLabel,
|
||||
VoidCallback? onOpenWebsite,
|
||||
}) {
|
||||
final items = <_MetaItem>[];
|
||||
if (duration != null) {
|
||||
items.add(_MetaItem(icon: Icons.timer_outlined, label: '$duration min stay'));
|
||||
}
|
||||
if (reachTime != null) {
|
||||
items.add(_MetaItem(icon: Icons.directions_walk, label: '$reachTime min walk'));
|
||||
}
|
||||
if (attractiveness != null) {
|
||||
items.add(_MetaItem(icon: Icons.star, label: 'Score $attractiveness'));
|
||||
}
|
||||
if (tagCount != null) {
|
||||
items.add(_MetaItem(icon: Icons.label, label: '$tagCount tags'));
|
||||
}
|
||||
if (isSecondary) {
|
||||
items.add(_MetaItem(icon: Icons.layers, label: 'Secondary stop'));
|
||||
}
|
||||
for (final tag in tags.where((tag) => tag.trim().isNotEmpty).take(4)) {
|
||||
items.add(_MetaItem(icon: Icons.local_offer, label: tag));
|
||||
}
|
||||
if (websiteLabel != null && websiteLabel.isNotEmpty && onOpenWebsite != null) {
|
||||
items.add(_MetaItem(icon: Icons.link, label: 'Website', onTap: onOpenWebsite));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
class _LandmarkMedia extends StatelessWidget {
|
||||
const _LandmarkMedia({required this.landmark, required this.position, required this.type});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final LandmarkTypeEnum type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const width = 116.0;
|
||||
const mediaHeight = 140.0;
|
||||
final label = _typeLabel(type);
|
||||
final icon = _typeIcon(type);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: mediaHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildMedia()),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'#$position',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia() {
|
||||
if (landmark.imageUrl != null && landmark.imageUrl!.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: landmark.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
errorWidget: (context, url, error) => _placeholder(),
|
||||
);
|
||||
}
|
||||
return _placeholder();
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF9B208), Color(0xFFE72E77)]),
|
||||
),
|
||||
child: const Center(child: Icon(Icons.photo, color: Colors.white, size: 32)),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return 'Start';
|
||||
case LandmarkTypeEnum.finish:
|
||||
return 'Finish';
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return 'Shopping';
|
||||
case LandmarkTypeEnum.nature:
|
||||
return 'Nature';
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return 'Sight';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _typeIcon(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return Icons.flag;
|
||||
case LandmarkTypeEnum.finish:
|
||||
return Icons.flag_circle;
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case LandmarkTypeEnum.nature:
|
||||
return Icons.park;
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return Icons.place;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaScroller extends StatelessWidget {
|
||||
const _MetaScroller({required this.items});
|
||||
|
||||
final List<_MetaItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.icon, size: 14, color: Colors.grey.shade800),
|
||||
const SizedBox(width: 4),
|
||||
Text(item.label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (item.onTap == null) return chip;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(borderRadius: BorderRadius.circular(14), onTap: item.onTap, child: chip),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaItem {
|
||||
const _MetaItem({required this.icon, required this.label, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
185
frontend/lib/presentation/widgets/trip_details_panel.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:map_launcher/map_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_header.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_hero_meta_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_landmark_card.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_details/trip_step_between_landmarks.dart';
|
||||
|
||||
class TripDetailsPanel extends StatefulWidget {
|
||||
const TripDetailsPanel({super.key, required this.trip, required this.controller, this.onLandmarkUpdated});
|
||||
|
||||
final Trip trip;
|
||||
final ScrollController controller;
|
||||
final VoidCallback? onLandmarkUpdated;
|
||||
|
||||
@override
|
||||
State<TripDetailsPanel> createState() => _TripDetailsPanelState();
|
||||
}
|
||||
|
||||
class _TripDetailsPanelState extends State<TripDetailsPanel> {
|
||||
late Future<TripLocaleInfo> _localeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripDetailsPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_localeFuture = TripLocationUtils.resolveLocaleInfo(widget.trip);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final landmarks = widget.trip.landmarks;
|
||||
if (landmarks.isEmpty) {
|
||||
return const Center(child: Text('No landmarks in this trip yet.'));
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
controller: widget.controller,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 42,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FutureBuilder<TripLocaleInfo>(
|
||||
future: _localeFuture,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
final isLoading = snapshot.connectionState == ConnectionState.waiting;
|
||||
final subtitle = isLoading ? 'Pinpointing your destination...' : 'Here is a recommended route for your trip in ${info?.cityName ?? 'your area'}.';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TripHeroHeader(localeInfo: info),
|
||||
const SizedBox(height: 10),
|
||||
TripHeroMetaCard(
|
||||
subtitle: subtitle,
|
||||
totalStops: widget.trip.landmarks.length,
|
||||
totalMinutes: widget.trip.totalTimeMinutes,
|
||||
countryName: info?.countryName,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final landmark = landmarks[index];
|
||||
return Column(
|
||||
children: [
|
||||
TripLandmarkCard(
|
||||
landmark: landmark,
|
||||
position: index + 1,
|
||||
onToggleVisited: () => _toggleVisited(landmark),
|
||||
onOpenWebsite: landmark.websiteUrl == null ? null : () => _openWebsite(landmark.websiteUrl!),
|
||||
),
|
||||
if (index < landmarks.length - 1)
|
||||
TripStepBetweenLandmarks(current: landmark, next: landmarks[index + 1], onRequestDirections: () => _showNavigationOptions(context, landmark, landmarks[index + 1])),
|
||||
],
|
||||
);
|
||||
}, childCount: landmarks.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleVisited(Landmark landmark) {
|
||||
setState(() => landmark.isVisited = !landmark.isVisited);
|
||||
widget.onLandmarkUpdated?.call();
|
||||
}
|
||||
|
||||
Future<void> _openWebsite(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _showNavigationOptions(BuildContext context, Landmark current, Landmark next) async {
|
||||
if (!_hasValidLocation(current) || !_hasValidLocation(next)) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Coordinates unavailable for directions.')));
|
||||
return;
|
||||
}
|
||||
|
||||
List<AvailableMap> availableMaps = [];
|
||||
try {
|
||||
availableMaps = await MapLauncher.installedMaps;
|
||||
} catch (error) {
|
||||
debugPrint('Unable to load maps: $error');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (availableMaps.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No navigation apps detected.')));
|
||||
return;
|
||||
}
|
||||
|
||||
final origin = Coords(current.location[0], current.location[1]);
|
||||
final destination = Coords(next.location[0], next.location[1]);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Navigate to ${next.name}', style: Theme.of(sheetContext).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('Choose your preferred maps app.', style: Theme.of(sheetContext).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final map in availableMaps)
|
||||
ListTile(
|
||||
leading: ClipRRect(borderRadius: BorderRadius.circular(8), child: SvgPicture.asset(map.icon, height: 30, width: 30)),
|
||||
title: Text(map.mapName),
|
||||
onTap: () async {
|
||||
await map.showDirections(origin: origin, originTitle: current.name, destination: destination, destinationTitle: next.name, directionsMode: DirectionsMode.walking);
|
||||
if (sheetContext.mounted) Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Landmark landmark) => landmark.location.length >= 2;
|
||||
}
|
||||
221
frontend/lib/presentation/widgets/trip_map.dart
Normal file
221
frontend/lib/presentation/widgets/trip_map.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:widget_to_marker/widget_to_marker.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_marker_graphic.dart';
|
||||
|
||||
class TripMap extends StatefulWidget {
|
||||
const TripMap({
|
||||
super.key,
|
||||
required this.trip,
|
||||
this.showRoute = false,
|
||||
this.interactive = false,
|
||||
this.height,
|
||||
this.borderRadius = 12,
|
||||
this.enableMyLocation = false,
|
||||
});
|
||||
|
||||
final Trip trip;
|
||||
final bool showRoute;
|
||||
final bool interactive;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
final bool enableMyLocation;
|
||||
|
||||
@override
|
||||
State<TripMap> createState() => _TripMapState();
|
||||
}
|
||||
|
||||
class _TripMapState extends State<TripMap> {
|
||||
GoogleMapController? _controller;
|
||||
LatLng? _startLatLng;
|
||||
Set<Marker> _markers = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startLatLng = _extractStartLatLng();
|
||||
_refreshMarkers();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_startLatLng = _extractStartLatLng();
|
||||
_animateToStart();
|
||||
}
|
||||
if (oldWidget.trip.landmarks != widget.trip.landmarks ||
|
||||
oldWidget.showRoute != widget.showRoute ||
|
||||
oldWidget.interactive != widget.interactive) {
|
||||
_startLatLng = _extractStartLatLng();
|
||||
}
|
||||
_refreshMarkers();
|
||||
}
|
||||
|
||||
LatLng? _extractStartLatLng() {
|
||||
final coords = TripLocationUtils.startCoordinates(widget.trip);
|
||||
if (coords == null) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(coords[0], coords[1]);
|
||||
}
|
||||
|
||||
void _animateToStart() {
|
||||
// TODO - required?
|
||||
if (_controller == null || _startLatLng == null) {
|
||||
return;
|
||||
}
|
||||
_controller!.animateCamera(CameraUpdate.newLatLng(_startLatLng!));
|
||||
}
|
||||
|
||||
Future<void> _refreshMarkers() async {
|
||||
if (_startLatLng == null) {
|
||||
setState(() => _markers = const {});
|
||||
return;
|
||||
}
|
||||
|
||||
final targets = widget.showRoute
|
||||
? widget.trip.landmarks
|
||||
: widget.trip.landmarks.isEmpty
|
||||
? const <Landmark>[]
|
||||
: <Landmark>[widget.trip.landmarks.first];
|
||||
|
||||
if (targets.isEmpty) {
|
||||
setState(() => _markers = const {});
|
||||
return;
|
||||
}
|
||||
|
||||
final markerSet = <Marker>{};
|
||||
for (var i = 0; i < targets.length; i++) {
|
||||
final landmark = targets[i];
|
||||
final latLng = _latLngFromLandmark(landmark);
|
||||
if (latLng == null) continue;
|
||||
final descriptor = await TripMarkerGraphic(
|
||||
landmark: landmark,
|
||||
position: i + 1,
|
||||
compact: !widget.showRoute,
|
||||
).toBitmapDescriptor(
|
||||
// use sizes based on font size to keep markers consistent:
|
||||
imageSize: Size(500, 500),
|
||||
);
|
||||
markerSet.add(
|
||||
Marker(
|
||||
markerId: MarkerId('landmark-${landmark.uuid}'),
|
||||
// since the marker is a cirlce (not a pin), we center it
|
||||
anchor: const Offset(0, 0),
|
||||
|
||||
position: latLng,
|
||||
icon: descriptor,
|
||||
// TODO - don't use info window but bind taps to the bottom panel
|
||||
// infoWindow: InfoWindow(title: landmark.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _markers = markerSet);
|
||||
}
|
||||
|
||||
LatLng? _latLngFromLandmark(Landmark landmark) {
|
||||
if (landmark.location.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(landmark.location[0], landmark.location[1]);
|
||||
}
|
||||
|
||||
Set<Polyline> _buildPolylines() {
|
||||
if (!widget.showRoute) {
|
||||
return const <Polyline>{};
|
||||
}
|
||||
final points = widget.trip.landmarks;
|
||||
if (points.length < 2) {
|
||||
return const <Polyline>{};
|
||||
}
|
||||
|
||||
final polylines = <Polyline>{};
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
final current = points[i];
|
||||
final next = points[i + 1];
|
||||
if (current.location.length < 2 || next.location.length < 2) {
|
||||
continue;
|
||||
}
|
||||
polylines.add(
|
||||
Polyline(
|
||||
polylineId: PolylineId('segment-${current.uuid}-${next.uuid}'),
|
||||
points: [
|
||||
LatLng(current.location[0], current.location[1]),
|
||||
LatLng(next.location[0], next.location[1]),
|
||||
],
|
||||
width: 4,
|
||||
color: points[i].isVisited && next.isVisited
|
||||
? Colors.grey
|
||||
: PRIMARY_COLOR,
|
||||
),
|
||||
);
|
||||
}
|
||||
return polylines;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_startLatLng == null) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('No landmarks available yet'),
|
||||
);
|
||||
}
|
||||
|
||||
final map = GoogleMap(
|
||||
key: ValueKey(
|
||||
'trip-map-${widget.trip.uuid}-${widget.showRoute}-${widget.interactive}',
|
||||
),
|
||||
onMapCreated: (controller) {
|
||||
_controller = controller;
|
||||
_animateToStart();
|
||||
},
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _startLatLng!,
|
||||
zoom: widget.interactive ? 12.5 : 13.5,
|
||||
),
|
||||
markers: _markers,
|
||||
polylines: _buildPolylines(),
|
||||
mapToolbarEnabled: widget.interactive,
|
||||
zoomControlsEnabled: widget.interactive,
|
||||
zoomGesturesEnabled: widget.interactive,
|
||||
scrollGesturesEnabled: widget.interactive,
|
||||
rotateGesturesEnabled: widget.interactive,
|
||||
tiltGesturesEnabled: widget.interactive,
|
||||
liteModeEnabled: !widget.interactive,
|
||||
myLocationEnabled: widget.enableMyLocation && widget.interactive,
|
||||
myLocationButtonEnabled: widget.enableMyLocation && widget.interactive,
|
||||
compassEnabled: widget.interactive,
|
||||
cloudMapId: MAP_ID,
|
||||
);
|
||||
|
||||
Widget decoratedMap = map;
|
||||
|
||||
if (widget.height != null) {
|
||||
decoratedMap = SizedBox(height: widget.height, child: decoratedMap);
|
||||
}
|
||||
|
||||
if (widget.borderRadius > 0) {
|
||||
decoratedMap = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: decoratedMap,
|
||||
);
|
||||
}
|
||||
|
||||
return decoratedMap;
|
||||
}
|
||||
}
|
||||
|
||||
99
frontend/lib/presentation/widgets/trip_marker_graphic.dart
Normal file
99
frontend/lib/presentation/widgets/trip_marker_graphic.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/core/constants.dart';
|
||||
import 'package:anyway/domain/entities/landmark.dart';
|
||||
import 'package:anyway/domain/entities/landmark_type.dart';
|
||||
|
||||
class TripMarkerGraphic extends StatelessWidget {
|
||||
const TripMarkerGraphic({
|
||||
super.key,
|
||||
required this.landmark,
|
||||
required this.position,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final Landmark landmark;
|
||||
final int position;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gradient = landmark.isVisited
|
||||
? const LinearGradient(colors: [Colors.grey, Colors.white])
|
||||
: APP_GRADIENT;
|
||||
final showPosition = landmark.type.type != LandmarkTypeEnum.start &&
|
||||
landmark.type.type != LandmarkTypeEnum.finish;
|
||||
final markerDiameter = compact ? 68.0 : 84.0;
|
||||
final borderWidth = compact ? 3.0 : 4.5;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
width: markerDiameter,
|
||||
height: markerDiameter,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black87, width: borderWidth),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 6,
|
||||
color: Colors.black26,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.all(compact ? 8 : 10),
|
||||
child: Icon(
|
||||
_iconForType(landmark.type.type),
|
||||
color: Colors.black87,
|
||||
size: compact ? 30 : 38,
|
||||
),
|
||||
),
|
||||
if (showPosition)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(compact ? 4 : 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black26),
|
||||
),
|
||||
child: Text(
|
||||
position.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: compact ? 12 : 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO - should this be a landmark property?
|
||||
IconData _iconForType(LandmarkTypeEnum type) {
|
||||
switch (type) {
|
||||
case LandmarkTypeEnum.start:
|
||||
return Icons.flag;
|
||||
case LandmarkTypeEnum.finish:
|
||||
return Icons.flag_circle;
|
||||
case LandmarkTypeEnum.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case LandmarkTypeEnum.nature:
|
||||
return Icons.park;
|
||||
case LandmarkTypeEnum.sightseeing:
|
||||
return Icons.place;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
frontend/lib/presentation/widgets/trip_summary_card.dart
Normal file
95
frontend/lib/presentation/widgets/trip_summary_card.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:anyway/domain/entities/trip.dart';
|
||||
import 'package:anyway/presentation/utils/trip_location_utils.dart';
|
||||
import 'package:anyway/presentation/widgets/trip_map.dart';
|
||||
|
||||
class TripSummaryCard extends StatefulWidget {
|
||||
const TripSummaryCard({super.key, required this.trip, required this.onTap});
|
||||
|
||||
final Trip trip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<TripSummaryCard> createState() => _TripSummaryCardState();
|
||||
}
|
||||
|
||||
class _TripSummaryCardState extends State<TripSummaryCard> {
|
||||
late Future<String> _cityFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TripSummaryCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trip.uuid != widget.trip.uuid) {
|
||||
_cityFuture = TripLocationUtils.resolveCityName(widget.trip);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final landmarksCount = widget.trip.landmarks.length;
|
||||
final startCoords = TripLocationUtils.startCoordinates(widget.trip);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TripMap(
|
||||
trip: widget.trip,
|
||||
showRoute: false,
|
||||
interactive: false,
|
||||
height: 180,
|
||||
borderRadius: 0,
|
||||
),
|
||||
// TODO - a more useful information to include will be the duration and the time of creation. But we are not there yet.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: FutureBuilder<String>(
|
||||
future: _cityFuture,
|
||||
builder: (context, snapshot) {
|
||||
final title = snapshot.data ?? 'Trip ${widget.trip.uuid}';
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
// TODO - start should be more descriptive. or omitted
|
||||
child: Text(
|
||||
startCoords == null
|
||||
? 'Start: unknown'
|
||||
: 'Start: ${startCoords[0].toStringAsFixed(4)}, '
|
||||
'${startCoords[1].toStringAsFixed(4)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.route, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text('$landmarksCount stops'),
|
||||
const Spacer(),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
124
frontend/test/current_trips_notifier_test.dart
Normal file
124
frontend/test/current_trips_notifier_test.dart
Normal file
@@ -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<Trip> _trips;
|
||||
|
||||
@override
|
||||
Future<void> deleteSavedTrip(String uuid) async {
|
||||
_trips.removeWhere((trip) => trip.uuid == uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Trip?> getSavedTrip(String uuid) async {
|
||||
try {
|
||||
return _trips.firstWhere((trip) => trip.uuid == uuid);
|
||||
} on StateError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Trip>> getSavedTrips() async {
|
||||
return List.unmodifiable(_trips);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<Trip> getTrip({Preferences? preferences, String? tripUUID, List<Landmark>? landmarks}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Landmark>> 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');
|
||||
});
|
||||
}
|
||||
69
frontend/test/trip_local_datasource_test.dart
Normal file
69
frontend/test/trip_local_datasource_test.dart
Normal file
@@ -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<String, dynamic> 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user