feat: add route planning and sharing
This commit is contained in:
parent
209987195b
commit
23e63350b0
|
|
@ -962,5 +962,236 @@ describe('Social Architecture Security Rules', () => {
|
|||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('MotorSignal route planning and sharing rules', () => {
|
||||
test('owner can create, read, update, and delete their own route', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const routeRef = doc(alice, 'routes/route_alice_1');
|
||||
|
||||
// 1. Create valid route: SUCCEEDS
|
||||
await assertSucceeds(setDoc(routeRef, {
|
||||
id: 'route_alice_1',
|
||||
ownerUid: 'alice',
|
||||
title: 'Skyline Boulevard Loop',
|
||||
description: 'Twisties through the redwoods',
|
||||
visibility: 'private',
|
||||
stops: [
|
||||
{
|
||||
id: 'stop-1',
|
||||
latitude: 37.4,
|
||||
longitude: -122.2,
|
||||
displayName: 'Alice\'s Restaurant',
|
||||
stopType: 'origin',
|
||||
orderIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'stop-2',
|
||||
latitude: 37.3,
|
||||
longitude: -122.1,
|
||||
displayName: 'Page Mill Vista',
|
||||
stopType: 'destination',
|
||||
orderIndex: 1,
|
||||
},
|
||||
],
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
}));
|
||||
|
||||
// 2. Read own route: SUCCEEDS
|
||||
await assertSucceeds(getDoc(routeRef));
|
||||
|
||||
// 3. Update own route: SUCCEEDS
|
||||
await assertSucceeds(updateDoc(routeRef, {
|
||||
title: 'Skyline Boulevard Loop Extended',
|
||||
updatedAt: '2026-09-04T12:30:00Z',
|
||||
}));
|
||||
|
||||
// 4. Delete own route: SUCCEEDS
|
||||
await assertSucceeds(deleteDoc(routeRef));
|
||||
});
|
||||
|
||||
test('validates route constraints: title length and max stops', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
|
||||
// Title too short (<3 chars): FAILS
|
||||
await assertFails(setDoc(doc(alice, 'routes/route_short_title'), {
|
||||
id: 'route_short_title',
|
||||
ownerUid: 'alice',
|
||||
title: 'AB',
|
||||
stops: [],
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
}));
|
||||
|
||||
// Stops exceed 25 cap: FAILS
|
||||
const excessiveStops = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
excessiveStops.push({
|
||||
id: `stop-${i}`,
|
||||
latitude: 37.0 + i * 0.01,
|
||||
longitude: -122.0,
|
||||
displayName: `Stop ${i}`,
|
||||
stopType: 'waypoint',
|
||||
orderIndex: i,
|
||||
});
|
||||
}
|
||||
await assertFails(setDoc(doc(alice, 'routes/route_too_many_stops'), {
|
||||
id: 'route_too_many_stops',
|
||||
ownerUid: 'alice',
|
||||
title: 'Way Too Many Stops Route',
|
||||
stops: excessiveStops,
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
}));
|
||||
});
|
||||
|
||||
test('private route cannot be read or modified by non-owner', async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'routes/route_private_alice'), {
|
||||
id: 'route_private_alice',
|
||||
ownerUid: 'alice',
|
||||
title: 'Secret Canyon Route',
|
||||
visibility: 'private',
|
||||
stops: [],
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const targetRoute = doc(bob, 'routes/route_private_alice');
|
||||
|
||||
// Bob cannot read private route
|
||||
await assertFails(getDoc(targetRoute));
|
||||
// Bob cannot update private route
|
||||
await assertFails(updateDoc(targetRoute, { title: 'Hacked Title' }));
|
||||
// Bob cannot delete private route
|
||||
await assertFails(deleteDoc(targetRoute));
|
||||
});
|
||||
|
||||
test('public route can be read by any signed-in user, but only modified by owner', async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'routes/route_public_alice'), {
|
||||
id: 'route_public_alice',
|
||||
ownerUid: 'alice',
|
||||
title: 'Public Coastal Highway 1',
|
||||
visibility: 'public',
|
||||
stops: [],
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const targetRoute = doc(bob, 'routes/route_public_alice');
|
||||
|
||||
// Bob can read public route
|
||||
await assertSucceeds(getDoc(targetRoute));
|
||||
// Bob cannot update public route
|
||||
await assertFails(updateDoc(targetRoute, { title: 'Bob Was Here' }));
|
||||
});
|
||||
|
||||
test('groupOnly route can be read by accepted group members, but not by outsiders', async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_for_route'), {
|
||||
id: 'ride_for_route',
|
||||
organizerUid: 'alice',
|
||||
title: 'Sunday Group Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
||||
});
|
||||
await setDoc(doc(db, 'group_rides/ride_for_route/participants/bob'), {
|
||||
userId: 'bob',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
riderHandle: 'bob',
|
||||
riderDisplayName: 'Bob',
|
||||
});
|
||||
await setDoc(doc(db, 'routes/route_group_only'), {
|
||||
id: 'route_group_only',
|
||||
ownerUid: 'alice',
|
||||
title: 'Group Official Route',
|
||||
visibility: 'groupOnly',
|
||||
attachedGroupRideId: 'ride_for_route',
|
||||
stops: [],
|
||||
createdAt: '2026-09-04T12:00:00Z',
|
||||
updatedAt: '2026-09-04T12:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
|
||||
// Accepted member Bob CAN read groupOnly route
|
||||
await assertSucceeds(getDoc(doc(bob, 'routes/route_group_only')));
|
||||
|
||||
// Non-member Charlie CANNOT read groupOnly route
|
||||
await assertFails(getDoc(doc(charlie, 'routes/route_group_only')));
|
||||
});
|
||||
|
||||
test('group ride route attachment authority: only organizer can attach or detach route', async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_attach_authority'), {
|
||||
id: 'ride_attach_authority',
|
||||
organizerUid: 'alice',
|
||||
title: 'Route Authority Group Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
||||
});
|
||||
await setDoc(doc(db, 'group_rides/ride_attach_authority/participants/bob'), {
|
||||
userId: 'bob',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
riderHandle: 'bob',
|
||||
riderDisplayName: 'Bob',
|
||||
});
|
||||
});
|
||||
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const rideDocRef = (db) => doc(db, 'group_rides/ride_attach_authority');
|
||||
|
||||
// 1. Participant Bob attempts to attach a route: MUST FAIL
|
||||
await assertFails(updateDoc(rideDocRef(bob), {
|
||||
routeId: 'route_bob_custom',
|
||||
routeTitle: 'Bob\'s Route',
|
||||
routeDistanceMeters: 45000,
|
||||
routeStopCount: 4,
|
||||
}));
|
||||
|
||||
// 2. Organizer Alice attaches a route: SUCCEEDS
|
||||
await assertSucceeds(updateDoc(rideDocRef(alice), {
|
||||
routeId: 'route_official_alice',
|
||||
routeTitle: 'Alice\'s Official Route',
|
||||
routeDistanceMeters: 52000,
|
||||
routeStopCount: 5,
|
||||
}));
|
||||
|
||||
// 3. Participant Bob attempts to detach or change route: MUST FAIL
|
||||
await assertFails(updateDoc(rideDocRef(bob), {
|
||||
routeId: null,
|
||||
}));
|
||||
|
||||
// 4. Organizer Alice detaches route: SUCCEEDS
|
||||
await assertSucceeds(updateDoc(rideDocRef(alice), {
|
||||
routeId: null,
|
||||
routeTitle: null,
|
||||
routeDistanceMeters: null,
|
||||
routeStopCount: null,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -360,8 +360,61 @@ service cloud.firestore {
|
|||
}
|
||||
}
|
||||
|
||||
// --- ROUTES ---
|
||||
// User-created motorcycle routes (standalone or attached to group rides).
|
||||
match /routes/{routeId} {
|
||||
function isRouteOwner() {
|
||||
return signedIn() && resource.data.ownerUid == request.auth.uid;
|
||||
}
|
||||
|
||||
function canReadRoute() {
|
||||
return signedIn() && (
|
||||
isRouteOwner() ||
|
||||
(
|
||||
!areMutuallyBlocked(request.auth.uid, resource.data.ownerUid) && (
|
||||
resource.data.visibility == 'public' ||
|
||||
(
|
||||
resource.data.visibility == 'groupOnly' &&
|
||||
resource.data.attachedGroupRideId != null &&
|
||||
exists(/databases/$(database)/documents/group_rides/$(resource.data.attachedGroupRideId)/participants/$(request.auth.uid))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validRouteCreate() {
|
||||
return signedIn()
|
||||
&& request.resource.data.id == routeId
|
||||
&& request.resource.data.ownerUid == request.auth.uid
|
||||
&& request.resource.data.title is string
|
||||
&& request.resource.data.title.size() >= 3
|
||||
&& request.resource.data.title.size() <= 80
|
||||
&& request.resource.data.stops is list
|
||||
&& request.resource.data.stops.size() <= 25
|
||||
&& request.resource.data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']);
|
||||
}
|
||||
|
||||
function validRouteUpdate() {
|
||||
return isRouteOwner()
|
||||
&& request.resource.data.id == resource.data.id
|
||||
&& request.resource.data.ownerUid == resource.data.ownerUid
|
||||
&& request.resource.data.title is string
|
||||
&& request.resource.data.title.size() >= 3
|
||||
&& request.resource.data.title.size() <= 80
|
||||
&& request.resource.data.stops is list
|
||||
&& request.resource.data.stops.size() <= 25;
|
||||
}
|
||||
|
||||
allow read: if canReadRoute();
|
||||
allow create: if validRouteCreate();
|
||||
allow update: if validRouteUpdate();
|
||||
allow delete: if isRouteOwner();
|
||||
}
|
||||
|
||||
match /{document=**} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import '../features/group_rides/group_rides_hub_screen.dart';
|
|||
import '../features/group_rides/create_group_ride_screen.dart';
|
||||
import '../features/group_rides/group_ride_detail_screen.dart';
|
||||
import '../features/group_rides/live_group_ride_screen.dart';
|
||||
import '../features/routes/route_planner_screen.dart';
|
||||
import '../features/routes/route_detail_screen.dart';
|
||||
|
||||
// ignore: unused_element
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -195,5 +197,23 @@ final appRouter = GoRouter(
|
|||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/routes/planner',
|
||||
builder: (context, state) {
|
||||
final initialRouteId = state.uri.queryParameters['routeId'];
|
||||
final attachToGroupRideId = state.uri.queryParameters['groupRideId'];
|
||||
return RoutePlannerScreen(
|
||||
initialRouteId: initialRouteId,
|
||||
attachToGroupRideId: attachToGroupRideId,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/routes/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return RouteDetailScreen(routeId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import 'package:go_router/go_router.dart';
|
|||
import '../../services/group_rides/group_ride_models.dart';
|
||||
import '../../services/group_rides/group_ride_providers.dart';
|
||||
import '../../services/social/social_providers.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
|
||||
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
||||
const CreateGroupRideScreen({super.key});
|
||||
|
|
@ -27,15 +30,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
final _meetupNameController = TextEditingController();
|
||||
final _meetupAddressController = TextEditingController();
|
||||
final _meetupNotesController = TextEditingController();
|
||||
final double _meetupLat = 34.5378;
|
||||
final double _meetupLng = -83.9849;
|
||||
double _meetupLat = 34.5378;
|
||||
double _meetupLng = -83.9849;
|
||||
|
||||
// Destination
|
||||
bool _hasDestination = true;
|
||||
final _destNameController = TextEditingController();
|
||||
final _destAddressController = TextEditingController();
|
||||
final double _destLat = 34.8826;
|
||||
final double _destLng = -83.9281;
|
||||
double _destLat = 34.8826;
|
||||
double _destLng = -83.9281;
|
||||
|
||||
// Attached Route info
|
||||
String? _selectedRouteId;
|
||||
String? _selectedRouteTitle;
|
||||
double? _selectedRouteDistanceMeters;
|
||||
int? _selectedRouteStopCount;
|
||||
|
||||
// Intermediate stops
|
||||
final List<GroupRideLocation> _stops = [];
|
||||
|
|
@ -119,6 +128,10 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
meetingPoint: meetingPoint,
|
||||
destination: destination,
|
||||
stops: _stops,
|
||||
routeId: _selectedRouteId,
|
||||
routeTitle: _selectedRouteTitle,
|
||||
routeDistanceMeters: _selectedRouteDistanceMeters,
|
||||
routeStopCount: _selectedRouteStopCount,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
|
|
@ -135,6 +148,156 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _applySavedRoute(MotorSignalRoute route) {
|
||||
setState(() {
|
||||
_selectedRouteId = route.id;
|
||||
_selectedRouteTitle = route.title;
|
||||
_selectedRouteDistanceMeters = route.totalDistanceMeters;
|
||||
_selectedRouteStopCount = route.stops.length;
|
||||
|
||||
if (_titleController.text.trim().isEmpty) {
|
||||
_titleController.text = route.title;
|
||||
}
|
||||
if (_descriptionController.text.trim().isEmpty && route.description.isNotEmpty) {
|
||||
_descriptionController.text = route.description;
|
||||
}
|
||||
|
||||
if (route.stops.isNotEmpty) {
|
||||
final origin = route.stops.first;
|
||||
_meetupNameController.text = origin.displayName;
|
||||
_meetupLat = origin.latitude;
|
||||
_meetupLng = origin.longitude;
|
||||
if (origin.notes.isNotEmpty) {
|
||||
_meetupNotesController.text = origin.notes;
|
||||
}
|
||||
|
||||
if (route.stops.length > 1) {
|
||||
final dest = route.stops.last;
|
||||
_hasDestination = true;
|
||||
_destNameController.text = dest.displayName;
|
||||
_destLat = dest.latitude;
|
||||
_destLng = dest.longitude;
|
||||
if (dest.notes.isNotEmpty) {
|
||||
_destAddressController.text = dest.notes;
|
||||
}
|
||||
|
||||
_stops.clear();
|
||||
if (route.stops.length > 2) {
|
||||
for (int i = 1; i < route.stops.length - 1; i++) {
|
||||
final s = route.stops[i];
|
||||
_stops.add(
|
||||
GroupRideLocation(
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
displayName: s.displayName,
|
||||
notes: s.notes,
|
||||
locationType: GroupRideLocationType.waypoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showSelectRouteSheet() {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF13151A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (sheetCtx) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'IMPORT SAVED ROUTE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
routesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No saved routes found.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
_applySavedRoute(route);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addStopDialog() {
|
||||
final nameCtrl = TextEditingController();
|
||||
final notesCtrl = TextEditingController();
|
||||
|
|
@ -239,6 +402,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)),
|
||||
tooltip: 'Import Route',
|
||||
onPressed: _showSelectRouteSheet,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _submit,
|
||||
child: _isSaving
|
||||
|
|
@ -477,6 +645,78 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
|||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// Route Pre-fill / Attachment
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF161922),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF2A2E3D)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.alt_route, color: Color(0xFF00E5FF), size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedRouteTitle != null
|
||||
? 'Route: $_selectedRouteTitle'
|
||||
: 'Plan with Saved Route',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
_selectedRouteTitle != null
|
||||
? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi'
|
||||
: 'Optional: import stops & coordinates',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selectedRouteTitle != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54, size: 18),
|
||||
tooltip: 'Clear route',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedRouteId = null;
|
||||
_selectedRouteTitle = null;
|
||||
_selectedRouteDistanceMeters = null;
|
||||
_selectedRouteStopCount = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
else
|
||||
OutlinedButton(
|
||||
onPressed: _showSelectRouteSheet,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFF00E5FF)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'IMPORT',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../services/auth/auth_providers.dart';
|
|||
import '../../services/group_rides/group_ride_models.dart';
|
||||
import '../../services/group_rides/group_ride_providers.dart';
|
||||
import '../../services/social/social_providers.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
|
||||
class GroupRideDetailScreen extends ConsumerStatefulWidget {
|
||||
final String rideId;
|
||||
|
|
@ -238,6 +239,10 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
_buildLocationsCard(ride),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Route Card
|
||||
_buildRouteCard(context, ride, isOrganizer, currentUid),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Intermediate stops
|
||||
if (ride.stops.isNotEmpty) ...[
|
||||
_buildStopsCard(ride),
|
||||
|
|
@ -837,6 +842,300 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildRouteCard(
|
||||
BuildContext context,
|
||||
GroupRide ride,
|
||||
bool isOrganizer,
|
||||
String? currentUid,
|
||||
) {
|
||||
final hasRoute = ride.routeId != null;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161922),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.alt_route, color: Color(0xFF00E5FF), size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'ROUTE & WAYPOINTS',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasRoute)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${ride.routeStopCount ?? 0} STOPS',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (hasRoute) ...[
|
||||
Text(
|
||||
ride.routeTitle ?? 'Attached Route',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (ride.routeDistanceMeters != null) ...[
|
||||
Text(
|
||||
'${(ride.routeDistanceMeters! / 1609.34).toStringAsFixed(1)} mi straight-line preview',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.push('/routes/${ride.routeId}'),
|
||||
icon: const Icon(Icons.map, size: 16),
|
||||
label: const Text('VIEW ROUTE / PLANNER'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showAttachRouteSheet(context, ride),
|
||||
icon: const Icon(Icons.swap_horiz, size: 16, color: Color(0xFF00E5FF)),
|
||||
label: const Text('CHANGE', style: TextStyle(color: Color(0xFF00E5FF))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFF2A2E3D)),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref.read(routeServiceProvider).detachRouteFromGroupRide(ride.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Route detached from group ride.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to detach route: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.link_off, size: 16, color: Color(0xFFFF5252)),
|
||||
label: const Text('DETACH', style: TextStyle(color: Color(0xFFFF5252))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFFF5252)),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
'No custom route attached yet.',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Attach a multi-stop route with straight-line map preview and navigation handoff.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.push('/routes/planner?groupRideId=${ride.id}'),
|
||||
icon: const Icon(Icons.add_road, size: 16),
|
||||
label: const Text('PLAN NEW ROUTE'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showAttachRouteSheet(context, ride),
|
||||
icon: const Icon(Icons.link, size: 16, color: Color(0xFF00E5FF)),
|
||||
label: const Text('ATTACH SAVED ROUTE', style: TextStyle(color: Color(0xFF00E5FF))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFF2A2E3D)),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAttachRouteSheet(BuildContext context, GroupRide ride) {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF13151A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (sheetCtx) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final routesAsync = ref.watch(userRoutesProvider(user.uid));
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'ATTACH SAVED ROUTE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
routesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
||||
),
|
||||
),
|
||||
error: (err, _) => Text(
|
||||
'Failed to load routes: $err',
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
data: (routes) {
|
||||
if (routes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'You have no saved routes yet.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(sheetCtx);
|
||||
context.push('/routes/planner?groupRideId=${ride.id}');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
child: const Text('CREATE A ROUTE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routes.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final route = routes[idx];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)),
|
||||
title: Text(
|
||||
route.title,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${route.stops.length} stops • ${route.formattedDistanceMiles}',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetCtx);
|
||||
try {
|
||||
await ref.read(routeServiceProvider).attachRouteToGroupRide(
|
||||
groupRideId: ride.id,
|
||||
route: route,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Attached "${route.title}" to group ride.'),
|
||||
backgroundColor: const Color(0xFF00E676),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to attach route: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStopsCard(GroupRide ride) {
|
||||
return Card(
|
||||
color: const Color(0xFF161922),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import '../../services/group_rides/group_ride_providers.dart';
|
|||
import '../../services/group_rides/live_broadcast_controller.dart';
|
||||
import '../../services/map/map_provider_interface.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
import '../../services/routes/navigation_handoff.dart';
|
||||
|
||||
class LiveGroupRideScreen extends ConsumerStatefulWidget {
|
||||
final String rideId;
|
||||
|
|
@ -174,13 +177,34 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
meetup: ride.meetingPoint,
|
||||
);
|
||||
|
||||
// Watch attached route if present
|
||||
final attachedRoute = ride.routeId != null
|
||||
? ref.watch(routeByIdProvider(ride.routeId!)).valueOrNull
|
||||
: null;
|
||||
|
||||
final polylines = <AppPolyline>[];
|
||||
if (attachedRoute != null && attachedRoute.stops.length >= 2) {
|
||||
polylines.add(
|
||||
AppPolyline(
|
||||
id: 'attached_route_path',
|
||||
points: attachedRoute.previewPoints
|
||||
.map((p) => AppLatLng(p.latitude, p.longitude))
|
||||
.toList(),
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.85),
|
||||
width: 3.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build markers
|
||||
final markers = _buildMarkers(
|
||||
context: context,
|
||||
ride: ride,
|
||||
participants: acceptedParticipants,
|
||||
presences: presences,
|
||||
currentUid: currentUid,
|
||||
leadUid: leadParticipant?.userId,
|
||||
attachedRoute: attachedRoute,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
|
|
@ -202,6 +226,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
center: center,
|
||||
zoom: _mapZoom,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
onPositionChanged: (pos) {
|
||||
_customMapCenter = pos;
|
||||
},
|
||||
|
|
@ -1027,11 +1052,13 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
}
|
||||
|
||||
List<AppMapMarker> _buildMarkers({
|
||||
required BuildContext context,
|
||||
required GroupRide ride,
|
||||
required List<GroupRideParticipant> participants,
|
||||
required List<LiveGroupPresence> presences,
|
||||
required String? currentUid,
|
||||
required String? leadUid,
|
||||
MotorSignalRoute? attachedRoute,
|
||||
}) {
|
||||
final markers = <AppMapMarker>[];
|
||||
|
||||
|
|
@ -1054,37 +1081,91 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
),
|
||||
child: const Icon(Icons.flag, color: Colors.black, size: 16),
|
||||
),
|
||||
onTap: () {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: ride.meetingPoint.latitude,
|
||||
longitude: ride.meetingPoint.longitude,
|
||||
displayName: ride.meetingPoint.displayName,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Intermediate Stop Markers
|
||||
for (int i = 0; i < ride.stops.length; i++) {
|
||||
final stop = ride.stops[i];
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'stop_$i',
|
||||
position: AppLatLng(stop.latitude, stop.longitude),
|
||||
label: stop.displayName.isNotEmpty ? stop.displayName : 'Stop ${i + 1}',
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
// 2. Intermediate Stop Markers (from ride.stops or attachedRoute.stops)
|
||||
if (attachedRoute != null && attachedRoute.stops.isNotEmpty) {
|
||||
for (final stop in attachedRoute.stops) {
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'route_stop_${stop.id}',
|
||||
position: AppLatLng(stop.latitude, stop.longitude),
|
||||
label: stop.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF00E5FF),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${stop.orderIndex + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < ride.stops.length; i++) {
|
||||
final stop = ride.stops[i];
|
||||
markers.add(
|
||||
AppMapMarker(
|
||||
id: 'stop_$i',
|
||||
position: AppLatLng(stop.latitude, stop.longitude),
|
||||
label: stop.displayName.isNotEmpty ? stop.displayName : 'Stop ${i + 1}',
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Destination Marker
|
||||
|
|
|
|||
|
|
@ -0,0 +1,597 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import '../../services/map/map_provider_interface.dart';
|
||||
import '../../services/map/unified_map_view.dart';
|
||||
import '../../services/routes/navigation_handoff.dart';
|
||||
import '../../services/routes/route_models.dart';
|
||||
import '../../services/routes/route_providers.dart';
|
||||
|
||||
class RouteDetailScreen extends ConsumerStatefulWidget {
|
||||
final String routeId;
|
||||
|
||||
const RouteDetailScreen({super.key, required this.routeId});
|
||||
|
||||
@override
|
||||
ConsumerState<RouteDetailScreen> createState() => _RouteDetailScreenState();
|
||||
}
|
||||
|
||||
class _RouteDetailScreenState extends ConsumerState<RouteDetailScreen> {
|
||||
bool _isDeleting = false;
|
||||
|
||||
Future<void> _confirmDelete(MotorSignalRoute route) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF13151A),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFF2A2E3D)),
|
||||
),
|
||||
title: const Text('Delete Route', style: TextStyle(color: Colors.white)),
|
||||
content: Text(
|
||||
'Are you sure you want to delete "${route.title}"? This cannot be undone.',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL', style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFF5252),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('DELETE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true || !mounted) return;
|
||||
|
||||
setState(() => _isDeleting = true);
|
||||
try {
|
||||
final service = ref.read(routeServiceProvider);
|
||||
await service.deleteRoute(widget.routeId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Route deleted.')),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isDeleting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to delete route: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getIconForStopType(RouteStopType type) {
|
||||
switch (type) {
|
||||
case RouteStopType.start:
|
||||
case RouteStopType.origin:
|
||||
return Icons.play_circle_fill;
|
||||
case RouteStopType.meetup:
|
||||
return Icons.people;
|
||||
case RouteStopType.waypoint:
|
||||
return Icons.place;
|
||||
case RouteStopType.fuel:
|
||||
return Icons.local_gas_station;
|
||||
case RouteStopType.rest:
|
||||
return Icons.hotel;
|
||||
case RouteStopType.food:
|
||||
return Icons.restaurant;
|
||||
case RouteStopType.scenic:
|
||||
return Icons.photo_camera;
|
||||
case RouteStopType.regroup:
|
||||
return Icons.group;
|
||||
case RouteStopType.destination:
|
||||
return Icons.flag;
|
||||
case RouteStopType.other:
|
||||
return Icons.location_on;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final routeAsync = ref.watch(routeByIdProvider(widget.routeId));
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D0E11),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF13151A),
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'ROUTE DETAILS',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1.2,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: routeAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
||||
),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: Color(0xFFFF5252)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Could not load route: $err',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (route) {
|
||||
if (route == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Route not found or has been deleted.',
|
||||
style: TextStyle(color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isOwner = currentUser?.uid == route.ownerUid;
|
||||
|
||||
// Map markers
|
||||
final markers = route.stops.map((stop) {
|
||||
final isFirst = stop.orderIndex == 0;
|
||||
final isLast =
|
||||
stop.orderIndex == route.stops.length - 1 && route.stops.length > 1;
|
||||
|
||||
return AppMapMarker(
|
||||
id: stop.id,
|
||||
position: AppLatLng(stop.latitude, stop.longitude),
|
||||
label: stop.displayName,
|
||||
iconWidget: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? const Color(0xFF00E676)
|
||||
: isLast
|
||||
? const Color(0xFFFF5252)
|
||||
: const Color(0xFF00E5FF),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black54, blurRadius: 4, spreadRadius: 1),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${stop.orderIndex + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Polylines
|
||||
final polylines = route.stops.length >= 2
|
||||
? [
|
||||
AppPolyline(
|
||||
id: 'route_straight_line',
|
||||
points: route.previewPoints
|
||||
.map((p) => AppLatLng(p.latitude, p.longitude))
|
||||
.toList(),
|
||||
color: const Color(0xFF00E5FF),
|
||||
width: 3.5,
|
||||
),
|
||||
]
|
||||
: <AppPolyline>[];
|
||||
|
||||
final mapCenter = route.stops.isNotEmpty
|
||||
? AppLatLng(
|
||||
route.stops.first.latitude, route.stops.first.longitude)
|
||||
: const AppLatLng(37.7749, -122.4194);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Map Preview
|
||||
SizedBox(
|
||||
height: 240,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
UnifiedMapView(
|
||||
center: mapCenter,
|
||||
zoom: 12.0,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF13151A).withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFF2A2E3D)),
|
||||
),
|
||||
child: Text(
|
||||
route.visibility.label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Title & Info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
route.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (route.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
route.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Distance and Stops Badges
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF13151A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF2A2E3D)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'STRAIGHT-LINE DISTANCE',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${route.formattedDistanceMiles} (${route.formattedDistanceKm})',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: const Color(0xFF2A2E3D),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'WAYPOINTS',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${route.stops.length} STOPS',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Honest Disclaimer
|
||||
const Text(
|
||||
'MotorSignal provides straight-line route planning and handoff. Turn-by-turn navigation opens in Google Maps or Waze.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Owner Actions: Edit & Delete
|
||||
if (isOwner) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
context.push('/routes/planner?routeId=${route.id}');
|
||||
},
|
||||
icon: const Icon(Icons.edit,
|
||||
size: 16, color: Color(0xFF00E5FF)),
|
||||
label: const Text('EDIT ROUTE',
|
||||
style: TextStyle(color: Color(0xFF00E5FF))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF00E5FF)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isDeleting
|
||||
? null
|
||||
: () => _confirmDelete(route),
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
size: 16, color: Color(0xFFFF5252)),
|
||||
label: const Text('DELETE',
|
||||
style: TextStyle(color: Color(0xFFFF5252))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFFF5252)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Attached Group Ride button if any
|
||||
if (route.attachedGroupRideId != null) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
context.push(
|
||||
'/group-rides/${route.attachedGroupRideId}');
|
||||
},
|
||||
icon: const Icon(Icons.group,
|
||||
size: 18, color: Color(0xFF00E5FF)),
|
||||
label: const Text('VIEW ATTACHED GROUP RIDE',
|
||||
style: TextStyle(color: Color(0xFF00E5FF))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFF2A2E3D)),
|
||||
minimumSize: const Size(double.infinity, 44),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Stops List Header
|
||||
const Text(
|
||||
'STOPS & WAYPOINTS',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Stops List
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: route.stops.length,
|
||||
itemBuilder: (context, index) {
|
||||
final stop = route.stops[index];
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == route.stops.length - 1 &&
|
||||
route.stops.length > 1;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF13151A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF2A2E3D)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? const Color(0xFF00E676)
|
||||
: isLast
|
||||
? const Color(0xFFFF5252)
|
||||
: const Color(0xFF00E5FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_getIconForStopType(stop.stopType),
|
||||
size: 14,
|
||||
color: const Color(0xFF00E5FF)),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
stop.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (stop.notes.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
stop.notes,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.navigation, size: 14),
|
||||
label: const Text('NAV'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Primary action: Navigate to First Stop
|
||||
if (route.stops.isNotEmpty)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final firstStop = route.stops.first;
|
||||
NavigationHandoffService.showHandoffSheet(
|
||||
context: context,
|
||||
latitude: firstStop.latitude,
|
||||
longitude: firstStop.longitude,
|
||||
displayName: 'Start: ${firstStop.displayName}',
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.directions, size: 20),
|
||||
label: const Text(
|
||||
'NAVIGATE TO ROUTE START',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -39,3 +39,8 @@ final featureAccessProvider = Provider<FeatureAccessService>((ref) {
|
|||
final state = ref.watch(entitlementProvider);
|
||||
return FeatureAccessService(state);
|
||||
});
|
||||
|
||||
/// Convenience provider returning whether user is currently a Pro subscriber.
|
||||
final isProSubscriberProvider = Provider<bool>((ref) {
|
||||
return ref.watch(entitlementProvider).isPro;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -359,6 +359,10 @@ class GroupRide {
|
|||
final GroupRideLocation meetingPoint;
|
||||
final GroupRideLocation? destination;
|
||||
final List<GroupRideLocation> stops; // Ordered waypoints
|
||||
final String? routeId;
|
||||
final String? routeTitle;
|
||||
final double? routeDistanceMeters;
|
||||
final int? routeStopCount;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? canceledAt;
|
||||
|
|
@ -381,6 +385,10 @@ class GroupRide {
|
|||
required this.meetingPoint,
|
||||
this.destination,
|
||||
this.stops = const [],
|
||||
this.routeId,
|
||||
this.routeTitle,
|
||||
this.routeDistanceMeters,
|
||||
this.routeStopCount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.canceledAt,
|
||||
|
|
@ -414,6 +422,11 @@ class GroupRide {
|
|||
'meetingPoint': meetingPoint.toJson(),
|
||||
if (destination != null) 'destination': destination!.toJson(),
|
||||
'stops': stops.map((s) => s.toJson()).toList(),
|
||||
if (routeId != null) 'routeId': routeId,
|
||||
if (routeTitle != null) 'routeTitle': routeTitle,
|
||||
if (routeDistanceMeters != null)
|
||||
'routeDistanceMeters': routeDistanceMeters,
|
||||
if (routeStopCount != null) 'routeStopCount': routeStopCount,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(),
|
||||
|
|
@ -478,6 +491,10 @@ class GroupRide {
|
|||
json['destination'] as Map<String, dynamic>)
|
||||
: null,
|
||||
stops: stopsList,
|
||||
routeId: json['routeId'] as String?,
|
||||
routeTitle: json['routeTitle'] as String?,
|
||||
routeDistanceMeters: (json['routeDistanceMeters'] as num?)?.toDouble(),
|
||||
routeStopCount: json['routeStopCount'] as int?,
|
||||
createdAt: created,
|
||||
updatedAt: updated,
|
||||
canceledAt: canceled,
|
||||
|
|
@ -502,6 +519,10 @@ class GroupRide {
|
|||
GroupRideLocation? meetingPoint,
|
||||
GroupRideLocation? destination,
|
||||
List<GroupRideLocation>? stops,
|
||||
String? routeId,
|
||||
String? routeTitle,
|
||||
double? routeDistanceMeters,
|
||||
int? routeStopCount,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? canceledAt,
|
||||
|
|
@ -524,6 +545,10 @@ class GroupRide {
|
|||
meetingPoint: meetingPoint ?? this.meetingPoint,
|
||||
destination: destination ?? this.destination,
|
||||
stops: stops ?? this.stops,
|
||||
routeId: routeId ?? this.routeId,
|
||||
routeTitle: routeTitle ?? this.routeTitle,
|
||||
routeDistanceMeters: routeDistanceMeters ?? this.routeDistanceMeters,
|
||||
routeStopCount: routeStopCount ?? this.routeStopCount,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
canceledAt: canceledAt ?? this.canceledAt,
|
||||
|
|
@ -543,7 +568,8 @@ class GroupRide {
|
|||
scheduledStartAt == other.scheduledStartAt &&
|
||||
departureAt == other.departureAt &&
|
||||
participantCount == other.participantCount &&
|
||||
participantLimit == other.participantLimit;
|
||||
participantLimit == other.participantLimit &&
|
||||
routeId == other.routeId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
|
|
@ -554,7 +580,8 @@ class GroupRide {
|
|||
scheduledStartAt.hashCode ^
|
||||
departureAt.hashCode ^
|
||||
participantCount.hashCode ^
|
||||
participantLimit.hashCode;
|
||||
participantLimit.hashCode ^
|
||||
routeId.hashCode;
|
||||
}
|
||||
|
||||
/// Status of rider in live group ride presence.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ class GroupRideService {
|
|||
GroupRideLocation? destination,
|
||||
List<GroupRideLocation> stops = const [],
|
||||
List<String> initialInviteUids = const [],
|
||||
String? routeId,
|
||||
String? routeTitle,
|
||||
double? routeDistanceMeters,
|
||||
int? routeStopCount,
|
||||
}) async {
|
||||
final cleanTitle = title.trim();
|
||||
if (cleanTitle.length < 3) {
|
||||
|
|
@ -70,6 +74,10 @@ class GroupRideService {
|
|||
meetingPoint: meetingPoint,
|
||||
destination: destination,
|
||||
stops: stops,
|
||||
routeId: routeId,
|
||||
routeTitle: routeTitle,
|
||||
routeDistanceMeters: routeDistanceMeters,
|
||||
routeStopCount: routeStopCount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'route_models.dart';
|
||||
|
||||
/// Target navigation application for handoff.
|
||||
enum NavigationApp {
|
||||
googleMaps,
|
||||
waze,
|
||||
platformDefault;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NavigationApp.googleMaps:
|
||||
return 'Google Maps';
|
||||
case NavigationApp.waze:
|
||||
return 'Waze';
|
||||
case NavigationApp.platformDefault:
|
||||
return 'System Map';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service handling truthful handoff to external navigation applications.
|
||||
/// MotorSignal delegates road-following turn-by-turn navigation to trusted
|
||||
/// navigation apps without claiming internal turn-by-turn guidance.
|
||||
class NavigationHandoffService {
|
||||
const NavigationHandoffService();
|
||||
|
||||
/// Builds a standard Google Maps turn-by-turn navigation URI.
|
||||
static Uri buildGoogleMapsUri({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
String? displayName,
|
||||
}) {
|
||||
final dest = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
||||
return Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$dest');
|
||||
}
|
||||
|
||||
/// Builds a standard Waze navigation URI.
|
||||
static Uri buildWazeUri({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) {
|
||||
final ll = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
||||
return Uri.parse('https://waze.com/ul?ll=$ll&navigate=yes');
|
||||
}
|
||||
|
||||
/// Builds a standard RFC 5870 system geo: navigation URI.
|
||||
static Uri buildSystemGeoUri({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required String displayName,
|
||||
}) {
|
||||
final coords = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}';
|
||||
return Uri.parse('geo:$coords?q=$coords($displayName)');
|
||||
}
|
||||
|
||||
/// Displays an interactive navigation handoff bottom sheet with direct links
|
||||
/// to Google Maps, Waze, and native system mapping.
|
||||
static void showHandoffSheet({
|
||||
required BuildContext context,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required String displayName,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF1B1E26),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.navigation, color: Color(0xFF00E5FF), size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'NAVIGATION HANDOFF',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.map, color: Colors.white),
|
||||
),
|
||||
title: const Text('Google Maps', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Open turn-by-turn in Google Maps', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
final uri = buildGoogleMapsUri(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
displayName: displayName,
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(color: Colors.white12, height: 1),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.directions_car, color: Color(0xFF00E5FF)),
|
||||
),
|
||||
title: const Text('Waze', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Open road directions in Waze', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
final uri = buildWazeUri(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(color: Colors.white12, height: 1),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.place, color: Colors.white70),
|
||||
),
|
||||
title: const Text('System Map', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Open in default platform mapping app', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
final uri = buildSystemGeoUri(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
displayName: displayName,
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Launches external navigation to a single target [stop].
|
||||
Future<bool> launchNavigationToStop({
|
||||
required RouteStop stop,
|
||||
NavigationApp app = NavigationApp.googleMaps,
|
||||
}) async {
|
||||
Uri uri;
|
||||
switch (app) {
|
||||
case NavigationApp.googleMaps:
|
||||
uri = buildGoogleMapsUri(
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
break;
|
||||
|
||||
case NavigationApp.waze:
|
||||
uri = buildWazeUri(
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
);
|
||||
break;
|
||||
|
||||
case NavigationApp.platformDefault:
|
||||
uri = buildSystemGeoUri(
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
displayName: stop.displayName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
final fallbackUri = Uri.parse(
|
||||
'https://www.google.com/maps/search/?api=1&query=${stop.latitude},${stop.longitude}',
|
||||
);
|
||||
if (await canLaunchUrl(fallbackUri)) {
|
||||
return await launchUrl(fallbackUri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationHandoffService] Failed to launch navigation: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches multi-stop road route navigation in external maps (up to provider limit).
|
||||
Future<bool> launchFullRouteNavigation({
|
||||
required MotorSignalRoute route,
|
||||
NavigationApp app = NavigationApp.googleMaps,
|
||||
}) async {
|
||||
final stops = route.orderedStops;
|
||||
if (stops.isEmpty) return false;
|
||||
if (stops.length == 1) {
|
||||
return launchNavigationToStop(stop: stops.first, app: app);
|
||||
}
|
||||
|
||||
final origin = '${stops.first.latitude},${stops.first.longitude}';
|
||||
final dest = '${stops.last.latitude},${stops.last.longitude}';
|
||||
|
||||
if (app == NavigationApp.googleMaps) {
|
||||
final intermediateStops = stops.sublist(1, stops.length - 1);
|
||||
final cappedIntermediates = intermediateStops.take(9);
|
||||
final waypointsStr = cappedIntermediates
|
||||
.map((s) => '${s.latitude},${s.longitude}')
|
||||
.join('|');
|
||||
|
||||
final waypointsParam =
|
||||
waypointsStr.isNotEmpty ? '&waypoints=$waypointsStr' : '';
|
||||
final uri = Uri.parse(
|
||||
'https://www.google.com/maps/dir/?api=1&origin=$origin&destination=$dest$waypointsParam',
|
||||
);
|
||||
|
||||
try {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationHandoffService] Failed to launch multi-stop route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return launchNavigationToStop(stop: stops.last, app: app);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
|
||||
/// Semantic types of route stops and waypoints.
|
||||
enum RouteStopType {
|
||||
start,
|
||||
origin,
|
||||
meetup,
|
||||
waypoint,
|
||||
fuel,
|
||||
rest,
|
||||
food,
|
||||
scenic,
|
||||
regroup,
|
||||
destination,
|
||||
other;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case RouteStopType.start:
|
||||
case RouteStopType.origin:
|
||||
return 'Start / Origin';
|
||||
case RouteStopType.meetup:
|
||||
return 'Meeting Point';
|
||||
case RouteStopType.waypoint:
|
||||
return 'Waypoint';
|
||||
case RouteStopType.fuel:
|
||||
return 'Fuel Stop';
|
||||
case RouteStopType.rest:
|
||||
return 'Rest Area';
|
||||
case RouteStopType.food:
|
||||
return 'Food & Drink';
|
||||
case RouteStopType.scenic:
|
||||
return 'Scenic Overlook';
|
||||
case RouteStopType.regroup:
|
||||
return 'Regroup Point';
|
||||
case RouteStopType.destination:
|
||||
return 'Destination';
|
||||
case RouteStopType.other:
|
||||
return 'Stop';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A discrete, ordered location stop along a route.
|
||||
@immutable
|
||||
class RouteStop {
|
||||
final String id;
|
||||
final int order;
|
||||
final RouteStopType type;
|
||||
final String displayName;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String address;
|
||||
final String notes;
|
||||
final int? plannedStopDurationMinutes;
|
||||
final String? providerPlaceId;
|
||||
|
||||
const RouteStop({
|
||||
required this.id,
|
||||
int? order,
|
||||
int? orderIndex,
|
||||
RouteStopType? type,
|
||||
RouteStopType? stopType,
|
||||
required this.displayName,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.address = '',
|
||||
this.notes = '',
|
||||
this.plannedStopDurationMinutes,
|
||||
this.providerPlaceId,
|
||||
}) : order = order ?? orderIndex ?? 0,
|
||||
type = type ?? stopType ?? RouteStopType.waypoint;
|
||||
|
||||
int get orderIndex => order;
|
||||
RouteStopType get stopType => type;
|
||||
|
||||
AppLatLng get latLng => AppLatLng(latitude, longitude);
|
||||
|
||||
RouteStop copyWith({
|
||||
String? id,
|
||||
int? order,
|
||||
int? orderIndex,
|
||||
RouteStopType? type,
|
||||
RouteStopType? stopType,
|
||||
String? displayName,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? address,
|
||||
String? notes,
|
||||
int? plannedStopDurationMinutes,
|
||||
String? providerPlaceId,
|
||||
}) {
|
||||
return RouteStop(
|
||||
id: id ?? this.id,
|
||||
order: order ?? orderIndex ?? this.order,
|
||||
type: type ?? stopType ?? this.type,
|
||||
displayName: displayName ?? this.displayName,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
address: address ?? this.address,
|
||||
notes: notes ?? this.notes,
|
||||
plannedStopDurationMinutes:
|
||||
plannedStopDurationMinutes ?? this.plannedStopDurationMinutes,
|
||||
providerPlaceId: providerPlaceId ?? this.providerPlaceId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'order': order,
|
||||
'orderIndex': order,
|
||||
'type': type.name,
|
||||
'stopType': type.name,
|
||||
'displayName': displayName,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
if (address.isNotEmpty) 'address': address,
|
||||
if (notes.isNotEmpty) 'notes': notes,
|
||||
if (plannedStopDurationMinutes != null)
|
||||
'plannedStopDurationMinutes': plannedStopDurationMinutes,
|
||||
if (providerPlaceId != null) 'providerPlaceId': providerPlaceId,
|
||||
};
|
||||
|
||||
factory RouteStop.fromJson(Map<String, dynamic> json) {
|
||||
final rawOrder = json['orderIndex'] ?? json['order'];
|
||||
final rawType = json['stopType'] ?? json['type'];
|
||||
return RouteStop(
|
||||
id: json['id'] as String? ?? '',
|
||||
order: (rawOrder as num?)?.toInt() ?? 0,
|
||||
type: RouteStopType.values.firstWhere(
|
||||
(t) => t.name == (rawType as String?),
|
||||
orElse: () => RouteStopType.waypoint,
|
||||
),
|
||||
displayName: json['displayName'] as String? ?? 'Stop',
|
||||
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||
address: json['address'] as String? ?? '',
|
||||
notes: json['notes'] as String? ?? '',
|
||||
plannedStopDurationMinutes:
|
||||
(json['plannedStopDurationMinutes'] as num?)?.toInt(),
|
||||
providerPlaceId: json['providerPlaceId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is RouteStop &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
order == other.order &&
|
||||
type == other.type &&
|
||||
displayName == other.displayName &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude &&
|
||||
address == other.address &&
|
||||
notes == other.notes &&
|
||||
plannedStopDurationMinutes == other.plannedStopDurationMinutes &&
|
||||
providerPlaceId == other.providerPlaceId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
order.hashCode ^
|
||||
type.hashCode ^
|
||||
displayName.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode ^
|
||||
address.hashCode ^
|
||||
notes.hashCode ^
|
||||
plannedStopDurationMinutes.hashCode ^
|
||||
providerPlaceId.hashCode;
|
||||
}
|
||||
|
||||
/// Visibility of a route.
|
||||
enum RouteVisibility {
|
||||
private,
|
||||
groupOnly,
|
||||
public;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case RouteVisibility.private:
|
||||
return 'Private';
|
||||
case RouteVisibility.groupOnly:
|
||||
return 'Group Ride Only';
|
||||
case RouteVisibility.public:
|
||||
return 'Public';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provenance of the route.
|
||||
enum RouteSource {
|
||||
manual,
|
||||
planner,
|
||||
gpxImport,
|
||||
groupRideConversion;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case RouteSource.manual:
|
||||
case RouteSource.planner:
|
||||
return 'Manual Planner';
|
||||
case RouteSource.gpxImport:
|
||||
return 'GPX Import';
|
||||
case RouteSource.groupRideConversion:
|
||||
return 'Group Ride Adaptation';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable MotorSignal planned route model.
|
||||
@immutable
|
||||
class MotorSignalRoute {
|
||||
final String id;
|
||||
final String ownerUid;
|
||||
final String ownerHandle;
|
||||
final String title;
|
||||
final String description;
|
||||
final RouteVisibility visibility;
|
||||
final List<RouteStop> stops;
|
||||
final double straightLineDistanceMeters;
|
||||
final double? estimatedDistanceMeters;
|
||||
final int? estimatedDurationSeconds;
|
||||
final RouteSource source;
|
||||
final int version;
|
||||
final String? attachedGroupRideId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
MotorSignalRoute({
|
||||
required this.id,
|
||||
required this.ownerUid,
|
||||
this.ownerHandle = '',
|
||||
required this.title,
|
||||
this.description = '',
|
||||
this.visibility = RouteVisibility.private,
|
||||
this.stops = const [],
|
||||
double? straightLineDistanceMeters,
|
||||
this.estimatedDistanceMeters,
|
||||
this.estimatedDurationSeconds,
|
||||
this.source = RouteSource.manual,
|
||||
this.version = 1,
|
||||
this.attachedGroupRideId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
}) : straightLineDistanceMeters = straightLineDistanceMeters ??
|
||||
calculateTotalDistanceMeters(stops);
|
||||
|
||||
/// Total straight-line distance in meters.
|
||||
double get totalDistanceMeters => straightLineDistanceMeters;
|
||||
|
||||
/// Total straight-line distance in miles.
|
||||
double get straightLineDistanceMiles =>
|
||||
straightLineDistanceMeters * 0.000621371;
|
||||
|
||||
double get distanceMiles => straightLineDistanceMiles;
|
||||
|
||||
/// Distance string in miles.
|
||||
String get formattedDistanceMiles =>
|
||||
'${straightLineDistanceMiles.toStringAsFixed(1)} mi';
|
||||
|
||||
/// Distance string in kilometers.
|
||||
String get formattedDistanceKm =>
|
||||
'${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
|
||||
|
||||
/// Truthful distance string according to calculation method.
|
||||
String get formattedDistance {
|
||||
if (estimatedDistanceMeters != null) {
|
||||
final roadMiles = estimatedDistanceMeters! * 0.000621371;
|
||||
return '${roadMiles.toStringAsFixed(1)} mi (Road Est.)';
|
||||
}
|
||||
return '${straightLineDistanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||
}
|
||||
|
||||
/// Truthful label describing the nature of the distance calculation.
|
||||
String get distanceLabel => estimatedDistanceMeters != null
|
||||
? 'Estimated road distance'
|
||||
: 'Straight-line distance';
|
||||
|
||||
/// Returns stops strictly ordered by index.
|
||||
List<RouteStop> get orderedStops {
|
||||
final sorted = List<RouteStop>.from(stops);
|
||||
sorted.sort((a, b) => a.order.compareTo(b.order));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// Polyline points connecting ordered stops (straight-line preview).
|
||||
List<AppLatLng> get previewPoints {
|
||||
return orderedStops.map((s) => s.latLng).toList();
|
||||
}
|
||||
|
||||
/// Calculates geodesic straight-line distance along ordered stops in meters using Haversine formula.
|
||||
static double calculateTotalDistanceMeters(List<RouteStop> stops) {
|
||||
if (stops.length < 2) return 0.0;
|
||||
final sorted = List<RouteStop>.from(stops);
|
||||
sorted.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
double total = 0.0;
|
||||
for (int i = 0; i < sorted.length - 1; i++) {
|
||||
total += _haversineMeters(
|
||||
sorted[i].latitude,
|
||||
sorted[i].longitude,
|
||||
sorted[i + 1].latitude,
|
||||
sorted[i + 1].longitude,
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
static double _haversineMeters(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const double r = 6371000.0; // Earth radius in meters
|
||||
final double phi1 = lat1 * math.pi / 180.0;
|
||||
final double phi2 = lat2 * math.pi / 180.0;
|
||||
final double deltaPhi = (lat2 - lat1) * math.pi / 180.0;
|
||||
final double deltaLambda = (lon2 - lon1) * math.pi / 180.0;
|
||||
|
||||
final double a = math.sin(deltaPhi / 2.0) * math.sin(deltaPhi / 2.0) +
|
||||
math.cos(phi1) *
|
||||
math.cos(phi2) *
|
||||
math.sin(deltaLambda / 2.0) *
|
||||
math.sin(deltaLambda / 2.0);
|
||||
final double c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a));
|
||||
return r * c;
|
||||
}
|
||||
|
||||
MotorSignalRoute copyWith({
|
||||
String? id,
|
||||
String? ownerUid,
|
||||
String? ownerHandle,
|
||||
String? title,
|
||||
String? description,
|
||||
RouteVisibility? visibility,
|
||||
List<RouteStop>? stops,
|
||||
double? straightLineDistanceMeters,
|
||||
double? estimatedDistanceMeters,
|
||||
int? estimatedDurationSeconds,
|
||||
RouteSource? source,
|
||||
int? version,
|
||||
String? attachedGroupRideId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return MotorSignalRoute(
|
||||
id: id ?? this.id,
|
||||
ownerUid: ownerUid ?? this.ownerUid,
|
||||
ownerHandle: ownerHandle ?? this.ownerHandle,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
visibility: visibility ?? this.visibility,
|
||||
stops: stops ?? this.stops,
|
||||
straightLineDistanceMeters:
|
||||
straightLineDistanceMeters ?? this.straightLineDistanceMeters,
|
||||
estimatedDistanceMeters:
|
||||
estimatedDistanceMeters ?? this.estimatedDistanceMeters,
|
||||
estimatedDurationSeconds:
|
||||
estimatedDurationSeconds ?? this.estimatedDurationSeconds,
|
||||
source: source ?? this.source,
|
||||
version: version ?? this.version,
|
||||
attachedGroupRideId: attachedGroupRideId ?? this.attachedGroupRideId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'ownerUid': ownerUid,
|
||||
'ownerHandle': ownerHandle,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'visibility': visibility.name,
|
||||
'stops': stops.map((s) => s.toJson()).toList(),
|
||||
'straightLineDistanceMeters': straightLineDistanceMeters,
|
||||
if (estimatedDistanceMeters != null)
|
||||
'estimatedDistanceMeters': estimatedDistanceMeters,
|
||||
if (estimatedDurationSeconds != null)
|
||||
'estimatedDurationSeconds': estimatedDurationSeconds,
|
||||
'source': source.name,
|
||||
'version': version,
|
||||
if (attachedGroupRideId != null)
|
||||
'attachedGroupRideId': attachedGroupRideId,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory MotorSignalRoute.fromJson(Map<String, dynamic> json) {
|
||||
final rawStops = json['stops'] as List<dynamic>? ?? [];
|
||||
final parsedStops = rawStops
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((s) => RouteStop.fromJson(s))
|
||||
.toList();
|
||||
|
||||
final created = json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
|
||||
: DateTime.now();
|
||||
final updated = json['updatedAt'] != null
|
||||
? DateTime.tryParse(json['updatedAt'] as String) ?? created
|
||||
: created;
|
||||
|
||||
return MotorSignalRoute(
|
||||
id: json['id'] as String? ?? '',
|
||||
ownerUid: json['ownerUid'] as String? ?? '',
|
||||
ownerHandle: json['ownerHandle'] as String? ?? '',
|
||||
title: json['title'] as String? ?? 'Untitled Route',
|
||||
description: json['description'] as String? ?? '',
|
||||
visibility: RouteVisibility.values.firstWhere(
|
||||
(v) => v.name == (json['visibility'] as String?),
|
||||
orElse: () => RouteVisibility.private,
|
||||
),
|
||||
stops: parsedStops,
|
||||
straightLineDistanceMeters:
|
||||
(json['straightLineDistanceMeters'] as num?)?.toDouble() ??
|
||||
calculateTotalDistanceMeters(parsedStops),
|
||||
estimatedDistanceMeters:
|
||||
(json['estimatedDistanceMeters'] as num?)?.toDouble(),
|
||||
estimatedDurationSeconds:
|
||||
(json['estimatedDurationSeconds'] as num?)?.toInt(),
|
||||
source: RouteSource.values.firstWhere(
|
||||
(s) => s.name == (json['source'] as String?),
|
||||
orElse: () => RouteSource.manual,
|
||||
),
|
||||
version: (json['version'] as num?)?.toInt() ?? 1,
|
||||
attachedGroupRideId: json['attachedGroupRideId'] as String?,
|
||||
createdAt: created,
|
||||
updatedAt: updated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import '../map/map_provider_interface.dart';
|
||||
import 'route_models.dart';
|
||||
|
||||
/// Result of a place/location search query.
|
||||
class PlaceSearchResult {
|
||||
final String name;
|
||||
final String address;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String? placeId;
|
||||
|
||||
const PlaceSearchResult({
|
||||
required this.name,
|
||||
this.address = '',
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.placeId,
|
||||
});
|
||||
|
||||
AppLatLng get latLng => AppLatLng(latitude, longitude);
|
||||
}
|
||||
|
||||
/// Result of calculating a route along ordered stops.
|
||||
class RouteCalculationResult {
|
||||
final double straightLineDistanceMeters;
|
||||
final double? estimatedDistanceMeters;
|
||||
final int? estimatedDurationSeconds;
|
||||
final List<AppLatLng> geometry;
|
||||
final bool isStraightLine;
|
||||
final String providerName;
|
||||
|
||||
const RouteCalculationResult({
|
||||
required this.straightLineDistanceMeters,
|
||||
this.estimatedDistanceMeters,
|
||||
this.estimatedDurationSeconds,
|
||||
required this.geometry,
|
||||
required this.isStraightLine,
|
||||
required this.providerName,
|
||||
});
|
||||
|
||||
/// Alias for geometry points.
|
||||
List<AppLatLng> get points => geometry;
|
||||
|
||||
/// Total straight-line distance in meters.
|
||||
double get totalDistanceMeters => straightLineDistanceMeters;
|
||||
|
||||
/// Alias for isStraightLine.
|
||||
bool get isHonestStraightLine => isStraightLine;
|
||||
|
||||
/// Total distance in miles.
|
||||
double get distanceMiles {
|
||||
final meters = estimatedDistanceMeters ?? straightLineDistanceMeters;
|
||||
return meters * 0.000621371;
|
||||
}
|
||||
|
||||
/// Truthful distance description.
|
||||
String get distanceLabel {
|
||||
if (estimatedDistanceMeters != null) {
|
||||
return '${distanceMiles.toStringAsFixed(1)} mi (Road Est.)';
|
||||
}
|
||||
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract provider interface for calculating routes and searching places.
|
||||
abstract class RouteProvider {
|
||||
String get name;
|
||||
|
||||
/// Calculates route geometry and metrics between ordered stops.
|
||||
Future<RouteCalculationResult> calculateRoute(List<RouteStop> stops);
|
||||
|
||||
/// Searches for places by textual query.
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
});
|
||||
}
|
||||
|
||||
/// Default geodesic straight-line preview implementation.
|
||||
///
|
||||
/// Truthfully connects stops with direct segments and calculates geodesic
|
||||
/// distance without fabricating road paths or speculative turn ETAs.
|
||||
class StraightLineRouteProvider implements RouteProvider {
|
||||
const StraightLineRouteProvider();
|
||||
|
||||
@override
|
||||
String get name => 'Straight-line Preview';
|
||||
|
||||
@override
|
||||
Future<RouteCalculationResult> calculateRoute(List<RouteStop> stops) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
final distanceMeters = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
final points = ordered.map((s) => s.latLng).toList();
|
||||
|
||||
return RouteCalculationResult(
|
||||
straightLineDistanceMeters: distanceMeters,
|
||||
estimatedDistanceMeters: null,
|
||||
estimatedDurationSeconds: null,
|
||||
geometry: points,
|
||||
isStraightLine: true,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
}) async {
|
||||
final clean = query.trim();
|
||||
if (clean.isEmpty) return const [];
|
||||
|
||||
// Support direct coordinate input (e.g. "34.5378, -83.9849")
|
||||
final parts = clean.split(RegExp(r'[,\s]+'));
|
||||
if (parts.length == 2) {
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lng = double.tryParse(parts[1]);
|
||||
if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
return [
|
||||
PlaceSearchResult(
|
||||
name: 'Coordinate Pin',
|
||||
address: '${lat.toStringAsFixed(5)}, ${lng.toStringAsFixed(5)}',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback: return query name centered at reference location if provided
|
||||
if (nearLocation != null) {
|
||||
return [
|
||||
PlaceSearchResult(
|
||||
name: clean,
|
||||
address: 'Near Current Map Location',
|
||||
latitude: nearLocation.latitude,
|
||||
longitude: nearLocation.longitude,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_providers.dart';
|
||||
import '../entitlements/entitlement_providers.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'navigation_handoff.dart';
|
||||
import 'route_models.dart';
|
||||
import 'route_provider.dart';
|
||||
import 'route_service.dart';
|
||||
|
||||
/// Provider for RouteService.
|
||||
final routeServiceProvider = Provider<RouteService>((ref) {
|
||||
return RouteService();
|
||||
});
|
||||
|
||||
/// Active RouteProvider implementation (defaults to honest straight-line preview).
|
||||
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
|
||||
return const StraightLineRouteProvider();
|
||||
});
|
||||
|
||||
/// Provider for NavigationHandoffService.
|
||||
final navigationHandoffServiceProvider =
|
||||
Provider<NavigationHandoffService>((ref) {
|
||||
return const NavigationHandoffService();
|
||||
});
|
||||
|
||||
/// Realtime stream provider for a single route by ID.
|
||||
final routeByIdProvider =
|
||||
StreamProvider.family<MotorSignalRoute?, String>((ref, routeId) {
|
||||
final service = ref.watch(routeServiceProvider);
|
||||
return service.routeStream(routeId);
|
||||
});
|
||||
|
||||
/// Realtime stream provider for routes owned by a user.
|
||||
final userRoutesProvider =
|
||||
StreamProvider.family<List<MotorSignalRoute>, String>((ref, uid) {
|
||||
final service = ref.watch(routeServiceProvider);
|
||||
return service.userRoutesStream(uid);
|
||||
});
|
||||
|
||||
/// Alias for routeByIdProvider.
|
||||
final routeStreamProvider = routeByIdProvider;
|
||||
|
||||
/// Realtime stream of routes created by current user.
|
||||
final myRoutesStreamProvider =
|
||||
StreamProvider<List<MotorSignalRoute>>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.uid.isEmpty) {
|
||||
return Stream.value(const []);
|
||||
}
|
||||
final service = ref.watch(routeServiceProvider);
|
||||
return service.userRoutesStream(user.uid);
|
||||
});
|
||||
|
||||
/// State of an active route planning session.
|
||||
class RoutePlannerState {
|
||||
final String? routeId;
|
||||
final String title;
|
||||
final String description;
|
||||
final RouteVisibility visibility;
|
||||
final List<RouteStop> stops;
|
||||
final double straightLineDistanceMeters;
|
||||
final bool isSaving;
|
||||
final String? errorMessage;
|
||||
final bool isDirty;
|
||||
|
||||
const RoutePlannerState({
|
||||
this.routeId,
|
||||
this.title = '',
|
||||
this.description = '',
|
||||
this.visibility = RouteVisibility.private,
|
||||
this.stops = const [],
|
||||
this.straightLineDistanceMeters = 0.0,
|
||||
this.isSaving = false,
|
||||
this.errorMessage,
|
||||
this.isDirty = false,
|
||||
});
|
||||
|
||||
double get distanceMiles => straightLineDistanceMeters * 0.000621371;
|
||||
|
||||
String get formattedDistance =>
|
||||
'${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
|
||||
|
||||
String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi';
|
||||
|
||||
String get formattedDistanceKm =>
|
||||
'${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
|
||||
|
||||
List<AppLatLng> get previewPoints => stops.map((s) => s.latLng).toList();
|
||||
|
||||
RoutePlannerState copyWith({
|
||||
String? routeId,
|
||||
String? title,
|
||||
String? description,
|
||||
RouteVisibility? visibility,
|
||||
List<RouteStop>? stops,
|
||||
double? straightLineDistanceMeters,
|
||||
bool? isSaving,
|
||||
String? errorMessage,
|
||||
bool? isDirty,
|
||||
}) {
|
||||
return RoutePlannerState(
|
||||
routeId: routeId ?? this.routeId,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
visibility: visibility ?? this.visibility,
|
||||
stops: stops ?? this.stops,
|
||||
straightLineDistanceMeters:
|
||||
straightLineDistanceMeters ?? this.straightLineDistanceMeters,
|
||||
isSaving: isSaving ?? this.isSaving,
|
||||
errorMessage: errorMessage,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller managing in-memory route editing state.
|
||||
class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
|
||||
final Ref ref;
|
||||
|
||||
RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute])
|
||||
: super(
|
||||
initialRoute != null
|
||||
? RoutePlannerState(
|
||||
routeId: initialRoute.id,
|
||||
title: initialRoute.title,
|
||||
description: initialRoute.description,
|
||||
visibility: initialRoute.visibility,
|
||||
stops: initialRoute.orderedStops,
|
||||
straightLineDistanceMeters:
|
||||
initialRoute.straightLineDistanceMeters,
|
||||
)
|
||||
: const RoutePlannerState(),
|
||||
);
|
||||
|
||||
void loadRoute(MotorSignalRoute route) {
|
||||
state = RoutePlannerState(
|
||||
routeId: route.id,
|
||||
title: route.title,
|
||||
description: route.description,
|
||||
visibility: route.visibility,
|
||||
stops: route.orderedStops,
|
||||
straightLineDistanceMeters: route.straightLineDistanceMeters,
|
||||
isDirty: false,
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const RoutePlannerState();
|
||||
}
|
||||
|
||||
void setTitle(String title) {
|
||||
state = state.copyWith(title: title, isDirty: true);
|
||||
}
|
||||
|
||||
void setDescription(String desc) {
|
||||
state = state.copyWith(description: desc, isDirty: true);
|
||||
}
|
||||
|
||||
void setVisibility(RouteVisibility visibility) {
|
||||
state = state.copyWith(visibility: visibility, isDirty: true);
|
||||
}
|
||||
|
||||
void addStop({
|
||||
RouteStop? stop,
|
||||
String? displayName,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
RouteStopType? stopType,
|
||||
String? notes,
|
||||
}) {
|
||||
final updatedStops = List<RouteStop>.from(state.stops);
|
||||
final effectiveOrder = updatedStops.length;
|
||||
|
||||
final newStop = stop != null
|
||||
? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder)
|
||||
: RouteStop(
|
||||
id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder',
|
||||
order: effectiveOrder,
|
||||
orderIndex: effectiveOrder,
|
||||
type: stopType ?? RouteStopType.waypoint,
|
||||
stopType: stopType ?? RouteStopType.waypoint,
|
||||
displayName: displayName ?? 'Stop $effectiveOrder',
|
||||
latitude: latitude ?? 0.0,
|
||||
longitude: longitude ?? 0.0,
|
||||
notes: notes ?? '',
|
||||
);
|
||||
|
||||
updatedStops.add(newStop);
|
||||
|
||||
final distance =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
||||
state = state.copyWith(
|
||||
stops: updatedStops,
|
||||
straightLineDistanceMeters: distance,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void updateStop(dynamic target, [RouteStop? maybeStop]) {
|
||||
final updatedStops = List<RouteStop>.from(state.stops);
|
||||
if (target is RouteStop) {
|
||||
final idx = updatedStops.indexWhere((s) => s.id == target.id);
|
||||
if (idx == -1) return;
|
||||
updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx);
|
||||
} else if (target is int && maybeStop != null) {
|
||||
if (target < 0 || target >= updatedStops.length) return;
|
||||
updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final distance =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(updatedStops);
|
||||
state = state.copyWith(
|
||||
stops: updatedStops,
|
||||
straightLineDistanceMeters: distance,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void removeStop(dynamic target) {
|
||||
final updatedStops = List<RouteStop>.from(state.stops);
|
||||
if (target is int) {
|
||||
if (target < 0 || target >= updatedStops.length) return;
|
||||
updatedStops.removeAt(target);
|
||||
} else if (target is String) {
|
||||
updatedStops.removeWhere((s) => s.id == target);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-index remaining stops
|
||||
final reindexed = <RouteStop>[];
|
||||
for (int i = 0; i < updatedStops.length; i++) {
|
||||
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
||||
}
|
||||
|
||||
final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed);
|
||||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void reorderStops(int oldIndex, int newIndex) {
|
||||
if (oldIndex < 0 || oldIndex >= state.stops.length) return;
|
||||
if (newIndex < 0 || newIndex > state.stops.length) return;
|
||||
|
||||
final updatedStops = List<RouteStop>.from(state.stops);
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = updatedStops.removeAt(oldIndex);
|
||||
updatedStops.insert(newIndex, item);
|
||||
|
||||
final reindexed = <RouteStop>[];
|
||||
for (int i = 0; i < updatedStops.length; i++) {
|
||||
reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i));
|
||||
}
|
||||
|
||||
final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed);
|
||||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void reverseStops() {
|
||||
if (state.stops.length < 2) return;
|
||||
final reversed = state.stops.reversed.toList();
|
||||
final reindexed = <RouteStop>[];
|
||||
for (int i = 0; i < reversed.length; i++) {
|
||||
reindexed.add(reversed[i].copyWith(order: i, orderIndex: i));
|
||||
}
|
||||
|
||||
final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed);
|
||||
state = state.copyWith(
|
||||
stops: reindexed,
|
||||
straightLineDistanceMeters: distance,
|
||||
isDirty: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MotorSignalRoute> saveRoute({
|
||||
String? title,
|
||||
String? description,
|
||||
String? ownerUid,
|
||||
RouteVisibility? visibility,
|
||||
String? attachedGroupRideId,
|
||||
}) async {
|
||||
final effectiveTitle = (title ?? state.title).trim();
|
||||
if (effectiveTitle.length < 3) {
|
||||
throw ArgumentError('Route title must be at least 3 characters.');
|
||||
}
|
||||
|
||||
if (state.stops.isEmpty) {
|
||||
throw ArgumentError('Please add at least one stop to the route.');
|
||||
}
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
final effectiveUid = ownerUid ?? user?.uid ?? '';
|
||||
if (effectiveUid.isEmpty) {
|
||||
throw StateError('Sign-in required to save routes.');
|
||||
}
|
||||
|
||||
final isPro = ref.read(isProSubscriberProvider);
|
||||
final service = ref.read(routeServiceProvider);
|
||||
|
||||
state = state.copyWith(isSaving: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
final saved = await service.saveRoute(
|
||||
id: state.routeId,
|
||||
title: effectiveTitle,
|
||||
description: (description ?? state.description).trim(),
|
||||
ownerUid: effectiveUid,
|
||||
ownerHandle: user?.email?.split('@').first ?? 'rider',
|
||||
visibility: visibility ?? state.visibility,
|
||||
stops: state.stops,
|
||||
isPro: isPro,
|
||||
attachedGroupRideId: attachedGroupRideId,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
routeId: saved.id,
|
||||
isSaving: false,
|
||||
isDirty: false,
|
||||
);
|
||||
return saved;
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isSaving: false,
|
||||
errorMessage: 'Failed to save route: $e',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global notifier provider for route planner state.
|
||||
final routePlannerNotifierProvider =
|
||||
StateNotifierProvider<RoutePlannerNotifier, RoutePlannerState>((ref) {
|
||||
return RoutePlannerNotifier(ref);
|
||||
});
|
||||
|
||||
/// Backward-compatible family alias.
|
||||
final routePlannerProvider = StateNotifierProvider.autoDispose
|
||||
.family<RoutePlannerNotifier, RoutePlannerState, MotorSignalRoute?>(
|
||||
(ref, initialRoute) => RoutePlannerNotifier(ref, initialRoute),
|
||||
);
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
import 'dart:async';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'route_models.dart';
|
||||
|
||||
/// Service managing route creation, persistence, reordering, retrieval, and attachment.
|
||||
class RouteService {
|
||||
final FirebaseFirestore? _firestore;
|
||||
|
||||
RouteService({FirebaseFirestore? firestore})
|
||||
: _firestore = firestore ?? _resolveFirestore();
|
||||
|
||||
static FirebaseFirestore? _resolveFirestore() {
|
||||
try {
|
||||
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get isAvailable => _firestore != null;
|
||||
|
||||
/// Technical safety bound for maximum stops in a single route.
|
||||
static const int maxTechnicalStops = 25;
|
||||
|
||||
/// Free tier recommended stop cap.
|
||||
static const int freeStopCap = 8;
|
||||
|
||||
/// Validates route stops invariants.
|
||||
static void validateStops(List<RouteStop> stops, {bool isPro = false}) {
|
||||
if (stops.length > maxTechnicalStops) {
|
||||
throw ArgumentError(
|
||||
'Route cannot exceed $maxTechnicalStops stops.',
|
||||
);
|
||||
}
|
||||
if (!isPro && stops.length > freeStopCap) {
|
||||
throw ArgumentError(
|
||||
'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.',
|
||||
);
|
||||
}
|
||||
|
||||
final seenIds = <String>{};
|
||||
for (int i = 0; i < stops.length; i++) {
|
||||
final s = stops[i];
|
||||
if (s.id.isEmpty) {
|
||||
throw ArgumentError('Route stop at index $i is missing an ID.');
|
||||
}
|
||||
if (seenIds.contains(s.id)) {
|
||||
throw ArgumentError('Duplicate route stop ID: ${s.id}');
|
||||
}
|
||||
seenIds.add(s.id);
|
||||
|
||||
if (s.latitude.isNaN || s.longitude.isNaN) {
|
||||
throw ArgumentError('Route stop contains invalid coordinates (NaN).');
|
||||
}
|
||||
if (s.latitude < -90.0 || s.latitude > 90.0) {
|
||||
throw ArgumentError(
|
||||
'Route stop contains invalid coordinates: latitude ${s.latitude} out of bounds.',
|
||||
);
|
||||
}
|
||||
if (s.longitude < -180.0 || s.longitude > 180.0) {
|
||||
throw ArgumentError(
|
||||
'Route stop contains invalid coordinates: longitude ${s.longitude} out of bounds.',
|
||||
);
|
||||
}
|
||||
if (s.displayName.trim().isEmpty) {
|
||||
throw ArgumentError('Stop name cannot be empty.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves or creates a route (offline-safe when Firestore is unconfigured).
|
||||
Future<MotorSignalRoute> saveRoute({
|
||||
String? id,
|
||||
required String title,
|
||||
String description = '',
|
||||
required String ownerUid,
|
||||
String ownerHandle = '',
|
||||
RouteVisibility visibility = RouteVisibility.private,
|
||||
required List<RouteStop> stops,
|
||||
bool isPro = false,
|
||||
String? attachedGroupRideId,
|
||||
}) async {
|
||||
final cleanTitle = title.trim();
|
||||
if (cleanTitle.length < 3 || cleanTitle.length > 80) {
|
||||
throw ArgumentError('Route title must be between 3 and 80 characters.');
|
||||
}
|
||||
|
||||
validateStops(stops, isPro: isPro);
|
||||
|
||||
final now = DateTime.now();
|
||||
final routeId = id ??
|
||||
'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}';
|
||||
|
||||
// Re-index stops 0..N-1
|
||||
final sortedStops = List<RouteStop>.from(stops);
|
||||
sortedStops.sort((a, b) => a.order.compareTo(b.order));
|
||||
final normalizedStops = <RouteStop>[];
|
||||
for (int i = 0; i < sortedStops.length; i++) {
|
||||
normalizedStops.add(sortedStops[i].copyWith(order: i, orderIndex: i));
|
||||
}
|
||||
|
||||
final totalMeters =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(normalizedStops);
|
||||
|
||||
final route = MotorSignalRoute(
|
||||
id: routeId,
|
||||
ownerUid: ownerUid,
|
||||
ownerHandle: ownerHandle,
|
||||
title: cleanTitle,
|
||||
description: description.trim(),
|
||||
visibility: visibility,
|
||||
stops: normalizedStops,
|
||||
straightLineDistanceMeters: totalMeters,
|
||||
source: RouteSource.planner,
|
||||
version: 1,
|
||||
attachedGroupRideId: attachedGroupRideId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore != null) {
|
||||
await firestore.collection('routes').doc(routeId).set(route.toJson());
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/// Legacy alias for saveRoute.
|
||||
Future<MotorSignalRoute> createRoute({
|
||||
required String ownerUid,
|
||||
required String ownerHandle,
|
||||
required String title,
|
||||
String description = '',
|
||||
RouteVisibility visibility = RouteVisibility.private,
|
||||
required List<RouteStop> stops,
|
||||
bool isPro = false,
|
||||
String? attachedGroupRideId,
|
||||
}) async {
|
||||
return saveRoute(
|
||||
ownerUid: ownerUid,
|
||||
ownerHandle: ownerHandle,
|
||||
title: title,
|
||||
description: description,
|
||||
visibility: visibility,
|
||||
stops: stops,
|
||||
isPro: isPro,
|
||||
attachedGroupRideId: attachedGroupRideId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieves a route by ID.
|
||||
Future<MotorSignalRoute?> getRoute(String routeId) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return null;
|
||||
|
||||
final doc = await firestore.collection('routes').doc(routeId).get();
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
|
||||
return MotorSignalRoute.fromJson(doc.data()!);
|
||||
}
|
||||
|
||||
/// Streams real-time updates for a single route.
|
||||
Stream<MotorSignalRoute?> routeStream(String routeId) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return Stream.value(null);
|
||||
|
||||
return firestore.collection('routes').doc(routeId).snapshots().map((doc) {
|
||||
if (!doc.exists || doc.data() == null) return null;
|
||||
return MotorSignalRoute.fromJson(doc.data()!);
|
||||
});
|
||||
}
|
||||
|
||||
/// Streams saved routes owned by [ownerUid].
|
||||
Stream<List<MotorSignalRoute>> userRoutesStream(String ownerUid) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null || ownerUid.isEmpty) return Stream.value(const []);
|
||||
|
||||
return firestore
|
||||
.collection('routes')
|
||||
.where('ownerUid', isEqualTo: ownerUid)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
final routes = snapshot.docs
|
||||
.map((doc) => MotorSignalRoute.fromJson(doc.data()))
|
||||
.toList();
|
||||
routes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return routes;
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates an existing route.
|
||||
Future<MotorSignalRoute> updateRoute({
|
||||
required MotorSignalRoute route,
|
||||
required String callerUid,
|
||||
bool isPro = false,
|
||||
}) async {
|
||||
if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) {
|
||||
throw StateError('Only the route owner can update this route.');
|
||||
}
|
||||
|
||||
return saveRoute(
|
||||
id: route.id,
|
||||
title: route.title,
|
||||
description: route.description,
|
||||
ownerUid: callerUid,
|
||||
ownerHandle: route.ownerHandle,
|
||||
visibility: route.visibility,
|
||||
stops: route.stops,
|
||||
isPro: isPro,
|
||||
attachedGroupRideId: route.attachedGroupRideId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a route safely.
|
||||
///
|
||||
/// If the route is attached to a Group Ride, it cleanly clears the
|
||||
/// attachment reference without destructive cascades.
|
||||
Future<void> deleteRoute(
|
||||
dynamic routeIdOrNamed, {
|
||||
String? routeId,
|
||||
String? callerUid,
|
||||
}) async {
|
||||
final effectiveRouteId =
|
||||
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
|
||||
if (effectiveRouteId.isEmpty) return;
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final docRef = firestore.collection('routes').doc(effectiveRouteId);
|
||||
final snapshot = await docRef.get();
|
||||
if (!snapshot.exists || snapshot.data() == null) return;
|
||||
|
||||
final data = snapshot.data()!;
|
||||
if (callerUid != null && data['ownerUid'] != callerUid) {
|
||||
throw StateError('Only the route owner can delete this route.');
|
||||
}
|
||||
|
||||
final attachedRideId = data['attachedGroupRideId'] as String?;
|
||||
if (attachedRideId != null && attachedRideId.isNotEmpty) {
|
||||
final rideRef = firestore.collection('group_rides').doc(attachedRideId);
|
||||
final rideSnap = await rideRef.get();
|
||||
if (rideSnap.exists) {
|
||||
await rideRef.update({
|
||||
'routeId': FieldValue.delete(),
|
||||
'routeTitle': FieldValue.delete(),
|
||||
'routeDistanceMeters': FieldValue.delete(),
|
||||
'routeStopCount': FieldValue.delete(),
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await docRef.delete();
|
||||
}
|
||||
|
||||
/// Attaches a saved route to a Group Ride.
|
||||
///
|
||||
/// Caller must be the Group Ride organizer.
|
||||
Future<void> attachRouteToGroupRide({
|
||||
String? routeId,
|
||||
MotorSignalRoute? route,
|
||||
required String groupRideId,
|
||||
String? callerUid,
|
||||
}) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final targetRouteId = route?.id ?? routeId;
|
||||
if (targetRouteId == null || targetRouteId.isEmpty) {
|
||||
throw ArgumentError('Route or routeId must be provided.');
|
||||
}
|
||||
|
||||
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
||||
final rideSnap = await rideRef.get();
|
||||
if (!rideSnap.exists || rideSnap.data() == null) {
|
||||
throw ArgumentError('Group Ride not found.');
|
||||
}
|
||||
|
||||
final rideData = rideSnap.data()!;
|
||||
if (callerUid != null &&
|
||||
rideData['organizerUid'] != null &&
|
||||
rideData['organizerUid'] != callerUid) {
|
||||
throw StateError('Only the Group Ride organizer can attach a route.');
|
||||
}
|
||||
|
||||
final effectiveRoute = route ?? await getRoute(targetRouteId);
|
||||
if (effectiveRoute == null) {
|
||||
throw ArgumentError('Route not found.');
|
||||
}
|
||||
|
||||
final routeRef = firestore.collection('routes').doc(targetRouteId);
|
||||
final now = DateTime.now();
|
||||
final batch = firestore.batch();
|
||||
|
||||
// 1. Update Group Ride metadata
|
||||
batch.update(rideRef, {
|
||||
'routeId': effectiveRoute.id,
|
||||
'routeTitle': effectiveRoute.title,
|
||||
'routeDistanceMeters': effectiveRoute.straightLineDistanceMeters,
|
||||
'routeStopCount': effectiveRoute.stops.length,
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
// 2. Update Route document attachment
|
||||
batch.update(routeRef, {
|
||||
'attachedGroupRideId': groupRideId,
|
||||
'visibility': RouteVisibility.groupOnly.name,
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
/// Detaches an attached route from a Group Ride.
|
||||
Future<void> detachRouteFromGroupRide(
|
||||
String groupRideId, {
|
||||
String? callerUid,
|
||||
}) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
||||
final rideSnap = await rideRef.get();
|
||||
if (!rideSnap.exists || rideSnap.data() == null) return;
|
||||
|
||||
final rideData = rideSnap.data()!;
|
||||
if (callerUid != null &&
|
||||
rideData['organizerUid'] != null &&
|
||||
rideData['organizerUid'] != callerUid) {
|
||||
throw StateError('Only the organizer can detach a route.');
|
||||
}
|
||||
|
||||
final routeId = rideData['routeId'] as String?;
|
||||
final now = DateTime.now();
|
||||
|
||||
final batch = firestore.batch();
|
||||
batch.update(rideRef, {
|
||||
'routeId': FieldValue.delete(),
|
||||
'routeTitle': FieldValue.delete(),
|
||||
'routeDistanceMeters': FieldValue.delete(),
|
||||
'routeStopCount': FieldValue.delete(),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
if (routeId != null && routeId.isNotEmpty) {
|
||||
final routeRef = firestore.collection('routes').doc(routeId);
|
||||
final routeSnap = await routeRef.get();
|
||||
if (routeSnap.exists) {
|
||||
batch.update(routeRef, {
|
||||
'attachedGroupRideId': FieldValue.delete(),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
|
||||
void main() {
|
||||
group('RouteStop & RouteStopType tests', () {
|
||||
test('RouteStop serialization round-trip', () {
|
||||
const stop = RouteStop(
|
||||
id: 'stop-1',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'San Francisco Hub',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
notes: 'Meet at 9 AM',
|
||||
);
|
||||
|
||||
final json = stop.toJson();
|
||||
final fromJson = RouteStop.fromJson(json);
|
||||
|
||||
expect(fromJson.id, equals('stop-1'));
|
||||
expect(fromJson.latitude, equals(37.7749));
|
||||
expect(fromJson.longitude, equals(-122.4194));
|
||||
expect(fromJson.displayName, equals('San Francisco Hub'));
|
||||
expect(fromJson.stopType, equals(RouteStopType.origin));
|
||||
expect(fromJson.orderIndex, equals(0));
|
||||
expect(fromJson.notes, equals('Meet at 9 AM'));
|
||||
expect(fromJson, equals(stop));
|
||||
});
|
||||
|
||||
test('RouteStop copyWith preserves and modifies fields', () {
|
||||
const stop = RouteStop(
|
||||
id: 'stop-1',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'SF',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
);
|
||||
|
||||
final updated = stop.copyWith(
|
||||
displayName: 'SF City Hall',
|
||||
orderIndex: 1,
|
||||
stopType: RouteStopType.waypoint,
|
||||
);
|
||||
|
||||
expect(updated.id, equals('stop-1'));
|
||||
expect(updated.displayName, equals('SF City Hall'));
|
||||
expect(updated.orderIndex, equals(1));
|
||||
expect(updated.stopType, equals(RouteStopType.waypoint));
|
||||
expect(updated.latitude, equals(37.7749));
|
||||
});
|
||||
|
||||
test('RouteStopType labels', () {
|
||||
expect(RouteStopType.origin.label, equals('Start / Origin'));
|
||||
expect(RouteStopType.waypoint.label, equals('Waypoint'));
|
||||
expect(RouteStopType.fuel.label, equals('Fuel Stop'));
|
||||
expect(RouteStopType.rest.label, equals('Rest Area'));
|
||||
expect(RouteStopType.food.label, equals('Food & Drink'));
|
||||
expect(RouteStopType.scenic.label, equals('Scenic Overlook'));
|
||||
expect(RouteStopType.regroup.label, equals('Regroup Point'));
|
||||
expect(RouteStopType.destination.label, equals('Destination'));
|
||||
});
|
||||
});
|
||||
|
||||
group('MotorSignalRoute domain model tests', () {
|
||||
final now = DateTime.utc(2026, 9, 4, 12, 0, 0);
|
||||
|
||||
test('calculates straight-line distance correctly via Haversine', () {
|
||||
final route = MotorSignalRoute(
|
||||
id: 'route-sf-sj',
|
||||
ownerUid: 'alice',
|
||||
title: 'SF to San Jose Run',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stops: const [
|
||||
RouteStop(
|
||||
id: 'stop-0',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'SF Start',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
RouteStop(
|
||||
id: 'stop-1',
|
||||
latitude: 37.4419,
|
||||
longitude: -122.1430,
|
||||
displayName: 'Palo Alto',
|
||||
stopType: RouteStopType.waypoint,
|
||||
orderIndex: 1,
|
||||
),
|
||||
RouteStop(
|
||||
id: 'stop-2',
|
||||
latitude: 37.3382,
|
||||
longitude: -121.8863,
|
||||
displayName: 'San Jose Finish',
|
||||
stopType: RouteStopType.destination,
|
||||
orderIndex: 2,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(route.stops.length, equals(3));
|
||||
expect(route.previewPoints.length, equals(3));
|
||||
expect(route.totalDistanceMeters, greaterThan(65000));
|
||||
expect(route.totalDistanceMeters, lessThan(85000));
|
||||
expect(route.distanceMiles, greaterThan(40));
|
||||
expect(route.distanceMiles, lessThan(55));
|
||||
expect(route.formattedDistanceMiles, contains('mi'));
|
||||
expect(route.formattedDistanceKm, contains('km'));
|
||||
});
|
||||
|
||||
test('empty or single stop route has 0 distance', () {
|
||||
final emptyRoute = MotorSignalRoute(
|
||||
id: 'route-empty',
|
||||
ownerUid: 'alice',
|
||||
title: 'Empty Route',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stops: const [],
|
||||
);
|
||||
expect(emptyRoute.totalDistanceMeters, equals(0.0));
|
||||
expect(emptyRoute.distanceMiles, equals(0.0));
|
||||
|
||||
final singleStopRoute = MotorSignalRoute(
|
||||
id: 'route-single',
|
||||
ownerUid: 'alice',
|
||||
title: 'Single Stop Route',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stops: const [
|
||||
RouteStop(
|
||||
id: 'stop-0',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'Solo',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
],
|
||||
);
|
||||
expect(singleStopRoute.totalDistanceMeters, equals(0.0));
|
||||
expect(singleStopRoute.distanceMiles, equals(0.0));
|
||||
});
|
||||
|
||||
test('MotorSignalRoute serialization round-trip', () {
|
||||
final route = MotorSignalRoute(
|
||||
id: 'route-1',
|
||||
ownerUid: 'alice',
|
||||
title: 'Skyline Ridge',
|
||||
description: 'Twisty canyon ride',
|
||||
visibility: RouteVisibility.groupOnly,
|
||||
source: RouteSource.planner,
|
||||
attachedGroupRideId: 'group-ride-123',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stops: const [
|
||||
RouteStop(
|
||||
id: 'stop-1',
|
||||
latitude: 37.4,
|
||||
longitude: -122.2,
|
||||
displayName: 'Alice\'s',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
RouteStop(
|
||||
id: 'stop-2',
|
||||
latitude: 37.2,
|
||||
longitude: -122.1,
|
||||
displayName: 'Vista Point',
|
||||
stopType: RouteStopType.destination,
|
||||
orderIndex: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final json = route.toJson();
|
||||
final fromJson = MotorSignalRoute.fromJson(json);
|
||||
|
||||
expect(fromJson.id, equals('route-1'));
|
||||
expect(fromJson.ownerUid, equals('alice'));
|
||||
expect(fromJson.title, equals('Skyline Ridge'));
|
||||
expect(fromJson.description, equals('Twisty canyon ride'));
|
||||
expect(fromJson.visibility, equals(RouteVisibility.groupOnly));
|
||||
expect(fromJson.source, equals(RouteSource.planner));
|
||||
expect(fromJson.attachedGroupRideId, equals('group-ride-123'));
|
||||
expect(fromJson.stops.length, equals(2));
|
||||
expect(fromJson.stops[0].displayName, equals('Alice\'s'));
|
||||
expect(fromJson.stops[1].displayName, equals('Vista Point'));
|
||||
});
|
||||
|
||||
test('RouteVisibility labels', () {
|
||||
expect(RouteVisibility.private.label, equals('Private'));
|
||||
expect(RouteVisibility.groupOnly.label, equals('Group Ride Only'));
|
||||
expect(RouteVisibility.public.label, equals('Public'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
||||
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('RoutePlannerScreen renders title, empty state, and stop controls', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(360, 640));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => const AuthUser(
|
||||
uid: 'alice',
|
||||
isAnonymous: false,
|
||||
)),
|
||||
isProSubscriberProvider.overrideWith((ref) => false),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: RoutePlannerScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify AppBar title
|
||||
expect(find.text('ROUTE PLANNER'), findsOneWidget);
|
||||
expect(find.text('SAVE'), findsOneWidget);
|
||||
|
||||
// Verify Empty state message
|
||||
expect(find.text('No stops added yet.'), findsOneWidget);
|
||||
expect(find.text('ADD STOP'), findsOneWidget);
|
||||
expect(find.text('CURRENT GPS'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('RoutePlannerScreen handles adding a stop', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(360, 640));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => const AuthUser(
|
||||
uid: 'alice',
|
||||
isAnonymous: false,
|
||||
)),
|
||||
isProSubscriberProvider.overrideWith((ref) => false),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: RoutePlannerScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap ADD STOP
|
||||
await tester.tap(find.text('ADD STOP'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog appears
|
||||
expect(find.text('Add Route Stop'), findsOneWidget);
|
||||
|
||||
// Enter name, lat, lng
|
||||
final dialogTextFields = find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
await tester.enterText(dialogTextFields.at(0), 'Alice\'s Restaurant');
|
||||
await tester.enterText(dialogTextFields.at(1), '37.3861');
|
||||
await tester.enterText(dialogTextFields.at(2), '-122.2641');
|
||||
|
||||
// Tap ADD button in dialog
|
||||
await tester.tap(find.text('ADD'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify stop is added in list
|
||||
expect(find.text('Alice\'s Restaurant'), findsOneWidget);
|
||||
expect(find.text('1/8 STOPS'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('RoutePlannerScreen responsive layout on narrow 320dp screen', (tester) async {
|
||||
// 320dp narrow device test
|
||||
await tester.binding.setSurfaceSize(const Size(320, 568));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
currentUserProvider.overrideWith((ref) => const AuthUser(
|
||||
uid: 'alice',
|
||||
isAnonymous: false,
|
||||
)),
|
||||
isProSubscriberProvider.overrideWith((ref) => false),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: RoutePlannerScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// On narrow screen, compact labels 'ADD' and 'GPS' are shown
|
||||
expect(find.text('ADD'), findsOneWidget);
|
||||
expect(find.text('GPS'), findsOneWidget);
|
||||
|
||||
// No RenderFlex overflow
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/routes/navigation_handoff.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_service.dart';
|
||||
|
||||
void main() {
|
||||
group('RouteService validation tests', () {
|
||||
final service = RouteService(firestore: null);
|
||||
|
||||
test('validates route title length (minimum 3 characters)', () async {
|
||||
expect(
|
||||
() => service.saveRoute(
|
||||
title: 'Hi',
|
||||
ownerUid: 'alice',
|
||||
stops: [],
|
||||
),
|
||||
throwsA(isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('between 3 and 80 characters'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('validates route title length (maximum 80 characters)', () async {
|
||||
final longTitle = 'A' * 81;
|
||||
expect(
|
||||
() => service.saveRoute(
|
||||
title: longTitle,
|
||||
ownerUid: 'alice',
|
||||
stops: [],
|
||||
),
|
||||
throwsA(isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('between 3 and 80 characters'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('validates maximum stops technical cap (25 stops)', () async {
|
||||
final stops = List.generate(
|
||||
26,
|
||||
(i) => RouteStop(
|
||||
id: 'stop-$i',
|
||||
latitude: 37.0 + i * 0.01,
|
||||
longitude: -122.0,
|
||||
displayName: 'Stop $i',
|
||||
stopType: RouteStopType.waypoint,
|
||||
orderIndex: i,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => service.saveRoute(
|
||||
title: 'Too Many Stops Route',
|
||||
ownerUid: 'alice',
|
||||
stops: stops,
|
||||
),
|
||||
throwsA(isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('cannot exceed 25 stops'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('validates stop coordinates bounds', () async {
|
||||
final invalidLatStop = [
|
||||
const RouteStop(
|
||||
id: 'stop-bad-lat',
|
||||
latitude: 95.0,
|
||||
longitude: -122.0,
|
||||
displayName: 'Bad Lat',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
() => service.saveRoute(
|
||||
title: 'Invalid Coordinates Route',
|
||||
ownerUid: 'alice',
|
||||
stops: invalidLatStop,
|
||||
),
|
||||
throwsA(isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('invalid coordinates'),
|
||||
)),
|
||||
);
|
||||
|
||||
final invalidLngStop = [
|
||||
const RouteStop(
|
||||
id: 'stop-bad-lng',
|
||||
latitude: 37.0,
|
||||
longitude: -185.0,
|
||||
displayName: 'Bad Lng',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
() => service.saveRoute(
|
||||
title: 'Invalid Coordinates Route',
|
||||
ownerUid: 'alice',
|
||||
stops: invalidLngStop,
|
||||
),
|
||||
throwsA(isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('invalid coordinates'),
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('offline fallback: saveRoute generates valid route object and reindexes stops', () async {
|
||||
final inputStops = [
|
||||
const RouteStop(
|
||||
id: 'stop-b',
|
||||
latitude: 37.4,
|
||||
longitude: -122.2,
|
||||
displayName: 'Midpoint',
|
||||
stopType: RouteStopType.waypoint,
|
||||
orderIndex: 99,
|
||||
),
|
||||
const RouteStop(
|
||||
id: 'stop-a',
|
||||
latitude: 37.7,
|
||||
longitude: -122.4,
|
||||
displayName: 'Start',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
];
|
||||
|
||||
final saved = await service.saveRoute(
|
||||
title: 'Skyline Ridge',
|
||||
description: 'Twisty Sunday Run',
|
||||
ownerUid: 'alice',
|
||||
visibility: RouteVisibility.groupOnly,
|
||||
stops: inputStops,
|
||||
);
|
||||
|
||||
expect(saved.id, startsWith('route_'));
|
||||
expect(saved.title, equals('Skyline Ridge'));
|
||||
expect(saved.ownerUid, equals('alice'));
|
||||
expect(saved.visibility, equals(RouteVisibility.groupOnly));
|
||||
expect(saved.stops.length, equals(2));
|
||||
expect(saved.stops[0].orderIndex, equals(0));
|
||||
expect(saved.stops[1].orderIndex, equals(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('StraightLineRouteProvider tests', () {
|
||||
const provider = StraightLineRouteProvider();
|
||||
|
||||
test('builds straight-line polyline and calculates distance', () async {
|
||||
const stops = [
|
||||
RouteStop(
|
||||
id: 's1',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'SF',
|
||||
stopType: RouteStopType.origin,
|
||||
orderIndex: 0,
|
||||
),
|
||||
RouteStop(
|
||||
id: 's2',
|
||||
latitude: 37.3382,
|
||||
longitude: -121.8863,
|
||||
displayName: 'San Jose',
|
||||
stopType: RouteStopType.destination,
|
||||
orderIndex: 1,
|
||||
),
|
||||
];
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
expect(result.points.length, equals(2));
|
||||
expect(result.points[0].latitude, equals(37.7749));
|
||||
expect(result.points[1].latitude, equals(37.3382));
|
||||
expect(result.totalDistanceMeters, greaterThan(65000));
|
||||
expect(result.isHonestStraightLine, isTrue);
|
||||
});
|
||||
|
||||
test('empty stops produces zero points and zero distance', () async {
|
||||
final result = await provider.calculateRoute(const []);
|
||||
expect(result.points.isEmpty, isTrue);
|
||||
expect(result.totalDistanceMeters, equals(0.0));
|
||||
});
|
||||
});
|
||||
|
||||
group('NavigationHandoffService URI tests', () {
|
||||
test('builds valid Google Maps navigation URI', () {
|
||||
final uri = NavigationHandoffService.buildGoogleMapsUri(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'San Francisco Hub',
|
||||
);
|
||||
|
||||
expect(uri.scheme, equals('https'));
|
||||
expect(uri.host, equals('www.google.com'));
|
||||
expect(uri.path, equals('/maps/dir/'));
|
||||
expect(uri.queryParameters['api'], equals('1'));
|
||||
expect(uri.queryParameters['destination'], equals('37.774900,-122.419400'));
|
||||
});
|
||||
|
||||
test('builds valid Waze navigation URI', () {
|
||||
final uri = NavigationHandoffService.buildWazeUri(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
);
|
||||
|
||||
expect(uri.scheme, equals('https'));
|
||||
expect(uri.host, equals('waze.com'));
|
||||
expect(uri.path, equals('/ul'));
|
||||
expect(uri.queryParameters['ll'], equals('37.774900,-122.419400'));
|
||||
expect(uri.queryParameters['navigate'], equals('yes'));
|
||||
});
|
||||
|
||||
test('builds valid system geo URI', () {
|
||||
final uri = NavigationHandoffService.buildSystemGeoUri(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
displayName: 'SF Landmark',
|
||||
);
|
||||
|
||||
expect(uri.scheme, equals('geo'));
|
||||
expect(uri.path, equals('37.774900,-122.419400'));
|
||||
expect(uri.queryParameters['q'], equals('37.774900,-122.419400(SF Landmark)'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue