fix: activate and validate real motorcycle routing
This commit is contained in:
parent
92ce23cdb7
commit
3ca69f9be4
|
|
@ -4,4 +4,5 @@
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<application android:networkSecurityConfig="@xml/network_security_config" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/theme/theme_extensions.dart';
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
|
|
@ -72,6 +72,8 @@ class _AddressAutocompleteFieldState
|
||||||
bool _showDropdown = false;
|
bool _showDropdown = false;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
String _lastText = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -92,8 +94,17 @@ class _AddressAutocompleteFieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
_resolvedPlace = widget.initialPlace;
|
_resolvedPlace = widget.initialPlace;
|
||||||
|
_lastText = _controller.text;
|
||||||
|
|
||||||
_focusNode.addListener(_handleFocusChange);
|
_focusNode.addListener(_handleFocusChange);
|
||||||
|
_controller.addListener(_handleControllerChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleControllerChange() {
|
||||||
|
if (_controller.text != _lastText) {
|
||||||
|
_lastText = _controller.text;
|
||||||
|
_onTextChanged(_controller.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -105,6 +116,7 @@ class _AddressAutocompleteFieldState
|
||||||
if (_controller.text != newText) {
|
if (_controller.text != newText) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _controller.text != newText) {
|
if (mounted && _controller.text != newText) {
|
||||||
|
_lastText = newText;
|
||||||
_controller.text = newText;
|
_controller.text = newText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -115,6 +127,7 @@ class _AddressAutocompleteFieldState
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
|
_controller.removeListener(_handleControllerChange);
|
||||||
_focusNode.removeListener(_handleFocusChange);
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
if (_internalFocusNode) {
|
if (_internalFocusNode) {
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
|
@ -232,6 +245,7 @@ class _AddressAutocompleteFieldState
|
||||||
|
|
||||||
void _applyResolvedPlace(ResolvedPlace place) {
|
void _applyResolvedPlace(ResolvedPlace place) {
|
||||||
_resolvedPlace = place;
|
_resolvedPlace = place;
|
||||||
|
_lastText = place.displayName;
|
||||||
_controller.text = place.displayName;
|
_controller.text = place.displayName;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isResolving = false;
|
_isResolving = false;
|
||||||
|
|
@ -244,6 +258,7 @@ class _AddressAutocompleteFieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clear() {
|
void _clear() {
|
||||||
|
_lastText = '';
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
_resolvedPlace = null;
|
_resolvedPlace = null;
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ abstract class AuthService {
|
||||||
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
|
||||||
Future<void> signInWithGoogle();
|
Future<void> signInWithGoogle();
|
||||||
Future<void> signInAnonymously();
|
Future<void> signInAnonymously();
|
||||||
|
Future<void> ensureBackendIdentity();
|
||||||
Future<void> sendPasswordResetEmail(String email);
|
Future<void> sendPasswordResetEmail(String email);
|
||||||
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
|
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
|
||||||
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
|
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
|
||||||
|
|
@ -351,6 +352,38 @@ class FirebaseAuthService implements AuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void>? _identityBootstrapFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> ensureBackendIdentity() {
|
||||||
|
final auth = _firebaseAuth;
|
||||||
|
if (auth != null && auth.currentUser != null) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
_identityBootstrapFuture ??= _performIdentityBootstrap().whenComplete(() {
|
||||||
|
_identityBootstrapFuture = null;
|
||||||
|
});
|
||||||
|
return _identityBootstrapFuture!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performIdentityBootstrap() async {
|
||||||
|
final auth = _firebaseAuth;
|
||||||
|
if (auth == null || !isConfigured) {
|
||||||
|
throw StateError('Firebase is not configured. Backend identity cannot be established.');
|
||||||
|
}
|
||||||
|
if (auth.currentUser != null) return;
|
||||||
|
try {
|
||||||
|
final credential = await auth.signInAnonymously();
|
||||||
|
if (credential.user != null) {
|
||||||
|
final user = _mapFirebaseUser(credential.user!);
|
||||||
|
_updateState(AuthState.anonymous(user, isFirebaseConfigured: true));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Backend identity bootstrap failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> sendPasswordResetEmail(String email) async {
|
Future<void> sendPasswordResetEmail(String email) async {
|
||||||
final auth = _firebaseAuth;
|
final auth = _firebaseAuth;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@ import 'package:latlong2/latlong.dart' as ll;
|
||||||
import 'map_provider_interface.dart';
|
import 'map_provider_interface.dart';
|
||||||
|
|
||||||
class OpenStreetMapProvider implements AppMapProvider {
|
class OpenStreetMapProvider implements AppMapProvider {
|
||||||
final MapController _mapController = MapController();
|
|
||||||
ll.LatLng? _lastFollowPos;
|
|
||||||
DateTime? _lastFollowTime;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppMapType get type => AppMapType.openStreetMap;
|
AppMapType get type => AppMapType.openStreetMap;
|
||||||
|
|
||||||
|
|
@ -27,9 +23,60 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
required ValueChanged<AppLatLng>? onPositionChanged,
|
required ValueChanged<AppLatLng>? onPositionChanged,
|
||||||
required VoidCallback? onUserInteraction,
|
required VoidCallback? onUserInteraction,
|
||||||
}) {
|
}) {
|
||||||
final centerLl = ll.LatLng(center.latitude, center.longitude);
|
return _OsmMapView(
|
||||||
|
center: center,
|
||||||
|
zoom: zoom,
|
||||||
|
markers: markers,
|
||||||
|
polylines: polylines,
|
||||||
|
isFollowMode: isFollowMode,
|
||||||
|
config: config,
|
||||||
|
onPositionChanged: onPositionChanged,
|
||||||
|
onUserInteraction: onUserInteraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isFollowMode) {
|
class _OsmMapView extends StatefulWidget {
|
||||||
|
final AppLatLng center;
|
||||||
|
final double zoom;
|
||||||
|
final List<AppMapMarker> markers;
|
||||||
|
final List<AppPolyline> polylines;
|
||||||
|
final bool isFollowMode;
|
||||||
|
final MapConfig config;
|
||||||
|
final ValueChanged<AppLatLng>? onPositionChanged;
|
||||||
|
final VoidCallback? onUserInteraction;
|
||||||
|
|
||||||
|
const _OsmMapView({
|
||||||
|
required this.center,
|
||||||
|
required this.zoom,
|
||||||
|
required this.markers,
|
||||||
|
required this.polylines,
|
||||||
|
required this.isFollowMode,
|
||||||
|
required this.config,
|
||||||
|
required this.onPositionChanged,
|
||||||
|
required this.onUserInteraction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OsmMapView> createState() => _OsmMapViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
|
late final MapController _mapController;
|
||||||
|
ll.LatLng? _lastFollowPos;
|
||||||
|
DateTime? _lastFollowTime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mapController = MapController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _OsmMapView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.isFollowMode) {
|
||||||
|
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final posDelta = _lastFollowPos == null
|
final posDelta = _lastFollowPos == null
|
||||||
? 1.0
|
? 1.0
|
||||||
|
|
@ -43,13 +90,25 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
_lastFollowPos = centerLl;
|
_lastFollowPos = centerLl;
|
||||||
_lastFollowTime = now;
|
_lastFollowTime = now;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
try {
|
if (mounted) {
|
||||||
_mapController.move(centerLl, zoom);
|
try {
|
||||||
} catch (_) {}
|
_mapController.move(centerLl, widget.zoom);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_mapController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
||||||
final media = MediaQuery.of(context);
|
final media = MediaQuery.of(context);
|
||||||
final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
|
final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
|
||||||
|
|
||||||
|
|
@ -58,30 +117,30 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
mapController: _mapController,
|
mapController: _mapController,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCenter: centerLl,
|
initialCenter: centerLl,
|
||||||
initialZoom: zoom,
|
initialZoom: widget.zoom,
|
||||||
minZoom: config.minZoom,
|
minZoom: widget.config.minZoom,
|
||||||
maxZoom: config.maxZoom,
|
maxZoom: widget.config.maxZoom,
|
||||||
backgroundColor: const Color(0xFF0F0F0F),
|
backgroundColor: const Color(0xFF0F0F0F),
|
||||||
onPositionChanged: (position, hasGesture) {
|
onPositionChanged: (position, hasGesture) {
|
||||||
if (hasGesture) {
|
if (hasGesture) {
|
||||||
onUserInteraction?.call();
|
widget.onUserInteraction?.call();
|
||||||
}
|
}
|
||||||
if (onPositionChanged != null) {
|
if (widget.onPositionChanged != null) {
|
||||||
final c = position.center;
|
final c = position.center;
|
||||||
onPositionChanged(AppLatLng(c.latitude, c.longitude));
|
widget.onPositionChanged!(AppLatLng(c.latitude, c.longitude));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
TileLayer(
|
||||||
urlTemplate: config.tileUrlTemplate,
|
urlTemplate: widget.config.tileUrlTemplate,
|
||||||
subdomains: config.subdomains,
|
subdomains: widget.config.subdomains,
|
||||||
userAgentPackageName: 'app.motorsignal.motorcycle',
|
userAgentPackageName: 'app.motorsignal.motorcycle',
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
maxNativeZoom: 19,
|
maxNativeZoom: 19,
|
||||||
),
|
),
|
||||||
PolylineLayer(
|
PolylineLayer(
|
||||||
polylines: polylines.map((p) {
|
polylines: widget.polylines.map((p) {
|
||||||
return Polyline(
|
return Polyline(
|
||||||
points: p.points.map((pt) => ll.LatLng(pt.latitude, pt.longitude)).toList(),
|
points: p.points.map((pt) => ll.LatLng(pt.latitude, pt.longitude)).toList(),
|
||||||
strokeWidth: p.width,
|
strokeWidth: p.width,
|
||||||
|
|
@ -90,7 +149,7 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
markers: markers.map((m) {
|
markers: widget.markers.map((m) {
|
||||||
return Marker(
|
return Marker(
|
||||||
point: ll.LatLng(m.position.latitude, m.position.longitude),
|
point: ll.LatLng(m.position.latitude, m.position.longitude),
|
||||||
width: 50,
|
width: 50,
|
||||||
|
|
@ -113,7 +172,7 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
RichAttributionWidget(
|
RichAttributionWidget(
|
||||||
attributions: [
|
attributions: [
|
||||||
TextSourceAttribution(
|
TextSourceAttribution(
|
||||||
config.attributionText,
|
widget.config.attributionText,
|
||||||
textStyle: const TextStyle(color: Colors.white70, fontSize: 10),
|
textStyle: const TextStyle(color: Colors.white70, fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,21 @@ import 'navigation_handoff.dart';
|
||||||
import 'route_models.dart';
|
import 'route_models.dart';
|
||||||
import 'route_provider.dart';
|
import 'route_provider.dart';
|
||||||
import 'route_service.dart';
|
import 'route_service.dart';
|
||||||
|
import 'valhalla_route_provider.dart';
|
||||||
/// Provider for RouteService.
|
/// Provider for RouteService.
|
||||||
final routeServiceProvider = Provider<RouteService>((ref) {
|
final routeServiceProvider = Provider<RouteService>((ref) {
|
||||||
return RouteService();
|
return RouteService();
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Active RouteProvider implementation (defaults to honest straight-line preview).
|
/// Active RouteProvider implementation.
|
||||||
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
|
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
|
||||||
return const StraightLineRouteProvider();
|
final authService = ref.watch(authServiceProvider);
|
||||||
|
return ValhallaRouteProvider(
|
||||||
|
tokenProvider: () async {
|
||||||
|
await authService.ensureBackendIdentity();
|
||||||
|
return await authService.getIdToken(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Provider for NavigationHandoffService.
|
/// Provider for NavigationHandoffService.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
@ -11,20 +12,32 @@ import 'valhalla_polyline_decoder.dart';
|
||||||
class ValhallaRouteProvider implements RouteProvider {
|
class ValhallaRouteProvider implements RouteProvider {
|
||||||
final String backendUrl;
|
final String backendUrl;
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final Future<String?> Function()? _tokenProvider;
|
final Future<String?> Function()? tokenProvider;
|
||||||
|
|
||||||
ValhallaRouteProvider({
|
ValhallaRouteProvider({
|
||||||
this.backendUrl = 'http://10.0.2.2:3000',
|
String? backendUrl,
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
this._tokenProvider,
|
this.tokenProvider,
|
||||||
}) : _client = client ?? http.Client();
|
}) : _client = client ?? http.Client(),
|
||||||
|
backendUrl = backendUrl ?? _getEffectiveBackendUrl();
|
||||||
|
|
||||||
|
static String _getEffectiveBackendUrl() {
|
||||||
|
const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL');
|
||||||
|
if (definedUrl.isNotEmpty) {
|
||||||
|
return definedUrl;
|
||||||
|
}
|
||||||
|
if (kReleaseMode) {
|
||||||
|
throw StateError('MOTORSIGNAL_API_BASE_URL is not configured for production build.');
|
||||||
|
}
|
||||||
|
return 'http://10.0.2.2:3000';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get name => 'Stadia Maps (Valhalla)';
|
String get name => 'Stadia Maps (Valhalla)';
|
||||||
|
|
||||||
Future<Map<String, String>> _getAuthHeaders() async {
|
Future<Map<String, String>> _getAuthHeaders() async {
|
||||||
String? token;
|
String? token;
|
||||||
final customTokenProvider = _tokenProvider;
|
final customTokenProvider = tokenProvider;
|
||||||
if (customTokenProvider != null) {
|
if (customTokenProvider != null) {
|
||||||
token = await customTokenProvider();
|
token = await customTokenProvider();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
|
const { getAuth } = require('firebase-admin/auth');
|
||||||
|
|
||||||
// Initialize Firebase Admin if not already initialized
|
// Initialize Firebase Admin if not already initialized
|
||||||
try {
|
try {
|
||||||
if (admin.apps && admin.apps.length === 0) {
|
const apps = admin.getApps ? admin.getApps() : (admin.apps || []);
|
||||||
admin.initializeApp();
|
if (apps.length === 0) {
|
||||||
|
admin.initializeApp({
|
||||||
|
projectId: process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Firebase admin initialization failed:', e.message);
|
console.warn('Firebase admin initialization failed:', e.message);
|
||||||
|
|
@ -24,7 +28,8 @@ async function requireAuth(req, res, next) {
|
||||||
const idToken = authHeader.split('Bearer ')[1];
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
const auth = typeof admin.auth === 'function' ? admin.auth() : getAuth();
|
||||||
|
const decodedToken = await auth.verifyIdToken(idToken);
|
||||||
req.user = decodedToken;
|
req.user = decodedToken;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { requireAuth } = require('../middleware/firebase_auth');
|
const { requireAuth } = require('../middleware/firebase_auth');
|
||||||
|
|
@ -114,7 +114,7 @@ router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, r
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
results: normalized,
|
results: normalized,
|
||||||
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
|
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||||
|
|
@ -190,7 +190,7 @@ router.get('/resolve', requireAppCheck, requireAuth, resolveLimiter, async (req,
|
||||||
formattedAddress: props.label || props.name || '',
|
formattedAddress: props.label || props.name || '',
|
||||||
latitude: coords[1],
|
latitude: coords[1],
|
||||||
longitude: coords[0],
|
longitude: coords[0],
|
||||||
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
|
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
||||||
|
|
||||||
class _Auth implements AuthService {
|
class _Auth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
String? token = 'test-token';
|
String? token = 'test-token';
|
||||||
AuthUser? user = const AuthUser(
|
AuthUser? user = const AuthUser(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true);
|
AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true);
|
||||||
bool reauthenticated = false;
|
bool reauthenticated = false;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
||||||
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
||||||
|
|
||||||
class _Auth implements AuthService {
|
class _Auth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
|
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
|
||||||
bool reauthenticated = false;
|
bool reauthenticated = false;
|
||||||
bool deleted = false;
|
bool deleted = false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
||||||
|
|
||||||
|
class MockFirebaseAuth implements fb.FirebaseAuth {
|
||||||
|
fb.User? _currentUser;
|
||||||
|
final StreamController<fb.User?> _authStateChangesController = StreamController<fb.User?>.broadcast();
|
||||||
|
int signInAnonymouslyCallCount = 0;
|
||||||
|
bool shouldThrowOnSignIn = false;
|
||||||
|
|
||||||
|
MockFirebaseAuth(this._currentUser);
|
||||||
|
|
||||||
|
@override
|
||||||
|
fb.User? get currentUser => _currentUser;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<fb.User?> authStateChanges() async* {
|
||||||
|
yield _currentUser;
|
||||||
|
yield* _authStateChangesController.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> signInAnonymously() async {
|
||||||
|
signInAnonymouslyCallCount++;
|
||||||
|
if (shouldThrowOnSignIn) {
|
||||||
|
throw fb.FirebaseAuthException(code: 'network-error', message: 'Network disconnected');
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
final user = MockUser('new_anon', true, null);
|
||||||
|
_currentUser = user;
|
||||||
|
_authStateChangesController.add(user);
|
||||||
|
return MockUserCredential(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) {
|
||||||
|
if (invocation.isGetter) return null;
|
||||||
|
return super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockUser implements fb.User {
|
||||||
|
@override
|
||||||
|
final String uid;
|
||||||
|
@override
|
||||||
|
final bool isAnonymous;
|
||||||
|
@override
|
||||||
|
final String? email;
|
||||||
|
@override
|
||||||
|
final bool emailVerified = false;
|
||||||
|
@override
|
||||||
|
final fb.UserMetadata metadata;
|
||||||
|
@override
|
||||||
|
final List<fb.UserInfo> providerData = const [];
|
||||||
|
|
||||||
|
MockUser(this.uid, this.isAnonymous, this.email) : metadata = MockUserMetadata();
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) {
|
||||||
|
if (invocation.isGetter) return null;
|
||||||
|
return super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockUserMetadata implements fb.UserMetadata {
|
||||||
|
@override
|
||||||
|
DateTime? get creationTime => DateTime.now();
|
||||||
|
@override
|
||||||
|
DateTime? get lastSignInTime => DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) {
|
||||||
|
if (invocation.isGetter) return null;
|
||||||
|
return super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockUserCredential implements fb.UserCredential {
|
||||||
|
@override
|
||||||
|
final fb.User? user;
|
||||||
|
|
||||||
|
MockUserCredential(this.user);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) {
|
||||||
|
if (invocation.isGetter) return null;
|
||||||
|
return super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AuthService ensureBackendIdentity Tests', () {
|
||||||
|
test('current registered user -> reused, no anonymous sign-in', () async {
|
||||||
|
final fakeUser = MockUser('reg_123', false, 'test@example.com');
|
||||||
|
final fakeAuth = MockFirebaseAuth(fakeUser);
|
||||||
|
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
|
||||||
|
await Future.delayed(Duration.zero); // allow init to run
|
||||||
|
|
||||||
|
await service.ensureBackendIdentity();
|
||||||
|
|
||||||
|
expect(fakeAuth.signInAnonymouslyCallCount, 0);
|
||||||
|
expect(service.currentUser?.isAnonymous, false);
|
||||||
|
expect(service.currentUser?.uid, 'reg_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current anonymous user -> reused', () async {
|
||||||
|
final fakeUser = MockUser('anon_123', true, null);
|
||||||
|
final fakeAuth = MockFirebaseAuth(fakeUser);
|
||||||
|
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
await service.ensureBackendIdentity();
|
||||||
|
|
||||||
|
expect(fakeAuth.signInAnonymouslyCallCount, 0);
|
||||||
|
expect(service.currentUser?.isAnonymous, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null user -> one anonymous sign-in', () async {
|
||||||
|
final fakeAuth = MockFirebaseAuth(null);
|
||||||
|
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
await service.ensureBackendIdentity();
|
||||||
|
|
||||||
|
expect(fakeAuth.signInAnonymouslyCallCount, 1);
|
||||||
|
expect(service.currentState.user?.uid, 'new_anon');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two simultaneous requests -> only one anonymous sign-in', () async {
|
||||||
|
final fakeAuth = MockFirebaseAuth(null);
|
||||||
|
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final future1 = service.ensureBackendIdentity();
|
||||||
|
final future2 = service.ensureBackendIdentity();
|
||||||
|
|
||||||
|
await Future.wait([future1, future2]);
|
||||||
|
|
||||||
|
expect(fakeAuth.signInAnonymouslyCallCount, 1);
|
||||||
|
expect(service.currentState.user?.uid, 'new_anon');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anonymous sign-in failure -> explicit provider/auth error', () async {
|
||||||
|
final fakeAuth = MockFirebaseAuth(null);
|
||||||
|
fakeAuth.shouldThrowOnSignIn = true;
|
||||||
|
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
|
||||||
|
await Future.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.ensureBackendIdentity(),
|
||||||
|
throwsA(isA<Exception>().having((e) => e.toString(), 'msg', contains('Network disconnected')))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ class FakeAuthService implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
final _stateController = StreamController<AuthState>.broadcast();
|
final _stateController = StreamController<AuthState>.broadcast();
|
||||||
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
|
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
|
||||||
bool _isConfigured = true;
|
bool _isConfigured = true;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
AuthUser? user;
|
AuthUser? user;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import 'package:drift/native.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
AuthUser? user;
|
AuthUser? user;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ const Map<String, Size> imeViewports = {
|
||||||
};
|
};
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
AuthUser? user;
|
AuthUser? user;
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class _TestAuthService implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
AuthUser? _user;
|
AuthUser? _user;
|
||||||
String? token = 'token';
|
String? token = 'token';
|
||||||
bool configured = true;
|
bool configured = true;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class TestMockAuthService implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
AuthUser? _user;
|
AuthUser? _user;
|
||||||
AuthState _state = const AuthState.unauthenticated();
|
AuthState _state = const AuthState.unauthenticated();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class MockAuthService implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
AuthUser? _user;
|
AuthUser? _user;
|
||||||
AuthState _state = const AuthState.unauthenticated();
|
AuthState _state = const AuthState.unauthenticated();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class _Auth implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
AuthUser? user;
|
AuthUser? user;
|
||||||
@override bool get isConfigured => true;
|
@override bool get isConfigured => true;
|
||||||
@override AuthUser? get currentUser => user;
|
@override AuthUser? get currentUser => user;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class MockSecurityAuthService implements AuthService {
|
||||||
@override Future<void> reloadCurrentUser() async {}
|
@override Future<void> reloadCurrentUser() async {}
|
||||||
@override Future<void> reauthenticate({String? password}) async {}
|
@override Future<void> reauthenticate({String? password}) async {}
|
||||||
@override Future<void> deleteCurrentUser() async {}
|
@override Future<void> deleteCurrentUser() async {}
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
AuthUser? _user;
|
AuthUser? _user;
|
||||||
String? _token;
|
String? _token;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class _FakeAuth implements AuthService {
|
class _FakeAuth implements AuthService {
|
||||||
|
@override Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
final _states = StreamController<AuthState>.broadcast();
|
final _states = StreamController<AuthState>.broadcast();
|
||||||
AuthUser? user;
|
AuthUser? user;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
@ -108,7 +110,7 @@ void main() {
|
||||||
'distanceMeters': 18500.5,
|
'distanceMeters': 18500.5,
|
||||||
'durationSeconds': 1320,
|
'durationSeconds': 1320,
|
||||||
'warnings': ['Motorcycle route preview'],
|
'warnings': ['Motorcycle route preview'],
|
||||||
'attribution': '© Stadia Maps',
|
'attribution': '© Stadia Maps',
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
|
|
@ -257,5 +259,125 @@ void main() {
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
test('routing request after bootstrap sends Bearer token', () async {
|
||||||
|
var tokenProviderCalled = false;
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
expect(request.headers['authorization'], 'Bearer my-bootstrap-token');
|
||||||
|
return http.Response(jsonEncode({
|
||||||
|
'encodedPolyline': 'w~j_~A_{n~P',
|
||||||
|
'distanceMeters': 1234.5,
|
||||||
|
'durationSeconds': 600,
|
||||||
|
'warnings': []
|
||||||
|
}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ValhallaRouteProvider(
|
||||||
|
client: mockClient,
|
||||||
|
tokenProvider: () async {
|
||||||
|
tokenProviderCalled = true;
|
||||||
|
return 'my-bootstrap-token';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final stops = [
|
||||||
|
const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0),
|
||||||
|
const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
await provider.calculateRoute(stops);
|
||||||
|
expect(tokenProviderCalled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search request after bootstrap sends Bearer token', () async {
|
||||||
|
var tokenProviderCalled = false;
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
expect(request.headers['authorization'], 'Bearer search-bootstrap-token');
|
||||||
|
return http.Response(jsonEncode({
|
||||||
|
'results': [],
|
||||||
|
'attribution': 'Test'
|
||||||
|
}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ValhallaRouteProvider(
|
||||||
|
client: mockClient,
|
||||||
|
tokenProvider: () async {
|
||||||
|
tokenProviderCalled = true;
|
||||||
|
return 'search-bootstrap-token';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.searchPlaces('cafe');
|
||||||
|
expect(tokenProviderCalled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve request after bootstrap sends Bearer token', () async {
|
||||||
|
var tokenProviderCalled = false;
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
expect(request.headers['authorization'], 'Bearer resolve-bootstrap-token');
|
||||||
|
return http.Response(jsonEncode({
|
||||||
|
'providerId': 'place_123',
|
||||||
|
'displayName': 'A Place',
|
||||||
|
'latitude': 10.0,
|
||||||
|
'longitude': 10.0
|
||||||
|
}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ValhallaRouteProvider(
|
||||||
|
client: mockClient,
|
||||||
|
tokenProvider: () async {
|
||||||
|
tokenProviderCalled = true;
|
||||||
|
return 'resolve-bootstrap-token';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await provider.resolvePlace('place_123');
|
||||||
|
expect(tokenProviderCalled, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Route Provider Configuration and Activation Tests', () {
|
||||||
|
test('configured backendUrl is honored', () {
|
||||||
|
final customProvider = ValhallaRouteProvider(
|
||||||
|
backendUrl: 'http://custom-host:8080',
|
||||||
|
tokenProvider: () async => 'token',
|
||||||
|
);
|
||||||
|
expect(customProvider.backendUrl, 'http://custom-host:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activeRouteCalculationProvider resolves to ValhallaRouteProvider by default', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final activeProvider = container.read(activeRouteCalculationProvider);
|
||||||
|
expect(activeProvider, isA<ValhallaRouteProvider>());
|
||||||
|
expect(activeProvider.name, 'Stadia Maps (Valhalla)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provider errors remain explicit and do not silently fall back to straight line', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(jsonEncode({
|
||||||
|
'error': 'providerUnavailable',
|
||||||
|
'message': 'Routing server unavailable'
|
||||||
|
}), 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = ValhallaRouteProvider(
|
||||||
|
client: mockClient,
|
||||||
|
tokenProvider: () async => 'token',
|
||||||
|
);
|
||||||
|
|
||||||
|
final stops = [
|
||||||
|
const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0),
|
||||||
|
const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => provider.calculateRoute(stops),
|
||||||
|
throwsA(isA<RouteProviderException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
'providerUnavailable',
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue