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:
Developer 2026-10-10 00:05:21 -04:00
parent 618ef8798b
commit ab4b322d85
12 changed files with 851 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
));
}
if (candidates.isEmpty) {
throw RouteProviderException(
'routeNotFound',
'Could not parse route candidates from AWS response.',
); );
} }
// 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;
} }
} }
}
final effectiveGeometry = geometry.isNotEmpty
? geometry
: ordered.map((s) => s.latLng).toList();
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);
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,
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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