fix: complete acceptance review hardening for navigation, routing, social, maintenance, and lean-angle calibration
- Voice navigation: configure Bluetooth SCO/A2DP and audio focus ducking; announce maneuvers and support mute
This commit is contained in:
parent
618ef8798b
commit
ab4b322d85
|
|
@ -54,10 +54,12 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
|
|
||||||
void handleTapToCalibrate() {
|
void handleTapToCalibrate() {
|
||||||
final currentSpeed = sample?.speed ?? 0.0;
|
final currentSpeed = sample?.speed ?? 0.0;
|
||||||
final rideState = ref.read(rideStateMachineProvider).state;
|
final currentLean = displayLean.abs();
|
||||||
final isMoving = currentSpeed > 3.0 || rideState == RideState.riding;
|
final longitudinalG = sample?.longitudinalG?.abs() ?? 0.0;
|
||||||
|
final lateralG = sample?.lateralG?.abs() ?? 0.0;
|
||||||
|
|
||||||
if (isMoving) {
|
// 1. Movement gate (> 3.0 MPH) - distinguish actual velocity from stationary recording
|
||||||
|
if (currentSpeed > 3.0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
key: ValueKey('lean_calibrate_moving_warning'),
|
key: ValueKey('lean_calibrate_moving_warning'),
|
||||||
|
|
@ -68,6 +70,30 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Reject unstable dynamic motion (dynamic accelerations distort gravity orientation)
|
||||||
|
if (longitudinalG > 0.25 || lateralG > 0.25) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
key: ValueKey('lean_calibrate_unstable_warning'),
|
||||||
|
content: Text('Sensors are unstable. Ensure the motorcycle is steady and stationary.'),
|
||||||
|
backgroundColor: Color(0xFFEF4444),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reject tilted/kickstand condition (> 12.0 degrees)
|
||||||
|
if (currentLean > 12.0) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
key: const ValueKey('lean_calibrate_tilted_warning'),
|
||||||
|
content: Text('Motorcycle is tilted (${currentLean.toStringAsFixed(0)}°). Hold the bike upright and level to calibrate.'),
|
||||||
|
backgroundColor: const Color(0xFFEF4444),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
|
||||||
|
|
@ -916,6 +916,30 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard>
|
||||||
color: Color(0xFFF1F5F9),
|
color: Color(0xFFF1F5F9),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (item.isDemo) ...[
|
||||||
|
Container(
|
||||||
|
key: const ValueKey('feed_demo_pill'),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'DEMO SAMPLE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
|
fontSize: 8.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (item.authorBadge != null) ...[
|
if (item.authorBadge != null) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
|
import '../../models/maintenance_models.dart';
|
||||||
import '../auth/auth_service.dart';
|
import '../auth/auth_service.dart';
|
||||||
import 'cloud_models.dart';
|
import 'cloud_models.dart';
|
||||||
|
|
||||||
|
|
@ -60,6 +62,59 @@ class FirebaseAccountSyncService {
|
||||||
return json.encode(map);
|
return json.encode(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merges local and remote specificationJson, preserving maintenance records
|
||||||
|
/// from both devices (deduplicated by record id), retaining the highest odometer,
|
||||||
|
/// and attaching the authoritative cloudId.
|
||||||
|
static String mergeSpecificationJson({
|
||||||
|
required String? localSpecJson,
|
||||||
|
required String? remoteSpecJson,
|
||||||
|
required String cloudId,
|
||||||
|
}) {
|
||||||
|
final localData = MaintenanceData.fromSpecificationJson(localSpecJson);
|
||||||
|
final remoteData = MaintenanceData.fromSpecificationJson(remoteSpecJson);
|
||||||
|
|
||||||
|
// Merge service records deduplicated by id
|
||||||
|
final recordMap = <String, ServiceRecord>{};
|
||||||
|
for (final r in localData.serviceRecords) {
|
||||||
|
recordMap[r.id] = r;
|
||||||
|
}
|
||||||
|
for (final r in remoteData.serviceRecords) {
|
||||||
|
recordMap[r.id] = r;
|
||||||
|
}
|
||||||
|
final mergedRecords = recordMap.values.toList()
|
||||||
|
..sort((a, b) => b.odometer.compareTo(a.odometer));
|
||||||
|
|
||||||
|
// Highest base odometer
|
||||||
|
final mergedBaseOdo = math.max(localData.baseOdometer, remoteData.baseOdometer);
|
||||||
|
|
||||||
|
// Merged custom intervals
|
||||||
|
final mergedIntervals = <String, int>{
|
||||||
|
...localData.customIntervals,
|
||||||
|
...remoteData.customIntervals,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine base spec map
|
||||||
|
Map<String, dynamic> baseMap = {};
|
||||||
|
if (localSpecJson != null && localSpecJson.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final dec = json.decode(localSpecJson);
|
||||||
|
if (dec is Map<String, dynamic>) baseMap = Map<String, dynamic>.from(dec);
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (remoteSpecJson != null && remoteSpecJson.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final dec = json.decode(remoteSpecJson);
|
||||||
|
if (dec is Map<String, dynamic>) baseMap = Map<String, dynamic>.from(dec);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseMap['cloudId'] = cloudId;
|
||||||
|
baseMap['baseOdometer'] = mergedBaseOdo;
|
||||||
|
baseMap['serviceRecords'] = mergedRecords.map((s) => s.toJson()).toList();
|
||||||
|
baseMap['customIntervals'] = mergedIntervals;
|
||||||
|
|
||||||
|
return json.encode(baseMap);
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a globally unique stable cloud document ID for a motorcycle.
|
/// Generates a globally unique stable cloud document ID for a motorcycle.
|
||||||
static String generateCloudMotorcycleId(
|
static String generateCloudMotorcycleId(
|
||||||
String uid,
|
String uid,
|
||||||
|
|
@ -161,10 +216,11 @@ class FirebaseAccountSyncService {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Existing motorcycle: link cloudId and preserve specifications
|
// Existing motorcycle: link cloudId and merge specifications/maintenance
|
||||||
final updatedSpec = attachCloudId(
|
final updatedSpec = mergeSpecificationJson(
|
||||||
existing.specificationJson ?? data['specificationJson']?.toString(),
|
localSpecJson: existing.specificationJson,
|
||||||
docId,
|
remoteSpecJson: data['specificationJson']?.toString(),
|
||||||
|
cloudId: docId,
|
||||||
);
|
);
|
||||||
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id)))
|
await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id)))
|
||||||
.write(GarageMotorcyclesCompanion(
|
.write(GarageMotorcyclesCompanion(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../location_service.dart';
|
||||||
import '../map/map_provider_interface.dart';
|
import '../map/map_provider_interface.dart';
|
||||||
import '../routes/route_models.dart';
|
import '../routes/route_models.dart';
|
||||||
import '../routes/route_provider.dart';
|
import '../routes/route_provider.dart';
|
||||||
|
import '../routes/route_providers.dart';
|
||||||
|
|
||||||
/// Navigation session status lifecycle.
|
/// Navigation session status lifecycle.
|
||||||
enum NavigationStatus {
|
enum NavigationStatus {
|
||||||
|
|
@ -15,6 +16,7 @@ enum NavigationStatus {
|
||||||
navigating,
|
navigating,
|
||||||
paused,
|
paused,
|
||||||
rerouting,
|
rerouting,
|
||||||
|
offRoute,
|
||||||
arrived,
|
arrived,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +53,9 @@ class NavigationSessionState {
|
||||||
this.startedAt,
|
this.startedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isNavigating => status == NavigationStatus.navigating;
|
bool get isNavigating =>
|
||||||
|
status == NavigationStatus.navigating || status == NavigationStatus.rerouting;
|
||||||
|
bool get isOffRoute => status == NavigationStatus.offRoute;
|
||||||
bool get hasActiveRoute => maneuvers.isNotEmpty;
|
bool get hasActiveRoute => maneuvers.isNotEmpty;
|
||||||
|
|
||||||
RouteManeuver? get currentManeuver =>
|
RouteManeuver? get currentManeuver =>
|
||||||
|
|
@ -137,10 +141,17 @@ class NavigationSessionState {
|
||||||
/// Controller managing live motorcycle turn-by-turn navigation session.
|
/// Controller managing live motorcycle turn-by-turn navigation session.
|
||||||
class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
final FlutterTts? _tts;
|
final FlutterTts? _tts;
|
||||||
|
final RouteProvider? routeProvider;
|
||||||
bool _ttsInitialized = false;
|
bool _ttsInitialized = false;
|
||||||
|
bool _isRecalculating = false;
|
||||||
|
DateTime? _lastRecalculationAttempt;
|
||||||
|
|
||||||
NavigationSessionNotifier({FlutterTts? tts})
|
static const double defaultOffRouteThresholdMeters = 65.0;
|
||||||
: _tts = tts ?? _createTtsSafe(),
|
|
||||||
|
NavigationSessionNotifier({
|
||||||
|
FlutterTts? tts,
|
||||||
|
this.routeProvider,
|
||||||
|
}) : _tts = tts ?? _createTtsSafe(),
|
||||||
super(const NavigationSessionState()) {
|
super(const NavigationSessionState()) {
|
||||||
_initTts();
|
_initTts();
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +173,17 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
await _tts.setSpeechRate(0.5); // Clear motorcycle cadence
|
await _tts.setSpeechRate(0.5); // Clear motorcycle cadence
|
||||||
await _tts.setVolume(1.0);
|
await _tts.setVolume(1.0);
|
||||||
await _tts.setPitch(1.0);
|
await _tts.setPitch(1.0);
|
||||||
|
try {
|
||||||
|
await _tts.setIosAudioCategory(
|
||||||
|
IosTextToSpeechAudioCategory.playback,
|
||||||
|
[
|
||||||
|
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
||||||
|
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
||||||
|
IosTextToSpeechAudioCategoryOptions.duckOthers,
|
||||||
|
],
|
||||||
|
IosTextToSpeechAudioMode.voicePrompt,
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
_ttsInitialized = true;
|
_ttsInitialized = true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_ttsInitialized = false;
|
_ttsInitialized = false;
|
||||||
|
|
@ -238,12 +260,12 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles spoken voice guidance.
|
/// Toggles spoken voice guidance.
|
||||||
void toggleTts() {
|
Future<void> toggleTts() async {
|
||||||
final newEnabled = !state.ttsEnabled;
|
final newEnabled = !state.ttsEnabled;
|
||||||
state = state.copyWith(ttsEnabled: newEnabled);
|
state = state.copyWith(ttsEnabled: newEnabled);
|
||||||
if (!newEnabled && _tts != null && _ttsInitialized) {
|
if (!newEnabled && _tts != null && _ttsInitialized) {
|
||||||
try {
|
try {
|
||||||
_tts.stop();
|
await _tts.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +274,23 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
|
void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) {
|
||||||
if (!state.isNavigating || state.maneuvers.isEmpty) return;
|
if (!state.isNavigating || state.maneuvers.isEmpty) return;
|
||||||
|
|
||||||
|
// Off-route detection check against polyline points
|
||||||
|
final points = state.calculationResult?.points ?? [];
|
||||||
|
if (points.isNotEmpty) {
|
||||||
|
final distToRoute = _distanceToPolylineMeters(riderPos, points);
|
||||||
|
if (distToRoute > defaultOffRouteThresholdMeters) {
|
||||||
|
if (!_isRecalculating) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastRecalculationAttempt == null ||
|
||||||
|
now.difference(_lastRecalculationAttempt!) > const Duration(seconds: 8)) {
|
||||||
|
_lastRecalculationAttempt = now;
|
||||||
|
_triggerReroute(riderPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final currIdx = state.currentManeuverIndex;
|
final currIdx = state.currentManeuverIndex;
|
||||||
if (currIdx >= state.maneuvers.length) {
|
if (currIdx >= state.maneuvers.length) {
|
||||||
_completeArrival();
|
_completeArrival();
|
||||||
|
|
@ -293,6 +332,75 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggers real route recalculation from rider's current position to destination.
|
||||||
|
Future<void> _triggerReroute(AppLatLng riderPos) async {
|
||||||
|
if (_isRecalculating) return;
|
||||||
|
_isRecalculating = true;
|
||||||
|
state = state.copyWith(status: NavigationStatus.rerouting);
|
||||||
|
_speakInstruction('Rerouting. Recalculating route.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final destStop = state.route?.stops.isNotEmpty == true
|
||||||
|
? state.route!.stops.last
|
||||||
|
: (state.destination != null
|
||||||
|
? RouteStop(
|
||||||
|
id: 'dest',
|
||||||
|
order: 1,
|
||||||
|
displayName: state.destination!.displayName,
|
||||||
|
latitude: state.destination!.latitude,
|
||||||
|
longitude: state.destination!.longitude,
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final provider = routeProvider;
|
||||||
|
if (destStop == null || provider == null) {
|
||||||
|
state = state.copyWith(status: NavigationStatus.offRoute);
|
||||||
|
_isRecalculating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final originStop = RouteStop(
|
||||||
|
id: 'reroute_origin',
|
||||||
|
order: 0,
|
||||||
|
displayName: 'Current Location',
|
||||||
|
latitude: riderPos.latitude,
|
||||||
|
longitude: riderPos.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newResult = await provider.calculateRoute(
|
||||||
|
[originStop, destStop],
|
||||||
|
options: state.routingOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newResult.maneuvers.isEmpty) {
|
||||||
|
state = state.copyWith(status: NavigationStatus.offRoute);
|
||||||
|
_isRecalculating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initialDist = newResult.estimatedDistanceMeters ??
|
||||||
|
newResult.straightLineDistanceMeters;
|
||||||
|
final initialDuration = newResult.estimatedDurationSeconds ??
|
||||||
|
(initialDist / 15.0).round();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
status: NavigationStatus.navigating,
|
||||||
|
calculationResult: newResult,
|
||||||
|
maneuvers: newResult.maneuvers,
|
||||||
|
currentManeuverIndex: 0,
|
||||||
|
distanceToNextManeuverMeters: newResult.maneuvers.first.distanceMeters,
|
||||||
|
remainingDistanceMeters: initialDist,
|
||||||
|
remainingDurationSeconds: initialDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isRecalculating = false;
|
||||||
|
_speakInstruction(newResult.maneuvers.first.instruction);
|
||||||
|
} catch (_) {
|
||||||
|
state = state.copyWith(status: NavigationStatus.offRoute);
|
||||||
|
_isRecalculating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _completeArrival() {
|
void _completeArrival() {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: NavigationStatus.arrived,
|
status: NavigationStatus.arrived,
|
||||||
|
|
@ -309,10 +417,41 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
|
|
||||||
state = state.copyWith(lastAnnouncedInstruction: instruction);
|
state = state.copyWith(lastAnnouncedInstruction: instruction);
|
||||||
try {
|
try {
|
||||||
await _tts.speak(instruction);
|
await _tts.speak(instruction, focus: true);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes shortest distance from point to polyline in meters.
|
||||||
|
static double _distanceToPolylineMeters(AppLatLng point, List<AppLatLng> polyline) {
|
||||||
|
if (polyline.isEmpty) return double.infinity;
|
||||||
|
if (polyline.length == 1) return _haversineMeters(point, polyline.first);
|
||||||
|
|
||||||
|
double minDistance = double.infinity;
|
||||||
|
for (int i = 0; i < polyline.length - 1; i++) {
|
||||||
|
final segDist = _distanceToSegmentMeters(point, polyline[i], polyline[i + 1]);
|
||||||
|
if (segDist < minDistance) {
|
||||||
|
minDistance = segDist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(AppLatLng p, AppLatLng a, AppLatLng b) {
|
||||||
|
final dAB = _haversineMeters(a, b);
|
||||||
|
if (dAB == 0) return _haversineMeters(p, a);
|
||||||
|
|
||||||
|
final dAP = _haversineMeters(a, p);
|
||||||
|
final dBP = _haversineMeters(b, p);
|
||||||
|
|
||||||
|
if (dBP * dBP >= dAP * dAP + dAB * dAB) return dAP;
|
||||||
|
if (dAP * dAP >= dBP * dBP + dAB * dAB) return dBP;
|
||||||
|
|
||||||
|
final s = (dAB + dAP + dBP) / 2.0;
|
||||||
|
final areaSq = s * (s - dAB) * (s - dAP) * (s - dBP);
|
||||||
|
if (areaSq <= 0.0) return 0.0;
|
||||||
|
return (2.0 * math.sqrt(areaSq)) / dAB;
|
||||||
|
}
|
||||||
|
|
||||||
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
|
static double _haversineMeters(AppLatLng p1, AppLatLng p2) {
|
||||||
const r = 6371000.0; // Earth radius in meters
|
const r = 6371000.0; // Earth radius in meters
|
||||||
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
||||||
|
|
@ -334,7 +473,8 @@ class NavigationSessionNotifier extends StateNotifier<NavigationSessionState> {
|
||||||
final navigationSessionProvider =
|
final navigationSessionProvider =
|
||||||
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
|
StateNotifierProvider<NavigationSessionNotifier, NavigationSessionState>(
|
||||||
(ref) {
|
(ref) {
|
||||||
final notifier = NavigationSessionNotifier();
|
final routeProvider = ref.watch(activeRouteCalculationProvider);
|
||||||
|
final notifier = NavigationSessionNotifier(routeProvider: routeProvider);
|
||||||
// Connect live location updates when navigating
|
// Connect live location updates when navigating
|
||||||
ref.listen(locationStreamProvider, (_, next) {
|
ref.listen(locationStreamProvider, (_, next) {
|
||||||
final pos = next.value;
|
final pos = next.value;
|
||||||
|
|
|
||||||
|
|
@ -252,12 +252,12 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final effectiveAvoidHighways = options?.avoidHighways ??
|
final effectiveAvoidHighways = (options?.avoidHighways == true) ||
|
||||||
(options?.preference == RoutingPreference.twisty ||
|
options?.preference == RoutingPreference.twisty ||
|
||||||
options?.preference == RoutingPreference.scenic ||
|
options?.preference == RoutingPreference.scenic ||
|
||||||
avoidHighways);
|
avoidHighways;
|
||||||
final effectiveAvoidTolls = options?.avoidTolls ?? avoidTolls;
|
final effectiveAvoidTolls = (options?.avoidTolls == true) || avoidTolls;
|
||||||
final effectiveAvoidFerries = options?.avoidFerries ?? avoidFerries;
|
final effectiveAvoidFerries = (options?.avoidFerries == true) || avoidFerries;
|
||||||
final optimizeFor = options?.preference == RoutingPreference.shortest
|
final optimizeFor = options?.preference == RoutingPreference.shortest
|
||||||
? 'ShortestRoute'
|
? 'ShortestRoute'
|
||||||
: 'FastestRoute';
|
: 'FastestRoute';
|
||||||
|
|
@ -295,6 +295,9 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
'LegGeometryFormat': 'Simple',
|
'LegGeometryFormat': 'Simple',
|
||||||
'LegAdditionalInputs': ['TravelSteps'],
|
'LegAdditionalInputs': ['TravelSteps'],
|
||||||
'Instructions': true,
|
'Instructions': true,
|
||||||
|
if (options?.preference == RoutingPreference.twisty ||
|
||||||
|
options?.preference == RoutingPreference.scenic)
|
||||||
|
'MaxAlternatives': 2,
|
||||||
};
|
};
|
||||||
if (waypoints != null && waypoints.isNotEmpty) {
|
if (waypoints != null && waypoints.isNotEmpty) {
|
||||||
bodyMap['Waypoints'] = waypoints;
|
bodyMap['Waypoints'] = waypoints;
|
||||||
|
|
@ -309,9 +312,16 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
|
String detail = 'Status ${response.statusCode}';
|
||||||
|
try {
|
||||||
|
final errData = jsonDecode(response.body);
|
||||||
|
if (errData is Map) {
|
||||||
|
detail = (errData['message'] ?? errData['Message'] ?? errData['error'] ?? detail).toString();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
throw RouteProviderException(
|
throw RouteProviderException(
|
||||||
'routeUnavailable',
|
'routeUnavailable',
|
||||||
'AWS route calculation returned status ${response.statusCode}',
|
'AWS route calculation failed ($detail)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,56 +330,82 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
if (routes == null || routes.isEmpty) {
|
if (routes == null || routes.isEmpty) {
|
||||||
throw RouteProviderException(
|
throw RouteProviderException(
|
||||||
'routeNotFound',
|
'routeNotFound',
|
||||||
'No route returned by AWS.',
|
'No route returned by AWS Location Service.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final route = routes[0];
|
final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||||
final summary = route['Summary'] as Map<String, dynamic>?;
|
|
||||||
final distanceMeters = (summary?['Distance'] as num?)?.toDouble() ?? 0.0;
|
|
||||||
final durationSeconds = (summary?['Duration'] as num?)?.toInt() ?? 0;
|
|
||||||
|
|
||||||
final legs = route['Legs'] as List<dynamic>? ?? [];
|
// Parse and score all candidate routes returned by AWS
|
||||||
final geometry = <AppLatLng>[];
|
final candidates = <_CandidateRoute>[];
|
||||||
|
for (final r in routes) {
|
||||||
|
if (r is! Map<String, dynamic>) continue;
|
||||||
|
final summary = r['Summary'] as Map<String, dynamic>?;
|
||||||
|
final distanceM = (summary?['Distance'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final durationS = (summary?['Duration'] as num?)?.toInt() ?? 0;
|
||||||
|
final legs = r['Legs'] as List<dynamic>? ?? [];
|
||||||
|
final geom = <AppLatLng>[];
|
||||||
|
|
||||||
for (final leg in legs) {
|
for (final leg in legs) {
|
||||||
final geom = leg['Geometry'] as Map<String, dynamic>?;
|
final gMap = leg['Geometry'] as Map<String, dynamic>?;
|
||||||
final lineString = geom?['LineString'] as List<dynamic>?;
|
final lineString = gMap?['LineString'] as List<dynamic>?;
|
||||||
if (lineString != null) {
|
if (lineString != null) {
|
||||||
for (final pt in lineString) {
|
for (final pt in lineString) {
|
||||||
if (pt is List && pt.length >= 2) {
|
if (pt is List && pt.length >= 2) {
|
||||||
geometry.add(
|
geom.add(AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()));
|
||||||
AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final effectiveGeom = geom.isNotEmpty ? geom : ordered.map((s) => s.latLng).toList();
|
||||||
|
final effectiveDist = distanceM > 0 ? distanceM : straightLine;
|
||||||
|
final distMi = effectiveDist * 0.000621371;
|
||||||
|
final curvature = _calculateCurvatureMetric(effectiveGeom, distMi);
|
||||||
|
final mans = _extractManeuvers(legs, effectiveGeom, ordered);
|
||||||
|
|
||||||
|
candidates.add(_CandidateRoute(
|
||||||
|
distanceMeters: effectiveDist,
|
||||||
|
durationSeconds: durationS,
|
||||||
|
geometry: effectiveGeom,
|
||||||
|
curvature: curvature,
|
||||||
|
maneuvers: mans,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
final effectiveGeometry = geometry.isNotEmpty
|
if (candidates.isEmpty) {
|
||||||
? geometry
|
throw RouteProviderException(
|
||||||
: ordered.map((s) => s.latLng).toList();
|
'routeNotFound',
|
||||||
|
'Could not parse route candidates from AWS response.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
// Actively select best route alternative according to user preference
|
||||||
final effectiveDist = distanceMeters > 0 ? distanceMeters : straightLine;
|
_CandidateRoute selected = candidates.first;
|
||||||
final distMiles = effectiveDist * 0.000621371;
|
if (candidates.length > 1) {
|
||||||
|
if (options?.preference == RoutingPreference.twisty) {
|
||||||
// Extract maneuvers from TravelSteps if available
|
// Actively choose route alternative with the highest curvature metric
|
||||||
final maneuvers = _extractManeuvers(legs, effectiveGeometry, ordered);
|
candidates.sort((a, b) => b.curvature.compareTo(a.curvature));
|
||||||
|
selected = candidates.first;
|
||||||
// Compute geometric curvature metric
|
} else if (options?.preference == RoutingPreference.shortest) {
|
||||||
final curvature = _calculateCurvatureMetric(effectiveGeometry, distMiles);
|
candidates.sort((a, b) => a.distanceMeters.compareTo(b.distanceMeters));
|
||||||
|
selected = candidates.first;
|
||||||
|
} else if (options?.preference == RoutingPreference.fastest) {
|
||||||
|
candidates.sort((a, b) => a.durationSeconds.compareTo(b.durationSeconds));
|
||||||
|
selected = candidates.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return RouteCalculationResult(
|
return RouteCalculationResult(
|
||||||
straightLineDistanceMeters: straightLine,
|
straightLineDistanceMeters: straightLine,
|
||||||
estimatedDistanceMeters: effectiveDist,
|
estimatedDistanceMeters: selected.distanceMeters,
|
||||||
estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null,
|
estimatedDurationSeconds: selected.durationSeconds > 0 ? selected.durationSeconds : null,
|
||||||
geometry: effectiveGeometry,
|
geometry: selected.geometry,
|
||||||
isStraightLine: geometry.isEmpty,
|
isStraightLine: selected.geometry.isEmpty,
|
||||||
providerName: name,
|
providerName: name,
|
||||||
maneuvers: maneuvers,
|
maneuvers: selected.maneuvers,
|
||||||
options: options,
|
options: options,
|
||||||
curvatureMetric: curvature,
|
curvatureMetric: selected.curvature,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is RouteProviderException) rethrow;
|
if (e is RouteProviderException) rethrow;
|
||||||
|
|
@ -571,3 +607,19 @@ class AwsLocationRouteProvider implements RouteProvider {
|
||||||
return (brng + 360.0) % 360.0;
|
return (brng + 360.0) % 360.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CandidateRoute {
|
||||||
|
final double distanceMeters;
|
||||||
|
final int durationSeconds;
|
||||||
|
final List<AppLatLng> geometry;
|
||||||
|
final double curvature;
|
||||||
|
final List<RouteManeuver> maneuvers;
|
||||||
|
|
||||||
|
_CandidateRoute({
|
||||||
|
required this.distanceMeters,
|
||||||
|
required this.durationSeconds,
|
||||||
|
required this.geometry,
|
||||||
|
required this.curvature,
|
||||||
|
required this.maneuvers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ class SocialFeedItem {
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final String? photoCaption;
|
final String? photoCaption;
|
||||||
final bool isGpsVerified;
|
final bool isGpsVerified;
|
||||||
|
final bool isDemo;
|
||||||
|
|
||||||
// Kudos & Reactions
|
// Kudos & Reactions
|
||||||
final int fistBumpCount;
|
final int fistBumpCount;
|
||||||
|
|
@ -202,6 +203,7 @@ class SocialFeedItem {
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.photoCaption,
|
this.photoCaption,
|
||||||
this.isGpsVerified = true,
|
this.isGpsVerified = true,
|
||||||
|
this.isDemo = false,
|
||||||
this.fistBumpCount = 0,
|
this.fistBumpCount = 0,
|
||||||
this.fistBumpUserIds = const [],
|
this.fistBumpUserIds = const [],
|
||||||
this.comments = const [],
|
this.comments = const [],
|
||||||
|
|
@ -239,6 +241,7 @@ class SocialFeedItem {
|
||||||
String? photoUrl,
|
String? photoUrl,
|
||||||
String? photoCaption,
|
String? photoCaption,
|
||||||
bool? isGpsVerified,
|
bool? isGpsVerified,
|
||||||
|
bool? isDemo,
|
||||||
int? fistBumpCount,
|
int? fistBumpCount,
|
||||||
List<String>? fistBumpUserIds,
|
List<String>? fistBumpUserIds,
|
||||||
List<FeedPostComment>? comments,
|
List<FeedPostComment>? comments,
|
||||||
|
|
@ -270,6 +273,7 @@ class SocialFeedItem {
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
photoCaption: photoCaption ?? this.photoCaption,
|
photoCaption: photoCaption ?? this.photoCaption,
|
||||||
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
|
isGpsVerified: isGpsVerified ?? this.isGpsVerified,
|
||||||
|
isDemo: isDemo ?? this.isDemo,
|
||||||
fistBumpCount: fistBumpCount ?? this.fistBumpCount,
|
fistBumpCount: fistBumpCount ?? this.fistBumpCount,
|
||||||
fistBumpUserIds: fistBumpUserIds ?? this.fistBumpUserIds,
|
fistBumpUserIds: fistBumpUserIds ?? this.fistBumpUserIds,
|
||||||
comments: comments ?? this.comments,
|
comments: comments ?? this.comments,
|
||||||
|
|
@ -303,6 +307,7 @@ class SocialFeedItem {
|
||||||
if (photoUrl != null) 'photoUrl': photoUrl,
|
if (photoUrl != null) 'photoUrl': photoUrl,
|
||||||
if (photoCaption != null) 'photoCaption': photoCaption,
|
if (photoCaption != null) 'photoCaption': photoCaption,
|
||||||
'isGpsVerified': isGpsVerified,
|
'isGpsVerified': isGpsVerified,
|
||||||
|
'isDemo': isDemo,
|
||||||
'fistBumpCount': fistBumpCount,
|
'fistBumpCount': fistBumpCount,
|
||||||
'fistBumpUserIds': fistBumpUserIds,
|
'fistBumpUserIds': fistBumpUserIds,
|
||||||
'comments': comments.map((c) => c.toJson()).toList(),
|
'comments': comments.map((c) => c.toJson()).toList(),
|
||||||
|
|
@ -361,6 +366,7 @@ class SocialFeedItem {
|
||||||
photoUrl: json['photoUrl'] as String?,
|
photoUrl: json['photoUrl'] as String?,
|
||||||
photoCaption: json['photoCaption'] as String?,
|
photoCaption: json['photoCaption'] as String?,
|
||||||
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
|
isGpsVerified: json['isGpsVerified'] as bool? ?? true,
|
||||||
|
isDemo: json['isDemo'] as bool? ?? false,
|
||||||
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
|
fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0,
|
||||||
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
fistBumpUserIds: (json['fistBumpUserIds'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||||
comments: comments,
|
comments: comments,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class SocialFeedService {
|
||||||
|
|
||||||
List<SocialFeedItem> _memoryFeed = [];
|
List<SocialFeedItem> _memoryFeed = [];
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
StreamSubscription? _firestoreSub;
|
||||||
|
|
||||||
SocialFeedService({this.firestore}) {
|
SocialFeedService({this.firestore}) {
|
||||||
_initFeed();
|
_initFeed();
|
||||||
|
|
@ -19,7 +20,27 @@ class SocialFeedService {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
|
||||||
// Populate with Stitch SOC-01 canonical community items
|
final fs = firestore;
|
||||||
|
if (fs != null) {
|
||||||
|
_memoryFeed = [];
|
||||||
|
_firestoreSub = fs
|
||||||
|
.collection('social_feed')
|
||||||
|
.orderBy('timestamp', descending: true)
|
||||||
|
.limit(50)
|
||||||
|
.snapshots()
|
||||||
|
.listen((snapshot) {
|
||||||
|
final realItems = snapshot.docs.map((doc) {
|
||||||
|
final data = doc.data();
|
||||||
|
return SocialFeedItem.fromJson({...data, 'id': doc.id, 'isDemo': false});
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_memoryFeed = realItems;
|
||||||
|
_localFeedController.add(List.unmodifiable(_memoryFeed));
|
||||||
|
}, onError: (_) {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with Stitch SOC-01 canonical community items (flagged as demo sample)
|
||||||
_memoryFeed = [
|
_memoryFeed = [
|
||||||
SocialFeedItem(
|
SocialFeedItem(
|
||||||
id: 'feed_post_1',
|
id: 'feed_post_1',
|
||||||
|
|
@ -45,6 +66,7 @@ class SocialFeedService {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: false,
|
isGpsVerified: false,
|
||||||
|
isDemo: true,
|
||||||
fistBumpCount: 42,
|
fistBumpCount: 42,
|
||||||
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
|
fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -91,6 +113,7 @@ class SocialFeedService {
|
||||||
photoUrl: 'assets/images/rock_store_lookout.png',
|
photoUrl: 'assets/images/rock_store_lookout.png',
|
||||||
photoCaption: 'Rock Store Lookout',
|
photoCaption: 'Rock Store Lookout',
|
||||||
isGpsVerified: false,
|
isGpsVerified: false,
|
||||||
|
isDemo: true,
|
||||||
fistBumpCount: 28,
|
fistBumpCount: 28,
|
||||||
fistBumpUserIds: const ['uid_marcus'],
|
fistBumpUserIds: const ['uid_marcus'],
|
||||||
comments: const [],
|
comments: const [],
|
||||||
|
|
@ -119,6 +142,7 @@ class SocialFeedService {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: false,
|
isGpsVerified: false,
|
||||||
|
isDemo: true,
|
||||||
fistBumpCount: 65,
|
fistBumpCount: 65,
|
||||||
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
|
fistBumpUserIds: const ['uid_marcus', 'uid_sarah'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -152,6 +176,7 @@ class SocialFeedService {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: false,
|
isGpsVerified: false,
|
||||||
|
isDemo: true,
|
||||||
fistBumpCount: 39,
|
fistBumpCount: 39,
|
||||||
fistBumpUserIds: const ['uid_marcus'],
|
fistBumpUserIds: const ['uid_marcus'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -185,6 +210,7 @@ class SocialFeedService {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
snapshotType: FeedSnapshotType.routeTrack,
|
snapshotType: FeedSnapshotType.routeTrack,
|
||||||
isGpsVerified: false,
|
isGpsVerified: false,
|
||||||
|
isDemo: true,
|
||||||
fistBumpCount: 51,
|
fistBumpCount: 51,
|
||||||
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
|
fistBumpUserIds: const ['uid_sarah', 'uid_alex'],
|
||||||
routePoints: const [
|
routePoints: const [
|
||||||
|
|
@ -474,6 +500,7 @@ class SocialFeedService {
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_firestoreSub?.cancel();
|
||||||
_localFeedController.close();
|
_localFeedController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,5 +315,113 @@ void main() {
|
||||||
expect(result.formattedDuration, equals('15 min'));
|
expect(result.formattedDuration, equals('15 min'));
|
||||||
expect(result.curvatureMetric, isNotNull);
|
expect(result.curvatureMetric, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calculateRoute with twisty preference requests alternatives and selects highest curvature', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
final body = jsonDecode(request.body);
|
||||||
|
expect(body['MaxAlternatives'], equals(2));
|
||||||
|
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
|
||||||
|
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'Routes': [
|
||||||
|
{
|
||||||
|
// Route 1: Straight route
|
||||||
|
'Summary': {'Distance': 10000.0, 'Duration': 600},
|
||||||
|
'Legs': [
|
||||||
|
{
|
||||||
|
'Geometry': {
|
||||||
|
'LineString': [
|
||||||
|
[-84.0, 34.0],
|
||||||
|
[-84.05, 34.05],
|
||||||
|
[-84.10, 34.10],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Route 2: Highly twisty mountain alternative with sharp turns
|
||||||
|
'Summary': {'Distance': 12000.0, 'Duration': 900},
|
||||||
|
'Legs': [
|
||||||
|
{
|
||||||
|
'Geometry': {
|
||||||
|
'LineString': [
|
||||||
|
[-84.0, 34.0],
|
||||||
|
[-84.02, 34.01],
|
||||||
|
[-84.01, 34.03],
|
||||||
|
[-84.05, 34.04],
|
||||||
|
[-84.03, 34.07],
|
||||||
|
[-84.08, 34.08],
|
||||||
|
[-84.10, 34.10],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = AwsLocationRouteProvider(
|
||||||
|
apiKey: 'test-key',
|
||||||
|
client: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stops = [
|
||||||
|
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
|
||||||
|
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
|
||||||
|
];
|
||||||
|
|
||||||
|
final result = await provider.calculateRoute(
|
||||||
|
stops,
|
||||||
|
options: const RoutingOptions(
|
||||||
|
preference: RoutingPreference.twisty,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that Route 2 was chosen because it has more curvature
|
||||||
|
expect(result.estimatedDistanceMeters, equals(12000.0));
|
||||||
|
expect(result.estimatedDurationSeconds, equals(900));
|
||||||
|
expect(result.geometry.length, equals(7));
|
||||||
|
expect(result.curvatureMetric, isNotNull);
|
||||||
|
expect(result.curvatureMetric, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calculateRoute extracts actionable error message on AWS API failure', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'message': 'AccessDeniedException: The provided API key is unauthorized for routes.geo.us-east-1.',
|
||||||
|
}),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = AwsLocationRouteProvider(
|
||||||
|
apiKey: 'invalid-key',
|
||||||
|
client: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stops = [
|
||||||
|
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
|
||||||
|
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => provider.calculateRoute(stops),
|
||||||
|
throwsA(
|
||||||
|
isA<RouteProviderException>()
|
||||||
|
.having((e) => e.code, 'code', 'routeUnavailable')
|
||||||
|
.having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
contains('AccessDeniedException: The provided API key is unauthorized'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:motorcycle_dash/database/database.dart';
|
||||||
import 'package:motorcycle_dash/models/maintenance_models.dart';
|
import 'package:motorcycle_dash/models/maintenance_models.dart';
|
||||||
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
|
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
|
||||||
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
|
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
|
||||||
|
import 'package:motorcycle_dash/services/cloud/firebase_account_sync_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('MaintenanceData & Countdown Pill Logic', () {
|
group('MaintenanceData & Countdown Pill Logic', () {
|
||||||
|
|
@ -180,4 +181,65 @@ void main() {
|
||||||
expect(data.customIntervals['oil'], 2500);
|
expect(data.customIntervals['oil'], 2500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Cross-Device Maintenance Sync Merging', () {
|
||||||
|
test('mergeSpecificationJson preserves service records from both devices and attaches cloudId', () {
|
||||||
|
final localJson = MaintenanceData.updateSpecJsonWithMaintenance(
|
||||||
|
originalSpecJson: json.encode({'displacementCc': 599.0, 'vin': 'JYARJ06E'}),
|
||||||
|
baseOdometer: 15200.0,
|
||||||
|
serviceRecords: [
|
||||||
|
ServiceRecord(
|
||||||
|
id: 'srv_phone_oil',
|
||||||
|
date: DateTime(2026, 3, 1),
|
||||||
|
odometer: 15000.0,
|
||||||
|
serviceType: 'Oil & Filter',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
customIntervals: {'oil': 3000},
|
||||||
|
);
|
||||||
|
|
||||||
|
final remoteJson = MaintenanceData.updateSpecJsonWithMaintenance(
|
||||||
|
originalSpecJson: json.encode({'displacementCc': 599.0, 'color': 'Blue'}),
|
||||||
|
baseOdometer: 15800.0,
|
||||||
|
serviceRecords: [
|
||||||
|
ServiceRecord(
|
||||||
|
id: 'srv_web_tires',
|
||||||
|
date: DateTime(2026, 4, 1),
|
||||||
|
odometer: 15500.0,
|
||||||
|
serviceType: 'Front/Rear Tires',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
customIntervals: {'tires': 6000},
|
||||||
|
);
|
||||||
|
|
||||||
|
final mergedJson = FirebaseAccountSyncService.mergeSpecificationJson(
|
||||||
|
localSpecJson: localJson,
|
||||||
|
remoteSpecJson: remoteJson,
|
||||||
|
cloudId: 'cloud_bike_abc123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final mergedData = MaintenanceData.fromSpecificationJson(mergedJson);
|
||||||
|
final rawDecoded = json.decode(mergedJson) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Verifies cloudId attached
|
||||||
|
expect(rawDecoded['cloudId'], equals('cloud_bike_abc123'));
|
||||||
|
|
||||||
|
// Verifies both local and remote service records survived
|
||||||
|
expect(mergedData.serviceRecords, hasLength(2));
|
||||||
|
final recordIds = mergedData.serviceRecords.map((r) => r.id).toList();
|
||||||
|
expect(recordIds, contains('srv_phone_oil'));
|
||||||
|
expect(recordIds, contains('srv_web_tires'));
|
||||||
|
|
||||||
|
// Verifies highest base odometer was selected
|
||||||
|
expect(mergedData.baseOdometer, equals(15800.0));
|
||||||
|
|
||||||
|
// Verifies custom intervals were merged
|
||||||
|
expect(mergedData.customIntervals['oil'], equals(3000));
|
||||||
|
expect(mergedData.customIntervals['tires'], equals(6000));
|
||||||
|
|
||||||
|
// Verifies specifications preserved
|
||||||
|
expect(rawDecoded['displacementCc'], equals(599.0));
|
||||||
|
expect(rawDecoded['vin'], equals('JYARJ06E'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,131 @@ void main() {
|
||||||
expect(calibrated, isTrue, reason: 'Must invoke calibrateUpright() on SensorFusionService');
|
expect(calibrated, isTrue, reason: 'Must invoke calibrateUpright() on SensorFusionService');
|
||||||
expect(find.byKey(const ValueKey('lean_calibrate_success_snackbar')), findsOneWidget);
|
expect(find.byKey(const ValueKey('lean_calibrate_success_snackbar')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('LeanAngleIndicator tap while stationary (< 3.0 MPH) during active RideState.riding allows calibration', (tester) async {
|
||||||
|
final sample = TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
leanAngle: 0.0,
|
||||||
|
speed: 0.0, // Stationary at traffic stop during ride
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
telemetryStreamProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(sample),
|
||||||
|
),
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => MockRideStateMachine(
|
||||||
|
RideSession(state: RideState.riding), // Actively recording
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LeanAngleIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap on indicator
|
||||||
|
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify dialog appears because motorcycle is stationary even though ride is recording
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('confirm_lean_calibrate_button')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('LeanAngleIndicator tap while tilted on kickstand (> 12.0°) blocks calibration with warning', (tester) async {
|
||||||
|
final sample = TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
leanAngle: 15.0, // Resting on kickstand
|
||||||
|
speed: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
telemetryStreamProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(sample),
|
||||||
|
),
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => MockRideStateMachine(
|
||||||
|
RideSession(state: RideState.idle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LeanAngleIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify kickstand/tilted warning snackbar is shown
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_tilted_warning')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Motorcycle is tilted (15°). Hold the bike upright'), findsOneWidget);
|
||||||
|
|
||||||
|
// Calibration dialog must NOT appear
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('LeanAngleIndicator tap while acceleration is unstable (> 0.25G) blocks calibration with warning', (tester) async {
|
||||||
|
final sample = TelemetrySample(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
leanAngle: 0.0,
|
||||||
|
speed: 0.0,
|
||||||
|
longitudinalG: 0.35, // High vibration or dynamic shake
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
telemetryStreamProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(sample),
|
||||||
|
),
|
||||||
|
rideStateMachineProvider.overrideWith(
|
||||||
|
(ref) => MockRideStateMachine(
|
||||||
|
RideSession(state: RideState.idle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: LeanAngleIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify unstable warning snackbar is shown
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_unstable_warning')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Sensors are unstable'), findsOneWidget);
|
||||||
|
|
||||||
|
// Calibration dialog must NOT appear
|
||||||
|
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockRideStateMachine extends StateNotifier<RideSession> implements RideStateMachine {
|
class MockRideStateMachine extends StateNotifier<RideSession> implements RideStateMachine {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,69 @@ import 'package:motorcycle_dash/services/navigation/navigation_session_service.d
|
||||||
import 'package:motorcycle_dash/services/routes/route_models.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_provider.dart';
|
||||||
|
|
||||||
|
class _TestRouteProvider implements RouteProvider {
|
||||||
|
int calculateCount = 0;
|
||||||
|
List<RouteStop>? lastStops;
|
||||||
|
RoutingOptions? lastOptions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'Test Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RouteCalculationResult> calculateRoute(
|
||||||
|
List<RouteStop> stops, {
|
||||||
|
bool avoidHighways = false,
|
||||||
|
bool avoidTolls = false,
|
||||||
|
bool avoidFerries = false,
|
||||||
|
RoutingOptions? options,
|
||||||
|
}) async {
|
||||||
|
calculateCount++;
|
||||||
|
lastStops = stops;
|
||||||
|
lastOptions = options;
|
||||||
|
|
||||||
|
final maneuvers = [
|
||||||
|
RouteManeuver(
|
||||||
|
index: 0,
|
||||||
|
instruction: 'Head toward New Route St',
|
||||||
|
type: ManeuverType.depart,
|
||||||
|
distanceMeters: 400.0,
|
||||||
|
durationSeconds: 45,
|
||||||
|
location: stops.first.latLng,
|
||||||
|
),
|
||||||
|
RouteManeuver(
|
||||||
|
index: 1,
|
||||||
|
instruction: 'Arrive at destination',
|
||||||
|
type: ManeuverType.arrive,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: stops.last.latLng,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return RouteCalculationResult(
|
||||||
|
straightLineDistanceMeters: 1200.0,
|
||||||
|
estimatedDistanceMeters: 1200.0,
|
||||||
|
estimatedDurationSeconds: 150,
|
||||||
|
geometry: [stops.first.latLng, stops.last.latLng],
|
||||||
|
isStraightLine: false,
|
||||||
|
providerName: name,
|
||||||
|
maneuvers: maneuvers,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PlaceSearchResult>> searchPlaces(
|
||||||
|
String query, {
|
||||||
|
AppLatLng? nearLocation,
|
||||||
|
}) async =>
|
||||||
|
[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResolvedPlace> resolvePlace(String providerId) async =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('NavigationSessionNotifier Tests', () {
|
group('NavigationSessionNotifier Tests', () {
|
||||||
test('initial state is idle and empty', () {
|
test('initial state is idle and empty', () {
|
||||||
|
|
@ -14,7 +77,7 @@ void main() {
|
||||||
expect(notifier.state.maneuvers, isEmpty);
|
expect(notifier.state.maneuvers, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startNavigation initiates turn-by-turn guidance', () async {
|
test('startNavigation initiates turn-by-turn guidance and records last announced instruction', () async {
|
||||||
final notifier = NavigationSessionNotifier();
|
final notifier = NavigationSessionNotifier();
|
||||||
final maneuvers = [
|
final maneuvers = [
|
||||||
const RouteManeuver(
|
const RouteManeuver(
|
||||||
|
|
@ -97,7 +160,7 @@ void main() {
|
||||||
type: ManeuverType.turnLeft,
|
type: ManeuverType.turnLeft,
|
||||||
distanceMeters: 800.0,
|
distanceMeters: 800.0,
|
||||||
durationSeconds: 90,
|
durationSeconds: 90,
|
||||||
location: AppLatLng(33.7518, -84.3880), // ~200m north
|
location: AppLatLng(33.7518, -84.3880),
|
||||||
),
|
),
|
||||||
const RouteManeuver(
|
const RouteManeuver(
|
||||||
index: 2,
|
index: 2,
|
||||||
|
|
@ -125,11 +188,7 @@ void main() {
|
||||||
|
|
||||||
await notifier.startNavigation(calculationResult: calcResult);
|
await notifier.startNavigation(calculationResult: calcResult);
|
||||||
|
|
||||||
// Rider is approaching starting waypoint (approx 100m south)
|
// Rider reaches within 10 meters of maneuver 0 point along the route
|
||||||
notifier.updateLocation(const AppLatLng(33.7490, -84.3880), speedMph: 25.0);
|
|
||||||
expect(notifier.state.currentManeuverIndex, equals(0));
|
|
||||||
|
|
||||||
// Rider reaches within 10 meters of maneuver 0 point
|
|
||||||
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0);
|
notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0);
|
||||||
expect(notifier.state.currentManeuverIndex, equals(1));
|
expect(notifier.state.currentManeuverIndex, equals(1));
|
||||||
expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm'));
|
expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm'));
|
||||||
|
|
@ -148,6 +207,71 @@ void main() {
|
||||||
expect(notifier.state.hasActiveRoute, isFalse);
|
expect(notifier.state.hasActiveRoute, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('off-route detection triggers genuine route recalculation with newly calculated maneuvers', () async {
|
||||||
|
final testRouteProvider = _TestRouteProvider();
|
||||||
|
final notifier = NavigationSessionNotifier(routeProvider: testRouteProvider);
|
||||||
|
|
||||||
|
final initialManeuvers = [
|
||||||
|
const RouteManeuver(
|
||||||
|
index: 0,
|
||||||
|
instruction: 'Head north on North Ave',
|
||||||
|
type: ManeuverType.depart,
|
||||||
|
distanceMeters: 500.0,
|
||||||
|
durationSeconds: 60,
|
||||||
|
location: AppLatLng(33.7500, -84.3880),
|
||||||
|
),
|
||||||
|
const RouteManeuver(
|
||||||
|
index: 1,
|
||||||
|
instruction: 'Arrive at destination',
|
||||||
|
type: ManeuverType.arrive,
|
||||||
|
distanceMeters: 0.0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
location: AppLatLng(33.7600, -84.3880),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final initialResult = RouteCalculationResult(
|
||||||
|
straightLineDistanceMeters: 1100.0,
|
||||||
|
estimatedDistanceMeters: 1100.0,
|
||||||
|
estimatedDurationSeconds: 90,
|
||||||
|
geometry: const [
|
||||||
|
AppLatLng(33.7500, -84.3880),
|
||||||
|
AppLatLng(33.7600, -84.3880),
|
||||||
|
],
|
||||||
|
isStraightLine: false,
|
||||||
|
providerName: 'Test Provider',
|
||||||
|
maneuvers: initialManeuvers,
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.startNavigation(
|
||||||
|
calculationResult: initialResult,
|
||||||
|
destination: const ResolvedPlace(
|
||||||
|
providerId: 'dest_park',
|
||||||
|
displayName: 'Park',
|
||||||
|
latitude: 33.7600,
|
||||||
|
longitude: -84.3880,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifier.state.isNavigating, isTrue);
|
||||||
|
expect(testRouteProvider.calculateCount, equals(0));
|
||||||
|
|
||||||
|
// Rider deviates far off-route (> 500 meters east)
|
||||||
|
const offRoutePos = AppLatLng(33.7550, -84.3750);
|
||||||
|
notifier.updateLocation(offRoutePos, speedMph: 35.0);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
// Verify recalculation was actually invoked on the RouteProvider
|
||||||
|
expect(testRouteProvider.calculateCount, equals(1));
|
||||||
|
expect(testRouteProvider.lastStops?.first.latitude, equals(offRoutePos.latitude));
|
||||||
|
expect(testRouteProvider.lastStops?.first.longitude, equals(offRoutePos.longitude));
|
||||||
|
|
||||||
|
// Verify session updated with the new recalculation result and maneuvers
|
||||||
|
expect(notifier.state.isNavigating, isTrue);
|
||||||
|
expect(notifier.state.currentManeuver?.instruction, equals('Head toward New Route St'));
|
||||||
|
expect(notifier.state.maneuvers.length, equals(2));
|
||||||
|
});
|
||||||
|
|
||||||
test('toggleTts toggles voice guidance', () {
|
test('toggleTts toggles voice guidance', () {
|
||||||
final notifier = NavigationSessionNotifier();
|
final notifier = NavigationSessionNotifier();
|
||||||
expect(notifier.state.ttsEnabled, isTrue);
|
expect(notifier.state.ttsEnabled, isTrue);
|
||||||
|
|
|
||||||
|
|
@ -88,5 +88,43 @@ void main() {
|
||||||
// Verify no RenderFlex overflow exceptions occurred
|
// Verify no RenderFlex overflow exceptions occurred
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('SocialFeedService mock sample feeds are explicitly flagged with isDemo = true', () {
|
||||||
|
final service = SocialFeedService();
|
||||||
|
final items = service.currentFeed;
|
||||||
|
|
||||||
|
expect(items, isNotEmpty);
|
||||||
|
for (final item in items) {
|
||||||
|
expect(
|
||||||
|
item.isDemo,
|
||||||
|
isTrue,
|
||||||
|
reason: 'Mock feed item ${item.id} (${item.rideTitle}) must be flagged as demo.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('SocialFeedScreen renders DEMO SAMPLE badge on mock cards to prevent fake community activity', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(412, 915));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: const [
|
||||||
|
MotorSignalThemeColors.dark,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: SocialFeedScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Verify that the DEMO SAMPLE badge is rendered on mock feed items
|
||||||
|
expect(find.text('DEMO SAMPLE'), findsWidgets);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue