From ab4b322d8568e247314a75cfc4f2eedfa3d58625 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 10 Oct 2026 00:05:21 -0400 Subject: [PATCH] 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 --- .../widgets/lean_angle_indicator.dart | 32 +++- .../social/feed/social_feed_screen.dart | 24 +++ .../cloud/firebase_account_sync_service.dart | 64 +++++++- .../navigation_session_service.dart | 154 +++++++++++++++++- .../routes/aws_location_route_provider.dart | 134 ++++++++++----- lib/services/social/social_feed_models.dart | 6 + lib/services/social/social_feed_service.dart | 29 +++- test/aws_location_route_provider_test.dart | 108 ++++++++++++ test/garage_maintenance_test.dart | 62 +++++++ test/lean_angle_tap_calibrate_test.dart | 125 ++++++++++++++ test/navigation_session_service_test.dart | 138 +++++++++++++++- test/social_hybrid_ui_test.dart | 38 +++++ 12 files changed, 851 insertions(+), 63 deletions(-) diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index a53d591..c2d618b 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -54,10 +54,12 @@ class LeanAngleIndicator extends ConsumerWidget { void handleTapToCalibrate() { final currentSpeed = sample?.speed ?? 0.0; - final rideState = ref.read(rideStateMachineProvider).state; - final isMoving = currentSpeed > 3.0 || rideState == RideState.riding; + final currentLean = displayLean.abs(); + 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( const SnackBar( key: ValueKey('lean_calibrate_moving_warning'), @@ -68,6 +70,30 @@ class LeanAngleIndicator extends ConsumerWidget { 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( context: context, builder: (ctx) => AlertDialog( diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index 23ff3ec..a8d05da 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -916,6 +916,30 @@ class _SocialFeedCardState extends ConsumerState<_SocialFeedCard> 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) ...[ Container( padding: const EdgeInsets.symmetric( diff --git a/lib/services/cloud/firebase_account_sync_service.dart b/lib/services/cloud/firebase_account_sync_service.dart index eab1b1b..c9f68a3 100644 --- a/lib/services/cloud/firebase_account_sync_service.dart +++ b/lib/services/cloud/firebase_account_sync_service.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:drift/drift.dart'; import 'package:firebase_core/firebase_core.dart'; import '../../database/database.dart'; +import '../../models/maintenance_models.dart'; import '../auth/auth_service.dart'; import 'cloud_models.dart'; @@ -60,6 +62,59 @@ class FirebaseAccountSyncService { 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 = {}; + 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 = { + ...localData.customIntervals, + ...remoteData.customIntervals, + }; + + // Combine base spec map + Map baseMap = {}; + if (localSpecJson != null && localSpecJson.isNotEmpty) { + try { + final dec = json.decode(localSpecJson); + if (dec is Map) baseMap = Map.from(dec); + } catch (_) {} + } else if (remoteSpecJson != null && remoteSpecJson.isNotEmpty) { + try { + final dec = json.decode(remoteSpecJson); + if (dec is Map) baseMap = Map.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. static String generateCloudMotorcycleId( String uid, @@ -161,10 +216,11 @@ class FirebaseAccountSyncService { ), ); } else { - // Existing motorcycle: link cloudId and preserve specifications - final updatedSpec = attachCloudId( - existing.specificationJson ?? data['specificationJson']?.toString(), - docId, + // Existing motorcycle: link cloudId and merge specifications/maintenance + final updatedSpec = mergeSpecificationJson( + localSpecJson: existing.specificationJson, + remoteSpecJson: data['specificationJson']?.toString(), + cloudId: docId, ); await (db.update(db.garageMotorcycles)..where((t) => t.id.equals(existing!.id))) .write(GarageMotorcyclesCompanion( diff --git a/lib/services/navigation/navigation_session_service.dart b/lib/services/navigation/navigation_session_service.dart index 0d04d6e..0cc849f 100644 --- a/lib/services/navigation/navigation_session_service.dart +++ b/lib/services/navigation/navigation_session_service.dart @@ -8,6 +8,7 @@ import '../location_service.dart'; import '../map/map_provider_interface.dart'; import '../routes/route_models.dart'; import '../routes/route_provider.dart'; +import '../routes/route_providers.dart'; /// Navigation session status lifecycle. enum NavigationStatus { @@ -15,6 +16,7 @@ enum NavigationStatus { navigating, paused, rerouting, + offRoute, arrived, } @@ -51,7 +53,9 @@ class NavigationSessionState { 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; RouteManeuver? get currentManeuver => @@ -137,10 +141,17 @@ class NavigationSessionState { /// Controller managing live motorcycle turn-by-turn navigation session. class NavigationSessionNotifier extends StateNotifier { final FlutterTts? _tts; + final RouteProvider? routeProvider; bool _ttsInitialized = false; + bool _isRecalculating = false; + DateTime? _lastRecalculationAttempt; - NavigationSessionNotifier({FlutterTts? tts}) - : _tts = tts ?? _createTtsSafe(), + static const double defaultOffRouteThresholdMeters = 65.0; + + NavigationSessionNotifier({ + FlutterTts? tts, + this.routeProvider, + }) : _tts = tts ?? _createTtsSafe(), super(const NavigationSessionState()) { _initTts(); } @@ -162,6 +173,17 @@ class NavigationSessionNotifier extends StateNotifier { await _tts.setSpeechRate(0.5); // Clear motorcycle cadence await _tts.setVolume(1.0); await _tts.setPitch(1.0); + try { + await _tts.setIosAudioCategory( + IosTextToSpeechAudioCategory.playback, + [ + IosTextToSpeechAudioCategoryOptions.allowBluetooth, + IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, + IosTextToSpeechAudioCategoryOptions.duckOthers, + ], + IosTextToSpeechAudioMode.voicePrompt, + ); + } catch (_) {} _ttsInitialized = true; } catch (_) { _ttsInitialized = false; @@ -238,12 +260,12 @@ class NavigationSessionNotifier extends StateNotifier { } /// Toggles spoken voice guidance. - void toggleTts() { + Future toggleTts() async { final newEnabled = !state.ttsEnabled; state = state.copyWith(ttsEnabled: newEnabled); if (!newEnabled && _tts != null && _ttsInitialized) { try { - _tts.stop(); + await _tts.stop(); } catch (_) {} } } @@ -252,6 +274,23 @@ class NavigationSessionNotifier extends StateNotifier { void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) { 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; if (currIdx >= state.maneuvers.length) { _completeArrival(); @@ -293,6 +332,75 @@ class NavigationSessionNotifier extends StateNotifier { ); } + /// Triggers real route recalculation from rider's current position to destination. + Future _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() { state = state.copyWith( status: NavigationStatus.arrived, @@ -309,10 +417,41 @@ class NavigationSessionNotifier extends StateNotifier { state = state.copyWith(lastAnnouncedInstruction: instruction); try { - await _tts.speak(instruction); + await _tts.speak(instruction, focus: true); } catch (_) {} } + /// Computes shortest distance from point to polyline in meters. + static double _distanceToPolylineMeters(AppLatLng point, List 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) { const r = 6371000.0; // Earth radius in meters final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0); @@ -334,7 +473,8 @@ class NavigationSessionNotifier extends StateNotifier { final navigationSessionProvider = StateNotifierProvider( (ref) { - final notifier = NavigationSessionNotifier(); + final routeProvider = ref.watch(activeRouteCalculationProvider); + final notifier = NavigationSessionNotifier(routeProvider: routeProvider); // Connect live location updates when navigating ref.listen(locationStreamProvider, (_, next) { final pos = next.value; diff --git a/lib/services/routes/aws_location_route_provider.dart b/lib/services/routes/aws_location_route_provider.dart index 859fb4e..cb7d18c 100644 --- a/lib/services/routes/aws_location_route_provider.dart +++ b/lib/services/routes/aws_location_route_provider.dart @@ -252,12 +252,12 @@ class AwsLocationRouteProvider implements RouteProvider { ); } - final effectiveAvoidHighways = options?.avoidHighways ?? - (options?.preference == RoutingPreference.twisty || - options?.preference == RoutingPreference.scenic || - avoidHighways); - final effectiveAvoidTolls = options?.avoidTolls ?? avoidTolls; - final effectiveAvoidFerries = options?.avoidFerries ?? avoidFerries; + final effectiveAvoidHighways = (options?.avoidHighways == true) || + options?.preference == RoutingPreference.twisty || + options?.preference == RoutingPreference.scenic || + avoidHighways; + final effectiveAvoidTolls = (options?.avoidTolls == true) || avoidTolls; + final effectiveAvoidFerries = (options?.avoidFerries == true) || avoidFerries; final optimizeFor = options?.preference == RoutingPreference.shortest ? 'ShortestRoute' : 'FastestRoute'; @@ -295,6 +295,9 @@ class AwsLocationRouteProvider implements RouteProvider { 'LegGeometryFormat': 'Simple', 'LegAdditionalInputs': ['TravelSteps'], 'Instructions': true, + if (options?.preference == RoutingPreference.twisty || + options?.preference == RoutingPreference.scenic) + 'MaxAlternatives': 2, }; if (waypoints != null && waypoints.isNotEmpty) { bodyMap['Waypoints'] = waypoints; @@ -309,9 +312,16 @@ class AwsLocationRouteProvider implements RouteProvider { .timeout(const Duration(seconds: 15)); 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( '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) { throw RouteProviderException( 'routeNotFound', - 'No route returned by AWS.', + 'No route returned by AWS Location Service.', ); } - final route = routes[0]; - final summary = route['Summary'] as Map?; - final distanceMeters = (summary?['Distance'] as num?)?.toDouble() ?? 0.0; - final durationSeconds = (summary?['Duration'] as num?)?.toInt() ?? 0; + final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered); - final legs = route['Legs'] as List? ?? []; - final geometry = []; + // Parse and score all candidate routes returned by AWS + final candidates = <_CandidateRoute>[]; + for (final r in routes) { + if (r is! Map) continue; + final summary = r['Summary'] as Map?; + final distanceM = (summary?['Distance'] as num?)?.toDouble() ?? 0.0; + final durationS = (summary?['Duration'] as num?)?.toInt() ?? 0; + final legs = r['Legs'] as List? ?? []; + final geom = []; - for (final leg in legs) { - final geom = leg['Geometry'] as Map?; - final lineString = geom?['LineString'] as List?; - if (lineString != null) { - for (final pt in lineString) { - if (pt is List && pt.length >= 2) { - geometry.add( - AppLatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()), - ); + for (final leg in legs) { + final gMap = leg['Geometry'] as Map?; + final lineString = gMap?['LineString'] as List?; + if (lineString != null) { + for (final pt in lineString) { + if (pt is List && pt.length >= 2) { + geom.add(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 - ? geometry - : ordered.map((s) => s.latLng).toList(); + if (candidates.isEmpty) { + throw RouteProviderException( + 'routeNotFound', + 'Could not parse route candidates from AWS response.', + ); + } - final straightLine = MotorSignalRoute.calculateTotalDistanceMeters(ordered); - final effectiveDist = distanceMeters > 0 ? distanceMeters : straightLine; - final distMiles = effectiveDist * 0.000621371; - - // Extract maneuvers from TravelSteps if available - final maneuvers = _extractManeuvers(legs, effectiveGeometry, ordered); - - // Compute geometric curvature metric - final curvature = _calculateCurvatureMetric(effectiveGeometry, distMiles); + // Actively select best route alternative according to user preference + _CandidateRoute selected = candidates.first; + if (candidates.length > 1) { + if (options?.preference == RoutingPreference.twisty) { + // Actively choose route alternative with the highest curvature metric + candidates.sort((a, b) => b.curvature.compareTo(a.curvature)); + selected = candidates.first; + } else if (options?.preference == RoutingPreference.shortest) { + 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( straightLineDistanceMeters: straightLine, - estimatedDistanceMeters: effectiveDist, - estimatedDurationSeconds: durationSeconds > 0 ? durationSeconds : null, - geometry: effectiveGeometry, - isStraightLine: geometry.isEmpty, + estimatedDistanceMeters: selected.distanceMeters, + estimatedDurationSeconds: selected.durationSeconds > 0 ? selected.durationSeconds : null, + geometry: selected.geometry, + isStraightLine: selected.geometry.isEmpty, providerName: name, - maneuvers: maneuvers, + maneuvers: selected.maneuvers, options: options, - curvatureMetric: curvature, + curvatureMetric: selected.curvature, ); } catch (e) { if (e is RouteProviderException) rethrow; @@ -571,3 +607,19 @@ class AwsLocationRouteProvider implements RouteProvider { return (brng + 360.0) % 360.0; } } + +class _CandidateRoute { + final double distanceMeters; + final int durationSeconds; + final List geometry; + final double curvature; + final List maneuvers; + + _CandidateRoute({ + required this.distanceMeters, + required this.durationSeconds, + required this.geometry, + required this.curvature, + required this.maneuvers, + }); +} diff --git a/lib/services/social/social_feed_models.dart b/lib/services/social/social_feed_models.dart index 366d717..789741d 100644 --- a/lib/services/social/social_feed_models.dart +++ b/lib/services/social/social_feed_models.dart @@ -169,6 +169,7 @@ class SocialFeedItem { final String? photoUrl; final String? photoCaption; final bool isGpsVerified; + final bool isDemo; // Kudos & Reactions final int fistBumpCount; @@ -202,6 +203,7 @@ class SocialFeedItem { this.photoUrl, this.photoCaption, this.isGpsVerified = true, + this.isDemo = false, this.fistBumpCount = 0, this.fistBumpUserIds = const [], this.comments = const [], @@ -239,6 +241,7 @@ class SocialFeedItem { String? photoUrl, String? photoCaption, bool? isGpsVerified, + bool? isDemo, int? fistBumpCount, List? fistBumpUserIds, List? comments, @@ -270,6 +273,7 @@ class SocialFeedItem { photoUrl: photoUrl ?? this.photoUrl, photoCaption: photoCaption ?? this.photoCaption, isGpsVerified: isGpsVerified ?? this.isGpsVerified, + isDemo: isDemo ?? this.isDemo, fistBumpCount: fistBumpCount ?? this.fistBumpCount, fistBumpUserIds: fistBumpUserIds ?? this.fistBumpUserIds, comments: comments ?? this.comments, @@ -303,6 +307,7 @@ class SocialFeedItem { if (photoUrl != null) 'photoUrl': photoUrl, if (photoCaption != null) 'photoCaption': photoCaption, 'isGpsVerified': isGpsVerified, + 'isDemo': isDemo, 'fistBumpCount': fistBumpCount, 'fistBumpUserIds': fistBumpUserIds, 'comments': comments.map((c) => c.toJson()).toList(), @@ -361,6 +366,7 @@ class SocialFeedItem { photoUrl: json['photoUrl'] as String?, photoCaption: json['photoCaption'] as String?, isGpsVerified: json['isGpsVerified'] as bool? ?? true, + isDemo: json['isDemo'] as bool? ?? false, fistBumpCount: (json['fistBumpCount'] as num?)?.toInt() ?? 0, fistBumpUserIds: (json['fistBumpUserIds'] as List?)?.cast() ?? [], comments: comments, diff --git a/lib/services/social/social_feed_service.dart b/lib/services/social/social_feed_service.dart index 5fedab8..3ed6294 100644 --- a/lib/services/social/social_feed_service.dart +++ b/lib/services/social/social_feed_service.dart @@ -10,6 +10,7 @@ class SocialFeedService { List _memoryFeed = []; bool _initialized = false; + StreamSubscription? _firestoreSub; SocialFeedService({this.firestore}) { _initFeed(); @@ -19,7 +20,27 @@ class SocialFeedService { if (_initialized) return; _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 = [ SocialFeedItem( id: 'feed_post_1', @@ -45,6 +66,7 @@ class SocialFeedService { isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, + isDemo: true, fistBumpCount: 42, fistBumpUserIds: const ['uid_rider_2', 'uid_rider_3'], routePoints: const [ @@ -91,6 +113,7 @@ class SocialFeedService { photoUrl: 'assets/images/rock_store_lookout.png', photoCaption: 'Rock Store Lookout', isGpsVerified: false, + isDemo: true, fistBumpCount: 28, fistBumpUserIds: const ['uid_marcus'], comments: const [], @@ -119,6 +142,7 @@ class SocialFeedService { isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, + isDemo: true, fistBumpCount: 65, fistBumpUserIds: const ['uid_marcus', 'uid_sarah'], routePoints: const [ @@ -152,6 +176,7 @@ class SocialFeedService { isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, + isDemo: true, fistBumpCount: 39, fistBumpUserIds: const ['uid_marcus'], routePoints: const [ @@ -185,6 +210,7 @@ class SocialFeedService { isFeatured: true, snapshotType: FeedSnapshotType.routeTrack, isGpsVerified: false, + isDemo: true, fistBumpCount: 51, fistBumpUserIds: const ['uid_sarah', 'uid_alex'], routePoints: const [ @@ -474,6 +500,7 @@ class SocialFeedService { } void dispose() { + _firestoreSub?.cancel(); _localFeedController.close(); } } diff --git a/test/aws_location_route_provider_test.dart b/test/aws_location_route_provider_test.dart index f80d3b9..14868a1 100644 --- a/test/aws_location_route_provider_test.dart +++ b/test/aws_location_route_provider_test.dart @@ -315,5 +315,113 @@ void main() { expect(result.formattedDuration, equals('15 min')); 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() + .having((e) => e.code, 'code', 'routeUnavailable') + .having( + (e) => e.message, + 'message', + contains('AccessDeniedException: The provided API key is unauthorized'), + ), + ), + ); + }); }); } diff --git a/test/garage_maintenance_test.dart b/test/garage_maintenance_test.dart index 0c03ff5..74ff5a5 100644 --- a/test/garage_maintenance_test.dart +++ b/test/garage_maintenance_test.dart @@ -5,6 +5,7 @@ import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/models/maintenance_models.dart'; import 'package:motorcycle_dash/features/garage/garage_providers.dart'; import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart'; +import 'package:motorcycle_dash/services/cloud/firebase_account_sync_service.dart'; void main() { group('MaintenanceData & Countdown Pill Logic', () { @@ -180,4 +181,65 @@ void main() { 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; + + // 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')); + }); + }); } diff --git a/test/lean_angle_tap_calibrate_test.dart b/test/lean_angle_tap_calibrate_test.dart index 997d48c..9bfdacc 100644 --- a/test/lean_angle_tap_calibrate_test.dart +++ b/test/lean_angle_tap_calibrate_test.dart @@ -106,6 +106,131 @@ void main() { expect(calibrated, isTrue, reason: 'Must invoke calibrateUpright() on SensorFusionService'); 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 implements RideStateMachine { diff --git a/test/navigation_session_service_test.dart b/test/navigation_session_service_test.dart index 678d6ae..b32d1b2 100644 --- a/test/navigation_session_service_test.dart +++ b/test/navigation_session_service_test.dart @@ -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_provider.dart'; +class _TestRouteProvider implements RouteProvider { + int calculateCount = 0; + List? lastStops; + RoutingOptions? lastOptions; + + @override + String get name => 'Test Provider'; + + @override + Future calculateRoute( + List 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> searchPlaces( + String query, { + AppLatLng? nearLocation, + }) async => + []; + + @override + Future resolvePlace(String providerId) async => + throw UnimplementedError(); +} + void main() { group('NavigationSessionNotifier Tests', () { test('initial state is idle and empty', () { @@ -14,7 +77,7 @@ void main() { 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 maneuvers = [ const RouteManeuver( @@ -97,7 +160,7 @@ void main() { type: ManeuverType.turnLeft, distanceMeters: 800.0, durationSeconds: 90, - location: AppLatLng(33.7518, -84.3880), // ~200m north + location: AppLatLng(33.7518, -84.3880), ), const RouteManeuver( index: 2, @@ -125,11 +188,7 @@ void main() { await notifier.startNavigation(calculationResult: calcResult); - // Rider is approaching starting waypoint (approx 100m south) - 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 + // Rider reaches within 10 meters of maneuver 0 point along the route notifier.updateLocation(const AppLatLng(33.7500, -84.3880), speedMph: 25.0); expect(notifier.state.currentManeuverIndex, equals(1)); expect(notifier.state.currentManeuver?.instruction, equals('Turn left on Elm')); @@ -148,6 +207,71 @@ void main() { 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.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', () { final notifier = NavigationSessionNotifier(); expect(notifier.state.ttsEnabled, isTrue); diff --git a/test/social_hybrid_ui_test.dart b/test/social_hybrid_ui_test.dart index 4223fde..35547f7 100644 --- a/test/social_hybrid_ui_test.dart +++ b/test/social_hybrid_ui_test.dart @@ -88,5 +88,43 @@ void main() { // Verify no RenderFlex overflow exceptions occurred 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); + }); }); }