feat: add route planning and sharing

This commit is contained in:
Developer 2026-09-04 12:28:21 -04:00
parent 209987195b
commit 23e63350b0
19 changed files with 4738 additions and 31 deletions

View File

@ -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,
}));
});
});
});

View File

@ -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;
}
}
}

View File

@ -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);
},
),
],
);

View File

@ -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: [

View File

@ -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),

View File

@ -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,10 +1081,55 @@ 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
// 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(
@ -1083,9 +1155,18 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
),
),
),
onTap: () {
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: stop.latitude,
longitude: stop.longitude,
displayName: stop.displayName,
);
},
),
);
}
}
// 3. Destination Marker
if (ride.destination != null) {

View File

@ -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

View File

@ -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;
});

View File

@ -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.

View File

@ -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,
);

View File

@ -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);
}
}

View File

@ -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,
);
}
}

View File

@ -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 [];
}
}

View File

@ -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),
);

View File

@ -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();
}
}

198
test/route_models_test.dart Normal file
View File

@ -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'));
});
});
}

View File

@ -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);
});
}

View File

@ -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)'));
});
});
}