fix(android): resolve BUG-001 through BUG-005 (#78)

* fix(dashboard): ensure Touring map follows rider and supports recenter (#48)

* fix(social): make shared session telemetry read-only and authoritative (#50)

* fix(social): use recorded telemetry for feed charts and verify GPS badge (#51)

* fix(social): standardize community feed typography and theme colors (#49)

* fix(social): prevent horizontal overflow in GPX export snackbar (#52)

- Wrap GPX export snackbar message Text in Expanded with maxLines: 2 and TextOverflow.ellipsis

- Add ValueKey('gpx_export_button_<id>') for deterministic testing

- Wrap narrow feed header/metric/empty-state Row labels in Flexible to prevent overflow under 320px/360px at 1.5x text scale

- Add BUG-005 widget tests across 320x640, 360x800, and 800x480 viewports at 1.5x text scale

* fix(social): harden share modal, inspector theming, and PR #78 accuracy (#49, #50, #51, #52)

- Require a selected recorded Ride in ShareSessionModal before enabling publish

- Theme ShareSessionModal and FeedTelemetryInspectorModal for Light and Dark modes

- Use LayoutBuilder width for FeedTelemetryInspectorModal scrubber drag/tap

- Route PopupMenuButton export_gpx action through the overflow-safe GPX snackbar

- Enforce telemetrySamples >= 2 when provided to verifyGpsCriteria

* fix(android): remediate map follow smoothness, orientation controls, starting location, and landscape overflow (#79, #80, #81, #52)

* fix(android): stop Pro upsell sheet overflowing in landscape (#52 / BUG-005)

The "Forensic CSV & PDF Export" Pro upsell sheet showed "BOTTOM OVERFLOWED BY 90
PIXELS" in landscape on a Pixel 8a. showProUpgradeSheet built a non-scrolling Column
inside a default modal bottom sheet, which Flutter caps at 9/16 of the screen height
(about 230 dp in landscape) - shorter than the header, description and two actions.

Use isScrollControlled and wrap the content in a SingleChildScrollView so the sheet can
take the full height and never overflows. This also covers every other caller (garage,
add motorcycle, ride detail).

Tests: pro_upgrade_sheet_landscape_test covers 914x411 (Pixel 8a), 800x360 and 780x360
at 1.0x and 1.3x text scale plus NOT NOW dismissal. Verified to fail on the old code
(RenderFlex overflowed 64-144 px) and pass on the fix. Full suite 1700 passed,
flutter analyze clean. Not validated on the physical device yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(social): require telemetry evidence, geodesic GPS threshold, and 3 MPH lean gate (#50, #51)

---------

Co-authored-by: Developer <dev@example.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Billie Ponder 2026-10-17 14:35:33 -04:00 committed by GitHub
parent 2ca069fdb9
commit b005bb00ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3796 additions and 948 deletions

View File

@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
import '../../../services/alerts/road_alert_models.dart'; import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart'; import '../../../services/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart'; import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/map_providers.dart';
import '../../../services/map/unified_map_view.dart'; import '../../../services/map/unified_map_view.dart';
import '../../../services/navigation/navigation_session_service.dart'; import '../../../services/navigation/navigation_session_service.dart';
import '../../../services/telemetry_service.dart'; import '../../../services/telemetry_service.dart';
import '../../rides/ride_state_machine.dart';
/// Embedded tactical map panel for Touring mode and responsive dashboard views. /// Embedded tactical map panel for Touring mode and responsive dashboard views.
/// ///
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider] /// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
/// and alert pins via [activeAlertsStreamProvider]. /// and alert pins via [activeAlertsStreamProvider], maintains camera follow
class DashboardMapPanel extends ConsumerWidget { /// state, and provides a recenter control after manual pan.
class DashboardMapPanel extends ConsumerStatefulWidget {
final double zoom; final double zoom;
const DashboardMapPanel({ const DashboardMapPanel({
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DashboardMapPanel> createState() => _DashboardMapPanelState();
}
class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
static const AppLatLng _defaultFallbackCenter = AppLatLng(37.7749, -122.4194);
AppLatLng? _lastKnownRiderPos;
@override
void initState() {
super.initState();
// Ensure follow mode is active whenever the Touring map panel is mounted.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
}
bool _isValidCoord(double? lat, double? lng) {
return lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
(lat != 0.0 || lng != 0.0);
}
@override
Widget build(BuildContext context) {
// Re-enable camera follow automatically when a ride session starts.
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
if (previous?.state != RideState.riding &&
next.state == RideState.riding &&
mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
final vehicleCoords = ref.watch( final vehicleCoords = ref.watch(
telemetryStreamProvider.select( telemetryStreamProvider.select(
(s) => ( (s) => (
@ -34,20 +73,22 @@ class DashboardMapPanel extends ConsumerWidget {
), ),
); );
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0) final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
? vehicleCoords.lat! if (hasCoordinates) {
: 37.7749; _lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0) }
? vehicleCoords.lng!
: -122.4194;
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
final isFresh = vehicleCoords.isGpsFresh; final isFresh = vehicleCoords.isGpsFresh;
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0; final isFollowMode = ref.watch(mapFollowStateProvider);
final orientation = ref.watch(mapOrientationProvider);
final isNorthUp = orientation == MapOrientation.northUp;
final effectiveBearing = isNorthUp ? 0.0 : vehicleCoords.heading;
final vehicleMarker = AppMapMarker( final vehicleMarker = AppMapMarker(
id: 'dash_vehicle', id: 'dash_vehicle',
position: AppLatLng(centerLat, centerLng), position: activeCenter,
heading: vehicleCoords.heading, heading: isNorthUp ? vehicleCoords.heading : 0.0,
iconWidget: Icon( iconWidget: Icon(
Icons.navigation, Icons.navigation,
color: isFresh color: isFresh
@ -64,7 +105,7 @@ class DashboardMapPanel extends ConsumerWidget {
for (final alert in activeAlerts) { for (final alert in activeAlerts) {
mapMarkers.add( mapMarkers.add(
AppMapMarker( AppMapMarker(
id: 'dash_alert__', id: 'dash_alert_${alert.id}',
position: AppLatLng(alert.latitude, alert.longitude), position: AppLatLng(alert.latitude, alert.longitude),
heading: alert.heading ?? 0.0, heading: alert.heading ?? 0.0,
label: alert.category.displayName, label: alert.category.displayName,
@ -116,14 +157,69 @@ class DashboardMapPanel extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: UnifiedMapView( child: UnifiedMapView(
key: const ValueKey('dash_map_x'), key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng), center: activeCenter,
zoom: zoom, zoom: widget.zoom,
bearing: 0.0, bearing: effectiveBearing,
markers: mapMarkers, markers: mapMarkers,
polylines: polylines, polylines: polylines,
), ),
), ),
if (!isFresh && hasCoordinates) Positioned(
top: 10,
right: 10,
child: Material(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
child: InkWell(
key: const ValueKey('dashboard_map_orientation_button'),
borderRadius: BorderRadius.circular(20),
onTap: () {
ref.read(mapOrientationProvider.notifier).toggle();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isNorthUp
? Colors.white24
: AppColors.cyberCyan,
width: 1.2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isNorthUp ? Icons.explore : Icons.navigation,
size: 14,
color: isNorthUp
? Colors.white70
: AppColors.cyberCyan,
),
const SizedBox(width: 5),
Text(
isNorthUp ? 'NORTH UP' : 'HEADING UP',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: isNorthUp
? Colors.white70
: AppColors.cyberCyan,
),
),
],
),
),
),
),
),
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
Positioned( Positioned(
top: 10, top: 10,
left: 10, left: 10,
@ -154,6 +250,56 @@ class DashboardMapPanel extends ConsumerWidget {
), ),
), ),
), ),
if (!isFollowMode)
Positioned(
bottom: 10,
right: 10,
child: Material(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
child: InkWell(
key: const ValueKey('dashboard_map_recenter_button'),
borderRadius: BorderRadius.circular(20),
onTap: () {
ref.read(mapFollowStateProvider.notifier).recenter();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.cyberCyan,
width: 1.2,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.my_location,
size: 14,
color: AppColors.cyberCyan,
),
SizedBox(width: 5),
Text(
'RECENTER',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: AppColors.cyberCyan,
),
),
],
),
),
),
),
),
], ],
), ),
); );
@ -162,3 +308,4 @@ class DashboardMapPanel extends ConsumerWidget {
); );
} }
} }

View File

@ -168,13 +168,17 @@ Future<void> showProUpgradeSheet(
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
// A default modal sheet is capped at 9/16 of the screen height, which in landscape
// (~230 dp on a phone) is shorter than this content. Allow the full height and make
// the content scrollable so it can never overflow (Issue #52 / BUG-005).
isScrollControlled: true,
backgroundColor: const Color(0xFF0F0F0F), backgroundColor: const Color(0xFF0F0F0F),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (ctx) { builder: (ctx) {
return SafeArea( return SafeArea(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -48,7 +48,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
bool _showCornering = true; bool _showCornering = true;
double _currentZoom = 17.0; double _currentZoom = 17.0;
bool _isNorthUp = true;
String _destinationQuery = ''; String _destinationQuery = '';
late final TextEditingController _searchController; late final TextEditingController _searchController;
Timer? _searchDebounce; Timer? _searchDebounce;
@ -907,6 +906,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final isFollowMode = ref.watch(mapFollowStateProvider); final isFollowMode = ref.watch(mapFollowStateProvider);
final layerSettings = ref.watch(mapLayerSettingsProvider); final layerSettings = ref.watch(mapLayerSettingsProvider);
final isNorthUp = ref.watch(mapOrientationProvider) == MapOrientation.northUp;
final colors = context.motorColors; final colors = context.motorColors;
final theme = Theme.of(context); final theme = Theme.of(context);
@ -1084,7 +1084,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final motorcycleMarker = AppMapMarker( final motorcycleMarker = AppMapMarker(
id: 'motorcycle_live', id: 'motorcycle_live',
position: currentPos, position: currentPos,
heading: _isNorthUp ? currentHeading : 0.0, heading: isNorthUp ? currentHeading : 0.0,
iconWidget: Icon( iconWidget: Icon(
Icons.navigation, Icons.navigation,
color: colors.cyberCyan, color: colors.cyberCyan,
@ -1189,7 +1189,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
key: const ValueKey('live_map_view'), key: const ValueKey('live_map_view'),
center: currentPos, center: currentPos,
zoom: _currentZoom, zoom: _currentZoom,
bearing: _isNorthUp ? 0.0 : currentHeading, bearing: isNorthUp ? 0.0 : currentHeading,
markers: mapMarkers, markers: mapMarkers,
polylines: livePolylines, polylines: livePolylines,
), ),
@ -1280,10 +1280,10 @@ class _MapScreenState extends ConsumerState<MapScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
_HudIconButton( _HudIconButton(
key: const ValueKey('compass_orient_button'), key: const ValueKey('compass_orient_button'),
icon: _isNorthUp ? Icons.explore : Icons.navigation, icon: isNorthUp ? Icons.explore : Icons.navigation,
tooltip: _isNorthUp ? 'North Up' : 'Heading Up', tooltip: isNorthUp ? 'North Up' : 'Heading Up',
color: _isNorthUp ? Colors.white70 : colors.cyberCyan, color: isNorthUp ? Colors.white70 : colors.cyberCyan,
onTap: () => setState(() => _isNorthUp = !_isNorthUp), onTap: () => ref.read(mapOrientationProvider.notifier).toggle(),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_HudIconButton( _HudIconButton(

File diff suppressed because it is too large Load Diff

View File

@ -100,6 +100,14 @@ class _FeedCommentsBottomSheetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final accentColor =
isLight ? AppColors.brandBlueStrong : colors.brandCyanBright;
final sendBg = isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
final sendFg = isLight ? AppColors.onBrand : const Color(0xFF00363D);
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final feedItems = ref.watch(socialFeedNotifierProvider); final feedItems = ref.watch(socialFeedNotifierProvider);
final livePost = feedItems.cast<SocialFeedItem?>().firstWhere( final livePost = feedItems.cast<SocialFeedItem?>().firstWhere(
@ -116,14 +124,14 @@ class _FeedCommentsBottomSheetState
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF101419), color: colors.surfaceCardPrimary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)), border: Border.all(color: colors.surfaceBorder),
boxShadow: const [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black87, color: colors.glowShadow,
blurRadius: 30, blurRadius: 30,
offset: Offset(0, -10), offset: const Offset(0, -10),
), ),
], ],
), ),
@ -139,7 +147,7 @@ class _FeedCommentsBottomSheetState
width: 44, width: 44,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.outlineVariant, color: colors.surfaceBorder,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -149,9 +157,9 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.fromLTRB(20, 8, 12, 12), padding: const EdgeInsets.fromLTRB(20, 8, 12, 12),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.chat_bubble_outline_rounded, Icons.chat_bubble_outline_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -161,7 +169,7 @@ class _FeedCommentsBottomSheetState
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -169,33 +177,35 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1D2025), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.3), color: accentColor.withValues(alpha: 0.35),
), ),
), ),
child: Text( child: Text(
'${comments.length}', '${comments.length}',
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.close_rounded, icon: Icon(
color: Color(0xFF849396)), Icons.close_rounded,
color: secondaryTextColor,
),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
tooltip: 'Close', tooltip: 'Close',
), ),
], ],
), ),
), ),
const Divider(color: Color(0xFF1D2025), height: 1), Divider(color: colors.surfaceBorder, height: 1),
// Comment List or Empty // Comment List or Empty
Flexible( Flexible(
@ -209,16 +219,16 @@ class _FeedCommentsBottomSheetState
Icon( Icon(
Icons.forum_outlined, Icons.forum_outlined,
size: 40, size: 40,
color: colors.outline.withValues(alpha: 0.4), color: secondaryTextColor,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( Text(
'No debrief comments yet', 'No debrief comments yet',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white70, color: colors.textPrimary,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -228,7 +238,7 @@ class _FeedCommentsBottomSheetState
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -246,10 +256,10 @@ class _FeedCommentsBottomSheetState
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF181C21), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: const Color(0xFF272A30), color: colors.surfaceBorder,
), ),
), ),
child: Column( child: Column(
@ -259,12 +269,11 @@ class _FeedCommentsBottomSheetState
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 14, radius: 14,
backgroundColor: backgroundColor: colors.surfaceCardPrimary,
const Color(0xFF272A30), child: Icon(
child: const Icon(
Icons.sports_motorsports_rounded, Icons.sports_motorsports_rounded,
size: 16, size: 16,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -275,12 +284,12 @@ class _FeedCommentsBottomSheetState
child: Text( child: Text(
c.authorDisplayName, c.authorDisplayName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: fontFamily:
AppTypography.fontHeadline, AppTypography.fontHeadline,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
), ),
@ -292,7 +301,7 @@ class _FeedCommentsBottomSheetState
fontFamily: fontFamily:
AppTypography.fontBody, AppTypography.fontBody,
fontSize: 11, fontSize: 11,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -304,7 +313,7 @@ class _FeedCommentsBottomSheetState
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 10, fontSize: 10,
color: colors.outline, color: secondaryTextColor,
), ),
), ),
], ],
@ -314,11 +323,11 @@ class _FeedCommentsBottomSheetState
padding: const EdgeInsets.only(left: 36), padding: const EdgeInsets.only(left: 36),
child: Text( child: Text(
c.content, c.content,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
height: 1.35, height: 1.35,
color: Color(0xFFE0E2EA), color: colors.textPrimary,
), ),
), ),
), ),
@ -329,7 +338,7 @@ class _FeedCommentsBottomSheetState
), ),
), ),
const Divider(color: Color(0xFF1D2025), height: 1), Divider(color: colors.surfaceBorder, height: 1),
// Comment Input Field (Glove Friendly) // Comment Input Field (Glove Friendly)
Padding( Padding(
@ -340,28 +349,28 @@ class _FeedCommentsBottomSheetState
child: Container( child: Container(
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF181C21), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: const Color(0xFF272A30), color: colors.surfaceBorder,
), ),
), ),
child: TextField( child: TextField(
controller: _commentController, controller: _commentController,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: Colors.white, color: colors.textPrimary,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Add comment or pace tip...', hintText: 'Add comment or pace tip...',
hintStyle: TextStyle( hintStyle: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: 13,
color: Color(0xFF849396), color: secondaryTextColor,
), ),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 14, horizontal: 14,
vertical: 14, vertical: 14,
), ),
@ -376,8 +385,8 @@ class _FeedCommentsBottomSheetState
width: 48, width: 48,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.electricCyan, backgroundColor: sendBg,
foregroundColor: const Color(0xFF00363D), foregroundColor: sendFg,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -387,12 +396,12 @@ class _FeedCommentsBottomSheetState
onPressed: onPressed:
_isSubmitting ? null : () => _handlePostComment(), _isSubmitting ? null : () => _handlePostComment(),
child: _isSubmitting child: _isSubmitting
? const SizedBox( ? SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Color(0xFF00363D), color: sendFg,
), ),
) )
: const Icon(Icons.send_rounded, size: 20), : const Icon(Icons.send_rounded, size: 20),

View File

@ -58,19 +58,24 @@ class _FeedTelemetryInspectorModalState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.motorColors; final colors = context.motorColors;
final isLight = Theme.of(context).brightness == Brightness.light;
final secondaryTextColor =
isLight ? AppColors.lightOnSurfaceVariant : AppColors.darkOnSurfaceVariant;
final accentColor =
isLight ? AppColors.brandBlueStrong : AppColors.electricCyan;
final item = widget.feedItem; final item = widget.feedItem;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final maxHeight = mediaQuery.size.height * 0.90; final maxHeight = mediaQuery.size.height * 0.90;
return Container( return Container(
constraints: BoxConstraints(maxHeight: maxHeight), constraints: BoxConstraints(maxHeight: maxHeight),
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Color(0xFF0F1318), color: colors.surfaceCardPrimary,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border( border: Border(
top: BorderSide(color: Color(0xFF222933), width: 1.5), top: BorderSide(color: colors.surfaceBorder, width: 1.5),
left: BorderSide(color: Color(0xFF222933), width: 1.5), left: BorderSide(color: colors.surfaceBorder, width: 1.5),
right: BorderSide(color: Color(0xFF222933), width: 1.5), right: BorderSide(color: colors.surfaceBorder, width: 1.5),
), ),
), ),
child: SafeArea( child: SafeArea(
@ -86,7 +91,7 @@ class _FeedTelemetryInspectorModalState
width: 40, width: 40,
height: 4, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF333E4D), color: colors.surfaceBorder,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@ -100,15 +105,15 @@ class _FeedTelemetryInspectorModalState
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.electricCyan.withValues(alpha: 0.15), color: accentColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: AppColors.electricCyan.withValues(alpha: 0.35), color: accentColor.withValues(alpha: 0.35),
), ),
), ),
child: const Icon( child: Icon(
Icons.insights_rounded, Icons.insights_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
), ),
@ -123,11 +128,11 @@ class _FeedTelemetryInspectorModalState
child: Text( child: Text(
item.authorDisplayName, item.authorDisplayName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
), ),
@ -139,19 +144,27 @@ class _FeedTelemetryInspectorModalState
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.hazardAmber.withValues(alpha: 0.2), color: (isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: AppColors.hazardAmber.withValues(alpha: 0.4), color: (isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber)
.withValues(alpha: 0.4),
), ),
), ),
child: Text( child: Text(
item.authorBadge!, item.authorBadge!,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: AppColors.hazardAmber, color: isLight
? AppColors.semanticWarningLight
: AppColors.hazardAmber,
), ),
), ),
), ),
@ -161,24 +174,24 @@ class _FeedTelemetryInspectorModalState
Text( Text(
'${item.rideTitle} • ${item.bikeModel}', '${item.rideTitle} • ${item.bikeModel}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 12, fontSize: 12,
color: Color(0xFF8B98A5), color: secondaryTextColor,
), ),
), ),
], ],
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close_rounded, color: Colors.white70), icon: Icon(Icons.close_rounded, color: secondaryTextColor),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
), ),
), ),
const Divider(color: Color(0xFF1D242E), height: 1), Divider(color: colors.surfaceBorder, height: 1),
// Scrollable Content: Metrics + Graph + Replay Trigger // Scrollable Content: Metrics + Graph + Replay Trigger
Flexible( Flexible(
@ -188,11 +201,11 @@ class _FeedTelemetryInspectorModalState
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 2x3 Telemetry Insets Grid // 2x3 Telemetry Insets Grid
_buildMetricsGrid(item, colors), _buildMetricsGrid(item, colors, isLight, secondaryTextColor, accentColor),
const SizedBox(height: 18), const SizedBox(height: 18),
// Trace Selector Tabs // Trace Selector Tabs
_buildTraceSelector(colors), _buildTraceSelector(colors, secondaryTextColor, accentColor),
const SizedBox(height: 12), const SizedBox(height: 12),
// Interactive Telemetry Graph Canvas // Interactive Telemetry Graph Canvas
@ -200,11 +213,11 @@ class _FeedTelemetryInspectorModalState
const SizedBox(height: 16), const SizedBox(height: 16),
// Scrubber Details Pill // Scrubber Details Pill
_buildScrubberReadout(item, colors), _buildScrubberReadout(item, colors, secondaryTextColor, accentColor),
const SizedBox(height: 20), const SizedBox(height: 20),
// Launch Full Replay Button // Launch Full Replay Button
_buildReplayButton(context), _buildReplayButton(context, colors, accentColor),
], ],
), ),
), ),
@ -215,13 +228,24 @@ class _FeedTelemetryInspectorModalState
); );
} }
Widget _buildMetricsGrid(SocialFeedItem item, MotorSignalThemeColors colors) { Widget _buildMetricsGrid(
SocialFeedItem item,
MotorSignalThemeColors colors,
bool isLight,
Color secondaryTextColor,
Color accentColor,
) {
final successColor =
isLight ? AppColors.semanticSuccessLight : const Color(0xFF00E676);
final warnColor =
isLight ? AppColors.semanticWarningLight : AppColors.hazardAmber;
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141A22), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.surfaceBorder),
), ),
child: Column( child: Column(
children: [ children: [
@ -232,34 +256,37 @@ class _FeedTelemetryInspectorModalState
label: 'PEAK LEAN', label: 'PEAK LEAN',
value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°', value: '${item.maxLeanAngleDeg.toStringAsFixed(0)}°',
subtext: '${item.maxLeanSide} APEX', subtext: '${item.maxLeanSide} APEX',
accentColor: AppColors.electricCyan, accentColor: accentColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.rotate_right_rounded, icon: Icons.rotate_right_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.surfaceBorder),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'DISTANCE', label: 'DISTANCE',
value: item.distanceMiles.toStringAsFixed(1), value: item.distanceMiles.toStringAsFixed(1),
subtext: 'MILES', subtext: 'MILES',
accentColor: Colors.white, accentColor: colors.textPrimary,
secondaryTextColor: secondaryTextColor,
icon: Icons.route_rounded, icon: Icons.route_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.surfaceBorder),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'DURATION', label: 'DURATION',
value: _formatDuration(item.durationSeconds), value: _formatDuration(item.durationSeconds),
subtext: 'MOVING', subtext: 'MOVING',
accentColor: Colors.white, accentColor: colors.textPrimary,
secondaryTextColor: secondaryTextColor,
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(color: Color(0xFF222B38), height: 1), Divider(color: colors.surfaceBorder, height: 1),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
@ -270,29 +297,30 @@ class _FeedTelemetryInspectorModalState
subtext: item.paceFactor.contains(' ') subtext: item.paceFactor.contains(' ')
? item.paceFactor.split(' ').sublist(1).join(' ') ? item.paceFactor.split(' ').sublist(1).join(' ')
: 'PACE', : 'PACE',
accentColor: const Color(0xFF00E676), accentColor: successColor,
secondaryTextColor: secondaryTextColor,
icon: Icons.speed_rounded, icon: Icons.speed_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.surfaceBorder),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'ELEVATION', label: 'ELEVATION',
value: '+${item.elevationFt}', value: '+${item.elevationFt}',
subtext: 'FEET GAIN', subtext: 'FEET GAIN',
accentColor: Colors.white, accentColor: colors.textPrimary,
secondaryTextColor: secondaryTextColor,
icon: Icons.terrain_rounded, icon: Icons.terrain_rounded,
), ),
), ),
Container(width: 1, height: 48, color: const Color(0xFF242E3D)), Container(width: 1, height: 48, color: colors.surfaceBorder),
Expanded( Expanded(
child: _buildMetricCell( child: _buildMetricCell(
label: 'HAZARDS', label: 'HAZARDS',
value: '${item.hazardCount}', value: '${item.hazardCount}',
subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED', subtext: item.hazardCount == 0 ? 'CLEAR' : 'FLAGGED',
accentColor: item.hazardCount == 0 accentColor: item.hazardCount == 0 ? successColor : warnColor,
? const Color(0xFF00E676) secondaryTextColor: secondaryTextColor,
: AppColors.hazardAmber,
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,
), ),
), ),
@ -308,6 +336,7 @@ class _FeedTelemetryInspectorModalState
required String value, required String value,
required String subtext, required String subtext,
required Color accentColor, required Color accentColor,
required Color secondaryTextColor,
required IconData icon, required IconData icon,
}) { }) {
return Padding( return Padding(
@ -317,18 +346,18 @@ class _FeedTelemetryInspectorModalState
children: [ children: [
Row( Row(
children: [ children: [
Icon(icon, size: 12, color: const Color(0xFF6B7A8D)), Icon(icon, size: 12, color: secondaryTextColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: Text(
label, label,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
color: Color(0xFF7B8B9E), color: secondaryTextColor,
), ),
), ),
), ),
@ -350,12 +379,12 @@ class _FeedTelemetryInspectorModalState
), ),
Text( Text(
subtext, subtext,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.4, letterSpacing: 0.4,
color: Color(0xFF5D6C7E), color: secondaryTextColor,
), ),
), ),
], ],
@ -363,13 +392,17 @@ class _FeedTelemetryInspectorModalState
); );
} }
Widget _buildTraceSelector(MotorSignalThemeColors colors) { Widget _buildTraceSelector(
MotorSignalThemeColors colors,
Color secondaryTextColor,
Color accentColor,
) {
return Container( return Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141920), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.surfaceBorder),
), ),
child: Row( child: Row(
children: [ children: [
@ -377,16 +410,25 @@ class _FeedTelemetryInspectorModalState
tab: _InspectorTraceTab.leanApex, tab: _InspectorTraceTab.leanApex,
label: 'LEAN & APEX', label: 'LEAN & APEX',
icon: Icons.rotate_left_rounded, icon: Icons.rotate_left_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
_buildTraceTabItem( _buildTraceTabItem(
tab: _InspectorTraceTab.speedGForce, tab: _InspectorTraceTab.speedGForce,
label: 'SPEED & G-FORCE', label: 'SPEED & G-FORCE',
icon: Icons.show_chart_rounded, icon: Icons.show_chart_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
_buildTraceTabItem( _buildTraceTabItem(
tab: _InspectorTraceTab.elevation, tab: _InspectorTraceTab.elevation,
label: 'ELEVATION', label: 'ELEVATION',
icon: Icons.terrain_rounded, icon: Icons.terrain_rounded,
colors: colors,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
), ),
], ],
), ),
@ -397,6 +439,9 @@ class _FeedTelemetryInspectorModalState
required _InspectorTraceTab tab, required _InspectorTraceTab tab,
required String label, required String label,
required IconData icon, required IconData icon,
required MotorSignalThemeColors colors,
required Color secondaryTextColor,
required Color accentColor,
}) { }) {
final isSelected = _selectedTab == tab; final isSelected = _selectedTab == tab;
return Expanded( return Expanded(
@ -407,10 +452,10 @@ class _FeedTelemetryInspectorModalState
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? const Color(0xFF202A36) : Colors.transparent, color: isSelected ? colors.surfaceCardPrimary : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: isSelected border: isSelected
? Border.all(color: AppColors.electricCyan.withValues(alpha: 0.4)) ? Border.all(color: accentColor.withValues(alpha: 0.4))
: null, : null,
), ),
child: Row( child: Row(
@ -419,7 +464,7 @@ class _FeedTelemetryInspectorModalState
Icon( Icon(
icon, icon,
size: 13, size: 13,
color: isSelected ? AppColors.electricCyan : const Color(0xFF6B7A8D), color: isSelected ? accentColor : secondaryTextColor,
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
Flexible( Flexible(
@ -431,7 +476,7 @@ class _FeedTelemetryInspectorModalState
fontSize: 10, fontSize: 10,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
letterSpacing: 0.3, letterSpacing: 0.3,
color: isSelected ? Colors.white : const Color(0xFF7A8B9E), color: isSelected ? colors.textPrimary : secondaryTextColor,
), ),
), ),
), ),
@ -446,6 +491,7 @@ class _FeedTelemetryInspectorModalState
SocialFeedItem item, SocialFeedItem item,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
) { ) {
final hasRecorded = item.hasRecordedTelemetry;
return Container( return Container(
height: 220, height: 220,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -455,25 +501,32 @@ class _FeedTelemetryInspectorModalState
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: GestureDetector( child: Stack(
onHorizontalDragUpdate: (details) { fit: StackFit.expand,
final box = context.findRenderObject() as RenderBox?; children: [
if (box != null) { LayoutBuilder(
final localX = details.localPosition.dx; builder: (context, graphConstraints) {
final graphWidth =
graphConstraints.maxWidth > 0 ? graphConstraints.maxWidth : 340.0;
return GestureDetector(
onHorizontalDragUpdate: hasRecorded
? (details) {
setState(() { setState(() {
_scrubFraction = (localX / 340).clamp(0.0, 1.0); _scrubFraction =
(details.localPosition.dx / graphWidth)
.clamp(0.0, 1.0);
}); });
} }
}, : null,
onTapDown: (details) { onTapDown: hasRecorded
final box = context.findRenderObject() as RenderBox?; ? (details) {
if (box != null) {
final localX = details.localPosition.dx;
setState(() { setState(() {
_scrubFraction = (localX / 340).clamp(0.0, 1.0); _scrubFraction =
(details.localPosition.dx / graphWidth)
.clamp(0.0, 1.0);
}); });
} }
}, : null,
child: CustomPaint( child: CustomPaint(
size: const Size(double.infinity, 220), size: const Size(double.infinity, 220),
painter: _TelemetryWaveformPainter( painter: _TelemetryWaveformPainter(
@ -482,6 +535,58 @@ class _FeedTelemetryInspectorModalState
scrubFraction: _scrubFraction, scrubFraction: _scrubFraction,
), ),
), ),
);
},
),
if (!hasRecorded)
Center(
child: Container(
key: const ValueKey('inspector_no_telemetry_state'),
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFF141A22).withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A3647)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.sensors_off_rounded,
size: 24,
color: Color(0xFF8B98A5),
),
SizedBox(height: 8),
Text(
'NO RECORDED TELEMETRY SAMPLES',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: Colors.white,
),
),
SizedBox(height: 4),
Text(
'Detailed multi-trace waveforms require recorded ride telemetry points.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11,
color: Color(0xFF8B98A5),
),
),
],
),
),
),
],
), ),
), ),
); );
@ -490,19 +595,22 @@ class _FeedTelemetryInspectorModalState
Widget _buildScrubberReadout( Widget _buildScrubberReadout(
SocialFeedItem item, SocialFeedItem item,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
Color secondaryTextColor,
Color accentColor,
) { ) {
final hasRecorded = item.hasRecordedTelemetry;
final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1); final scrubDist = (item.distanceMiles * _scrubFraction).toStringAsFixed(1);
final scrubPct = (_scrubFraction * 100).toInt(); final scrubPct = (_scrubFraction * 100).toInt();
// Instantaneous interpolated readings based on scrub point // Instantaneous interpolated readings from recorded telemetry samples
final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction); final (speedVal, leanVal, gVal) = _calculateScrubValues(item, _scrubFraction);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF141920), color: colors.surfaceCardSecondary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF222B38)), border: Border.all(color: colors.surfaceBorder),
), ),
child: Column( child: Column(
children: [ children: [
@ -512,22 +620,24 @@ class _FeedTelemetryInspectorModalState
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.touch_app_rounded, Icons.touch_app_rounded,
size: 14, size: 14,
color: AppColors.electricCyan, color: accentColor,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
child: Text( child: Text(
'POINT: $scrubDist MI ($scrubPct%)', hasRecorded
? 'POINT: $scrubDist MI ($scrubPct%)'
: 'TELEMETRY TRACE UNAVAILABLE',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 9.5, fontSize: 9.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
color: AppColors.electricCyan, color: accentColor,
), ),
), ),
), ),
@ -535,13 +645,13 @@ class _FeedTelemetryInspectorModalState
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'DRAG TO SCRUB', hasRecorded ? 'DRAG TO SCRUB' : 'SUMMARY ONLY',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF5D6C7E), color: secondaryTextColor,
), ),
), ),
], ],
@ -550,11 +660,11 @@ class _FeedTelemetryInspectorModalState
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildScrubStat('SPEED', '$speedVal MPH'), _buildScrubStat('SPEED', hasRecorded ? '$speedVal MPH' : '-- MPH', colors, secondaryTextColor),
Container(width: 1, height: 24, color: const Color(0xFF222B38)), Container(width: 1, height: 24, color: colors.surfaceBorder),
_buildScrubStat('LEAN', '$leanVal°'), _buildScrubStat('LEAN', hasRecorded ? '$leanVal°' : '--°', colors, secondaryTextColor),
Container(width: 1, height: 24, color: const Color(0xFF222B38)), Container(width: 1, height: 24, color: colors.surfaceBorder),
_buildScrubStat('LAT G', '${gVal}G'), _buildScrubStat('LAT G', hasRecorded ? '${gVal}G' : '--G', colors, secondaryTextColor),
], ],
), ),
], ],
@ -563,72 +673,99 @@ class _FeedTelemetryInspectorModalState
} }
(int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) { (int, int, String) _calculateScrubValues(SocialFeedItem item, double frac) {
// Generate realistic wave curves based on session metrics if (!item.hasRecordedTelemetry || item.telemetrySamples.isEmpty) {
final baseSpeed = 48.0; return (0, 0, '0.00');
final speedVariation = 26.0 * math.sin(frac * math.pi * 5);
final speed = (baseSpeed + speedVariation).clamp(20, 115).toInt();
final leanVariation = item.maxLeanAngleDeg * math.sin(frac * math.pi * 7).abs();
final lean = leanVariation.clamp(0, item.maxLeanAngleDeg).toInt();
final gForce = (0.2 + (lean / 60.0) * 0.9).toStringAsFixed(2);
return (speed, lean, gForce);
} }
Widget _buildScrubStat(String label, String value) { final samples = item.telemetrySamples;
final clamped = frac.clamp(0.0, 1.0);
final exactIdx = clamped * (samples.length - 1);
final lowerIdx = exactIdx.floor().clamp(0, samples.length - 1);
final upperIdx = exactIdx.ceil().clamp(0, samples.length - 1);
final t = exactIdx - lowerIdx;
final a = samples[lowerIdx];
final b = samples[upperIdx];
final aLean = FeedTelemetrySample.applyMovingLeanGate(
speedMph: a.speedMph,
leanAngleDeg: a.leanAngleDeg,
).abs();
final bLean = FeedTelemetrySample.applyMovingLeanGate(
speedMph: b.speedMph,
leanAngleDeg: b.leanAngleDeg,
).abs();
final speed = (a.speedMph + (b.speedMph - a.speedMph) * t).round();
final lean = (aLean + (bLean - aLean) * t).round();
final latG = (a.lateralG.abs() + (b.lateralG.abs() - a.lateralG.abs()) * t)
.toStringAsFixed(2);
return (speed, lean, latG);
}
Widget _buildScrubStat(
String label,
String value,
MotorSignalThemeColors colors,
Color secondaryTextColor,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF6B7A8D), color: secondaryTextColor,
), ),
), ),
Text( Text(
value, value,
style: const TextStyle( style: TextStyle(
fontFamily: AppTypography.fontTelemetry, fontFamily: AppTypography.fontTelemetry,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
], ],
); );
} }
Widget _buildReplayButton(BuildContext context) { Widget _buildReplayButton(
BuildContext context,
MotorSignalThemeColors colors,
Color accentColor,
) {
return SizedBox( return SizedBox(
height: 48, height: 48,
child: OutlinedButton.icon( child: OutlinedButton.icon(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
backgroundColor: const Color(0xFF161E28), backgroundColor: colors.surfaceCardSecondary,
side: BorderSide( side: BorderSide(
color: AppColors.electricCyan.withValues(alpha: 0.5), color: accentColor.withValues(alpha: 0.5),
width: 1.5, width: 1.5,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
icon: const Icon( icon: Icon(
Icons.play_circle_fill_rounded, Icons.play_circle_fill_rounded,
color: AppColors.electricCyan, color: accentColor,
size: 20, size: 20,
), ),
label: const Text( label: Text(
'OPEN FULL RIDE REPLAY', 'OPEN FULL RIDE REPLAY',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 0.8, letterSpacing: 0.8,
color: Colors.white, color: colors.textPrimary,
), ),
), ),
onPressed: () { onPressed: () {
@ -640,7 +777,8 @@ class _FeedTelemetryInspectorModalState
} }
} }
/// Custom painter rendering multi-trace telemetry waveforms and apex markers. /// Custom painter rendering recorded multi-trace telemetry waveforms and apex markers.
/// Never fabricates synthetic curves when recorded telemetry samples are absent.
class _TelemetryWaveformPainter extends CustomPainter { class _TelemetryWaveformPainter extends CustomPainter {
final _InspectorTraceTab tab; final _InspectorTraceTab tab;
final SocialFeedItem item; final SocialFeedItem item;
@ -671,6 +809,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint); canvas.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
} }
if (!item.hasRecordedTelemetry || item.telemetrySamples.length < 2) {
return;
}
switch (tab) { switch (tab) {
case _InspectorTraceTab.leanApex: case _InspectorTraceTab.leanApex:
_paintLeanWaveform(canvas, size); _paintLeanWaveform(canvas, size);
@ -701,6 +843,7 @@ class _TelemetryWaveformPainter extends CustomPainter {
final w = size.width; final w = size.width;
final h = size.height; final h = size.height;
final midY = h * 0.52; final midY = h * 0.52;
final samples = item.telemetrySamples;
// Baseline (0° upright) // Baseline (0° upright)
final baselinePaint = Paint() final baselinePaint = Paint()
@ -710,24 +853,30 @@ class _TelemetryWaveformPainter extends CustomPainter {
canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint); canvas.drawLine(Offset(0, midY), Offset(w, midY), baselinePaint);
final path = Path(); final path = Path();
final points = <Offset>[]; var peakIndex = 0;
const steps = 100; var peakAbsLean = 0.0;
final maxLean = item.maxLeanAngleDeg; var peakPoint = Offset(0, midY);
for (var i = 0; i <= steps; i++) { for (var i = 0; i < samples.length; i++) {
final t = i / steps; final s = samples[i];
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
final x = t * w; final x = t * w;
// Multi-sine wave generating cornering sections final leanAngle = FeedTelemetrySample.applyMovingLeanGate(
final wave = math.sin(t * math.pi * 6) * 0.7 + speedMph: s.speedMph,
math.sin(t * math.pi * 12) * 0.3; leanAngleDeg: s.leanAngleDeg,
final leanAngle = wave * maxLean; );
final y = midY - (leanAngle / 60.0) * (h * 0.38); final y = midY - (leanAngle / 65.0) * (h * 0.38);
points.add(Offset(x, y)); final pt = Offset(x, y);
if (i == 0) { if (i == 0) {
path.moveTo(x, y); path.moveTo(x, y);
} else { } else {
path.lineTo(x, y); path.lineTo(x, y);
} }
if (leanAngle.abs() >= peakAbsLean) {
peakAbsLean = leanAngle.abs();
peakIndex = i;
peakPoint = pt;
}
} }
// Draw Gradient Area under Lean Curve // Draw Gradient Area under Lean Curve
@ -754,49 +903,59 @@ class _TelemetryWaveformPainter extends CustomPainter {
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawPath(path, linePaint); canvas.drawPath(path, linePaint);
// Draw Apex Markers (Peak Corners) if (peakIndex >= 0 && peakAbsLean > 0) {
final apexMarkers = [ const markerColor = Color(0xFF00E676);
(0.26, '${item.maxLeanAngleDeg.toStringAsFixed(0)}° APEX', const Color(0xFF00E676)), final dotPaint = Paint()..color = markerColor;
(0.68, '${(item.maxLeanAngleDeg * 0.94).toStringAsFixed(0)}° SWEEP', const Color(0xFFFFB300)), canvas.drawCircle(peakPoint, 5.0, dotPaint);
];
for (final marker in apexMarkers) {
final mx = marker.$1 * w;
final wave = math.sin(marker.$1 * math.pi * 6) * 0.7 +
math.sin(marker.$1 * math.pi * 12) * 0.3;
final my = midY - (wave * maxLean / 60.0) * (h * 0.38);
final dotPaint = Paint()..color = marker.$3;
canvas.drawCircle(Offset(mx, my), 5.0, dotPaint);
final ringPaint = Paint() final ringPaint = Paint()
..color = marker.$3.withValues(alpha: 0.4) ..color = markerColor.withValues(alpha: 0.4)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 3; ..strokeWidth = 3;
canvas.drawCircle(Offset(mx, my), 8.0, ringPaint); canvas.drawCircle(peakPoint, 8.0, ringPaint);
// Label Pill _drawTextBadge(
_drawTextBadge(canvas, marker.$2, Offset(mx - 24, my - 24), marker.$3); canvas,
'${peakAbsLean.toStringAsFixed(0)}° APEX',
Offset(
(peakPoint.dx - 24).clamp(6.0, w - 70.0),
(peakPoint.dy - 24).clamp(6.0, h - 24.0),
),
markerColor,
);
} }
} }
void _paintSpeedWaveform(Canvas canvas, Size size) { void _paintSpeedWaveform(Canvas canvas, Size size) {
final w = size.width; final w = size.width;
final h = size.height; final h = size.height;
final samples = item.telemetrySamples;
var maxSpeed = 1.0;
for (final s in samples) {
if (s.speedMph > maxSpeed) maxSpeed = s.speedMph;
}
final speedScale = math.max(maxSpeed * 1.15, 30.0);
final path = Path(); final path = Path();
const steps = 100; var peakSpeed = 0.0;
var peakPoint = Offset(w * 0.5, h * 0.5);
for (var i = 0; i <= steps; i++) { for (var i = 0; i < samples.length; i++) {
final t = i / steps; final s = samples[i];
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
final x = t * w; final x = t * w;
final speed = 40.0 + 35.0 * math.sin(t * math.pi * 4).abs() + 15.0 * math.cos(t * math.pi * 8); final y = (h * 0.90 - (s.speedMph / speedScale) * (h * 0.75))
final y = h * 0.90 - (speed / 120.0) * (h * 0.75); .clamp(h * 0.1, h * 0.90);
if (i == 0) { if (i == 0) {
path.moveTo(x, y); path.moveTo(x, y);
} else { } else {
path.lineTo(x, y); path.lineTo(x, y);
} }
if (s.speedMph >= peakSpeed) {
peakSpeed = s.speedMph;
peakPoint = Offset(x, y);
}
} }
final fillPath = Path.from(path) final fillPath = Path.from(path)
@ -821,26 +980,49 @@ class _TelemetryWaveformPainter extends CustomPainter {
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawPath(path, linePaint); canvas.drawPath(path, linePaint);
_drawTextBadge(canvas, 'TOP SPEED', Offset(w * 0.5 - 28, h * 0.16), const Color(0xFFFFB300)); _drawTextBadge(
canvas,
'${peakSpeed.round()} MPH TOP SPEED',
Offset(
(peakPoint.dx - 36).clamp(6.0, w - 100.0),
(peakPoint.dy - 22).clamp(6.0, h - 24.0),
),
const Color(0xFFFFB300),
);
} }
void _paintElevationWaveform(Canvas canvas, Size size) { void _paintElevationWaveform(Canvas canvas, Size size) {
final w = size.width; final w = size.width;
final h = size.height; final h = size.height;
final samples = item.telemetrySamples;
var minAlt = samples.first.altitudeFt;
var maxAlt = samples.first.altitudeFt;
for (final s in samples) {
if (s.altitudeFt < minAlt) minAlt = s.altitudeFt;
if (s.altitudeFt > maxAlt) maxAlt = s.altitudeFt;
}
final altSpan = (maxAlt - minAlt).abs();
final path = Path(); final path = Path();
const steps = 100; var crestPoint = Offset(w * 0.5, h * 0.25);
var highestAlt = minAlt;
for (var i = 0; i <= steps; i++) { for (var i = 0; i < samples.length; i++) {
final t = i / steps; final s = samples[i];
final t = samples.length == 1 ? 0.0 : i / (samples.length - 1);
final x = t * w; final x = t * w;
final elevationNorm = 0.2 + 0.65 * (math.sin(t * math.pi * 2.2) * 0.5 + 0.5); final norm = altSpan < 1.0 ? 0.5 : (s.altitudeFt - minAlt) / altSpan;
final y = h * 0.92 - elevationNorm * (h * 0.75); final y = h * 0.92 - (0.15 + norm * 0.70) * (h * 0.75);
if (i == 0) { if (i == 0) {
path.moveTo(x, y); path.moveTo(x, y);
} else { } else {
path.lineTo(x, y); path.lineTo(x, y);
} }
if (s.altitudeFt >= highestAlt) {
highestAlt = s.altitudeFt;
crestPoint = Offset(x, y);
}
} }
final fillPath = Path.from(path) final fillPath = Path.from(path)
@ -868,7 +1050,10 @@ class _TelemetryWaveformPainter extends CustomPainter {
_drawTextBadge( _drawTextBadge(
canvas, canvas,
'+${item.elevationFt} FT CREST', '+${item.elevationFt} FT CREST',
Offset(w * 0.68 - 36, h * 0.18), Offset(
(crestPoint.dx - 36).clamp(6.0, w - 95.0),
(crestPoint.dy - 22).clamp(6.0, h - 24.0),
),
const Color(0xFF448AFF), const Color(0xFF448AFF),
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as ll; import 'package:latlong2/latlong.dart' as ll;
import '../routes/aws_location_route_provider.dart'; import '../routes/aws_location_route_provider.dart';
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite). /// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
class AwsMapProvider implements AppMapProvider { class AwsMapProvider implements AppMapProvider {
@ -98,8 +99,10 @@ class _AwsMapView extends StatefulWidget {
State<_AwsMapView> createState() => _AwsMapViewState(); State<_AwsMapView> createState() => _AwsMapViewState();
} }
class _AwsMapViewState extends State<_AwsMapView> { class _AwsMapViewState extends State<_AwsMapView>
with TickerProviderStateMixin {
late final MapController _mapController; late final MapController _mapController;
AnimationController? _animController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
@ -107,6 +110,56 @@ class _AwsMapViewState extends State<_AwsMapView> {
void initState() { void initState() {
super.initState(); super.initState();
_mapController = MapController(); _mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _animateCameraTo({
required ll.LatLng target,
required double targetZoom,
required double targetBearing,
required Duration duration,
Curve curve = Curves.easeOutCubic,
}) {
if (!mounted) return;
_animController?.stop();
_animController?.dispose();
_animController = null;
final startCenter = _mapController.camera.center;
final startZoom = _mapController.camera.zoom;
final startBearing = _mapController.camera.rotation;
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
final endBearing = startBearing + bearingDiff;
_animController = AnimationController(
vsync: this,
duration: duration,
);
final curved = CurvedAnimation(parent: _animController!, curve: curve);
final startLat = startCenter.latitude;
final startLng = startCenter.longitude;
final endLat = target.latitude;
final endLng = target.longitude;
_animController!.addListener(() {
if (!mounted) return;
final t = curved.value;
final lat = startLat + (endLat - startLat) * t;
final lng = startLng + (endLng - startLng) * t;
final z = startZoom + (targetZoom - startZoom) * t;
final b = startBearing + (endBearing - startBearing) * t;
try {
_mapController.move(ll.LatLng(lat, lng), z);
_mapController.rotate(normalizeAngle(b));
} catch (_) {}
});
_animController!.forward();
} }
@override @override
@ -124,48 +177,56 @@ class _AwsMapViewState extends State<_AwsMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.000005;
final shouldMove = justBecameFollowMode || final intervalMs = _lastFollowTime == null
_lastFollowPos == null || ? 250
zoomChanged || : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) { final isInitialOrLargeJump =
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
if (isInitialOrLargeJump) {
_animController?.stop();
_lastFollowPos = centerLl; _lastFollowPos = centerLl;
_lastFollowTime = now; _lastFollowTime = now;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try { try {
_mapController.move(centerLl, widget.zoom); _mapController.move(centerLl, widget.zoom);
_mapController.rotate(normalizeAngle(widget.bearing));
} catch (_) {} } catch (_) {}
} else if (hasMoved || bearingChanged || zoomChanged) {
_lastFollowPos = centerLl;
_lastFollowTime = now;
_animateCameraTo(
target: centerLl,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
);
} }
}); } else {
} if (bearingChanged) {
_animateCameraTo(
target: _mapController.camera.center,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (zoomChanged) { } else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try { try {
_mapController.move(_mapController.camera.center, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } catch (_) {}
} }
});
}
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
} }
} }
@override @override
void dispose() { void dispose() {
_animController?.stop();
_animController?.dispose();
_animController = null;
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -2,10 +2,9 @@ import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps; import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps;
import '../../core/theme/map_style.dart'; import '../../core/theme/map_style.dart';
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
class GoogleMapProvider implements AppMapProvider { class GoogleMapProvider implements AppMapProvider {
gmaps.GoogleMapController? _controller;
@override @override
AppMapType get type => AppMapType.googleMaps; AppMapType get type => AppMapType.googleMaps;
@ -25,15 +24,122 @@ class GoogleMapProvider implements AppMapProvider {
required ValueChanged<AppLatLng>? onPositionChanged, required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction, required VoidCallback? onUserInteraction,
}) { }) {
final target = gmaps.LatLng(center.latitude, center.longitude); return _GoogleMapView(
center: center,
if (isFollowMode && _controller != null) { zoom: zoom,
_controller!.animateCamera( bearing: bearing,
gmaps.CameraUpdate.newLatLngZoom(target, zoom), markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
onPositionChanged: onPositionChanged,
onUserInteraction: onUserInteraction,
); );
} }
}
final gMarkers = markers.map((m) { class _GoogleMapView extends StatefulWidget {
final AppLatLng center;
final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
final ValueChanged<AppLatLng>? onPositionChanged;
final VoidCallback? onUserInteraction;
const _GoogleMapView({
required this.center,
required this.zoom,
this.bearing = 0.0,
required this.markers,
required this.polylines,
required this.isFollowMode,
required this.onPositionChanged,
required this.onUserInteraction,
});
@override
State<_GoogleMapView> createState() => _GoogleMapViewState();
}
class _GoogleMapViewState extends State<_GoogleMapView> {
gmaps.GoogleMapController? _controller;
gmaps.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
bool _isProgrammaticMove = false;
static const int _followThrottleMs = 250;
@override
void initState() {
super.initState();
_lastFollowPos = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
}
Future<void> _moveCamera(gmaps.LatLng target, double zoom, double bearing) async {
final controller = _controller;
if (controller == null) return;
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
_isProgrammaticMove = true;
try {
await controller.animateCamera(
gmaps.CameraUpdate.newCameraPosition(
gmaps.CameraPosition(
target: target,
zoom: zoom,
bearing: normalizeAngle(bearing),
),
),
);
} catch (_) {
} finally {
_isProgrammaticMove = false;
}
}
@override
void didUpdateWidget(covariant _GoogleMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller == null) return;
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
if (widget.isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (target.latitude - _lastFollowPos!.latitude).abs() +
(target.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
if (justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
bearingChanged ||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001))) {
_moveCamera(target, widget.zoom, widget.bearing);
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final gMarkers = widget.markers.map((m) {
return gmaps.Marker( return gmaps.Marker(
markerId: gmaps.MarkerId(m.id), markerId: gmaps.MarkerId(m.id),
position: gmaps.LatLng(m.position.latitude, m.position.longitude), position: gmaps.LatLng(m.position.latitude, m.position.longitude),
@ -42,7 +148,7 @@ class GoogleMapProvider implements AppMapProvider {
); );
}).toSet(); }).toSet();
final gPolylines = polylines.map((p) { final gPolylines = widget.polylines.map((p) {
return gmaps.Polyline( return gmaps.Polyline(
polylineId: gmaps.PolylineId(p.id), polylineId: gmaps.PolylineId(p.id),
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(), points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
@ -58,7 +164,8 @@ class GoogleMapProvider implements AppMapProvider {
key: ValueKey('google_map_$sizeKey'), key: ValueKey('google_map_$sizeKey'),
initialCameraPosition: gmaps.CameraPosition( initialCameraPosition: gmaps.CameraPosition(
target: target, target: target,
zoom: zoom, zoom: widget.zoom,
bearing: widget.bearing,
), ),
markers: gMarkers, markers: gMarkers,
polylines: gPolylines, polylines: gPolylines,
@ -68,13 +175,21 @@ class GoogleMapProvider implements AppMapProvider {
compassEnabled: false, compassEnabled: false,
onMapCreated: (controller) { onMapCreated: (controller) {
_controller = controller; _controller = controller;
if (widget.isFollowMode) {
_moveCamera(target, widget.zoom, widget.bearing);
}
}, },
onCameraMoveStarted: () { onCameraMoveStarted: () {
onUserInteraction?.call(); if (!_isProgrammaticMove) {
widget.onUserInteraction?.call();
}
}, },
onCameraMove: (position) { onCameraMove: (position) {
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude)); widget.onPositionChanged?.call(
AppLatLng(position.target.latitude, position.target.longitude),
);
}, },
); );
} }
} }

View File

@ -94,3 +94,69 @@ final mapFollowStateProvider =
StateNotifierProvider<MapFollowNotifier, bool>((ref) { StateNotifierProvider<MapFollowNotifier, bool>((ref) {
return MapFollowNotifier(); return MapFollowNotifier();
}); });
/// Map orientation mode: North Up vs Heading Up.
enum MapOrientation {
northUp,
headingUp;
String get displayName => this == northUp ? 'North Up' : 'Heading Up';
}
/// Computes the shortest angular difference in degrees from [from] to [to].
/// Result is in range (-180.0, 180.0].
/// Handles circular transitions across the 0°/360° discontinuity:
/// e.g. from 359° to 1° yields +2°, not -358°.
double shortestAngleDiff(double from, double to) {
return ((to - from + 540.0) % 360.0) - 180.0;
}
/// Normalizes an angle in degrees into [0.0, 360.0).
double normalizeAngle(double angle) {
return (angle % 360.0 + 360.0) % 360.0;
}
class MapOrientationNotifier extends StateNotifier<MapOrientation> {
static const String _prefKey = 'selected_map_orientation';
MapOrientationNotifier() : super(MapOrientation.northUp) {
_loadFromPrefs();
}
Future<void> _loadFromPrefs() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedVal = prefs.getString(_prefKey);
if (savedVal == 'headingUp') {
state = MapOrientation.headingUp;
} else if (savedVal == 'northUp') {
state = MapOrientation.northUp;
}
} catch (_) {}
}
Future<void> toggle() async {
final next = state == MapOrientation.northUp
? MapOrientation.headingUp
: MapOrientation.northUp;
state = next;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, next.name);
} catch (_) {}
}
Future<void> setOrientation(MapOrientation orientation) async {
state = orientation;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, orientation.name);
} catch (_) {}
}
}
final mapOrientationProvider =
StateNotifierProvider<MapOrientationNotifier, MapOrientation>((ref) {
return MapOrientationNotifier();
});

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' as ll; import 'package:latlong2/latlong.dart' as ll;
import 'map_provider_interface.dart'; import 'map_provider_interface.dart';
import 'map_providers.dart';
class OpenStreetMapProvider implements AppMapProvider { class OpenStreetMapProvider implements AppMapProvider {
@override @override
@ -65,8 +66,10 @@ class _OsmMapView extends StatefulWidget {
State<_OsmMapView> createState() => _OsmMapViewState(); State<_OsmMapView> createState() => _OsmMapViewState();
} }
class _OsmMapViewState extends State<_OsmMapView> { class _OsmMapViewState extends State<_OsmMapView>
with TickerProviderStateMixin {
late final MapController _mapController; late final MapController _mapController;
AnimationController? _animController;
ll.LatLng? _lastFollowPos; ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime; DateTime? _lastFollowTime;
@ -74,6 +77,56 @@ class _OsmMapViewState extends State<_OsmMapView> {
void initState() { void initState() {
super.initState(); super.initState();
_mapController = MapController(); _mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _animateCameraTo({
required ll.LatLng target,
required double targetZoom,
required double targetBearing,
required Duration duration,
Curve curve = Curves.easeOutCubic,
}) {
if (!mounted) return;
_animController?.stop();
_animController?.dispose();
_animController = null;
final startCenter = _mapController.camera.center;
final startZoom = _mapController.camera.zoom;
final startBearing = _mapController.camera.rotation;
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
final endBearing = startBearing + bearingDiff;
_animController = AnimationController(
vsync: this,
duration: duration,
);
final curved = CurvedAnimation(parent: _animController!, curve: curve);
final startLat = startCenter.latitude;
final startLng = startCenter.longitude;
final endLat = target.latitude;
final endLng = target.longitude;
_animController!.addListener(() {
if (!mounted) return;
final t = curved.value;
final lat = startLat + (endLat - startLat) * t;
final lng = startLng + (endLng - startLng) * t;
final z = startZoom + (targetZoom - startZoom) * t;
final b = startBearing + (endBearing - startBearing) * t;
try {
_mapController.move(ll.LatLng(lat, lng), z);
_mapController.rotate(normalizeAngle(b));
} catch (_) {}
});
_animController!.forward();
} }
@override @override
@ -90,48 +143,56 @@ class _OsmMapViewState extends State<_OsmMapView> {
? 1.0 ? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() + : (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs(); (centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.000005;
final shouldMove = justBecameFollowMode || final intervalMs = _lastFollowTime == null
_lastFollowPos == null || ? 250
zoomChanged || : now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
if (shouldMove) { final isInitialOrLargeJump =
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
if (isInitialOrLargeJump) {
_animController?.stop();
_lastFollowPos = centerLl; _lastFollowPos = centerLl;
_lastFollowTime = now; _lastFollowTime = now;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try { try {
_mapController.move(centerLl, widget.zoom); _mapController.move(centerLl, widget.zoom);
_mapController.rotate(normalizeAngle(widget.bearing));
} catch (_) {} } catch (_) {}
} else if (hasMoved || bearingChanged || zoomChanged) {
_lastFollowPos = centerLl;
_lastFollowTime = now;
_animateCameraTo(
target: centerLl,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
);
} }
}); } else {
} if (bearingChanged) {
_animateCameraTo(
target: _mapController.camera.center,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (zoomChanged) { } else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try { try {
_mapController.move(_mapController.camera.center, widget.zoom); _mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {} } catch (_) {}
} }
});
}
if (bearingChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.rotate(widget.bearing);
} catch (_) {}
}
});
} }
} }
@override @override
void dispose() { void dispose() {
_animController?.stop();
_animController?.dispose();
_animController = null;
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }

View File

@ -253,6 +253,66 @@ class AwsLocationRouteProvider implements RouteProvider {
} }
} }
/// Privacy-safe coarse reverse geocode for a geographic point.
///
/// Returns a coarse region label (e.g. "Saratoga, CA") suitable for privacy-safe
/// starting location labels without exposing house numbers, street names, or exact GPS coordinates.
///
/// Returns null if unavailable or if reverse-geocoding fails.
Future<String?> reverseGeocodeCoarse(AppLatLng position) async {
try {
final uri = Uri.parse(
'https://places.geo.$region.amazonaws.com/v2/reverse-geocode?key=$apiKey',
);
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'QueryPosition': [position.longitude, position.latitude],
'Filter': {
'IncludeCountries': ['USA'],
},
}),
)
.timeout(const Duration(seconds: 6));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
final items = data['ResultItems'] as List<dynamic>? ?? [];
if (items.isEmpty) return null;
final first = items.first as Map<String, dynamic>;
final address = first['Address'] as Map<String, dynamic>?;
if (address == null) return null;
// Extract coarse locality and region/state — strictly avoid street addresses or exact house numbers
final locality = address['Locality']?.toString().trim();
final regionStr = address['Region']?.toString().trim();
final subRegion = address['SubRegion']?.toString().trim();
if (locality != null &&
locality.isNotEmpty &&
regionStr != null &&
regionStr.isNotEmpty) {
return '$locality, $regionStr';
} else if (locality != null && locality.isNotEmpty) {
return locality;
} else if (subRegion != null &&
subRegion.isNotEmpty &&
regionStr != null &&
regionStr.isNotEmpty) {
return '$subRegion, $regionStr';
} else if (regionStr != null && regionStr.isNotEmpty) {
return regionStr;
}
return null;
} catch (_) {
return null;
}
}
@override @override
Future<RouteCalculationResult> calculateRoute( Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, { List<RouteStop> stops, {

View File

@ -1,5 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
DateTime? _parseDateTime(dynamic value) { DateTime? _parseDateTime(dynamic value) {
@ -134,10 +135,103 @@ enum FeedSnapshotType {
scenicPhoto, scenicPhoto,
} }
/// A recorded telemetry sample point attached to a shared social feed session.
@immutable
class FeedTelemetrySample {
/// Matches RideStateMachine._processStats 3.0 MPH moving gate and 65.0°
/// physical bound so stationary kickstand/dismount frames never enter
/// published lean traces or apex calculations.
static const double movingLeanGateSpeedMph = 3.0;
static const double maxPhysicalLeanDeg = 65.0;
static double applyMovingLeanGate({
required double? speedMph,
required double? leanAngleDeg,
}) {
if (speedMph == null ||
!speedMph.isFinite ||
speedMph < movingLeanGateSpeedMph ||
leanAngleDeg == null ||
!leanAngleDeg.isFinite ||
leanAngleDeg.abs() > maxPhysicalLeanDeg) {
return 0.0;
}
return leanAngleDeg;
}
final double progress; // 0.0 to 1.0 along the session
final double speedMph;
final double leanAngleDeg; // signed degrees (-left, +right)
final double lateralG;
final double altitudeFt;
final double? latitude;
final double? longitude;
final DateTime? timestamp;
const FeedTelemetrySample({
required this.progress,
required this.speedMph,
required this.leanAngleDeg,
this.lateralG = 0.0,
this.altitudeFt = 0.0,
this.latitude,
this.longitude,
this.timestamp,
});
FeedTelemetrySample withMovingLeanGate() {
final gatedLean = applyMovingLeanGate(
speedMph: speedMph,
leanAngleDeg: leanAngleDeg,
);
if (gatedLean == leanAngleDeg) return this;
return FeedTelemetrySample(
progress: progress,
speedMph: speedMph,
leanAngleDeg: gatedLean,
lateralG: lateralG,
altitudeFt: altitudeFt,
latitude: latitude,
longitude: longitude,
timestamp: timestamp,
);
}
Map<String, dynamic> toJson() => {
'progress': progress,
'speedMph': speedMph,
'leanAngleDeg': leanAngleDeg,
'lateralG': lateralG,
'altitudeFt': altitudeFt,
if (latitude != null) 'lat': latitude,
if (longitude != null) 'lng': longitude,
if (timestamp != null) 'timestamp': _dateTimeToFirestore(timestamp),
};
factory FeedTelemetrySample.fromJson(Map<String, dynamic> json) {
return FeedTelemetrySample(
progress: ((json['progress'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0),
speedMph: (json['speedMph'] as num?)?.toDouble() ?? 0.0,
leanAngleDeg: (json['leanAngleDeg'] as num?)?.toDouble() ?? 0.0,
lateralG: (json['lateralG'] as num?)?.toDouble() ?? 0.0,
altitudeFt: (json['altitudeFt'] as num?)?.toDouble() ?? 0.0,
latitude: (json['lat'] as num?)?.toDouble(),
longitude: (json['lng'] as num?)?.toDouble(),
timestamp: _parseDateTime(json['timestamp']),
);
}
}
/// A community social feed item (Stitch SOC-01). /// A community social feed item (Stitch SOC-01).
@immutable @immutable
class SocialFeedItem { class SocialFeedItem {
/// Minimum geodesic displacement required for GPS verification: 0.05 miles (~80.47 meters).
static const double minimumVerifiedDisplacementMiles = 0.05;
static const double minimumVerifiedDisplacementMeters =
minimumVerifiedDisplacementMiles * 1609.344;
final String id; final String id;
final int? rideId;
final String authorUid; final String authorUid;
final String authorDisplayName; final String authorDisplayName;
final String authorHandle; final String authorHandle;
@ -153,7 +247,7 @@ class SocialFeedItem {
final double distanceMiles; final double distanceMiles;
final int durationSeconds; final int durationSeconds;
final double maxLeanAngleDeg; final double maxLeanAngleDeg;
final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK' final String maxLeanSide; // 'RIGHT', 'LEFT', 'PEAK', 'NONE'
final String paceFactor; // e.g. '1.32x Spirited' final String paceFactor; // e.g. '1.32x Spirited'
final int hazardCount; // e.g. 0 final int hazardCount; // e.g. 0
final int elevationFt; // e.g. +2140 final int elevationFt; // e.g. +2140
@ -163,9 +257,10 @@ class SocialFeedItem {
final String? regionName; final String? regionName;
final bool isFeatured; final bool isFeatured;
// Visual Snapshot Module // Visual Snapshot & Recorded Telemetry Module
final FeedSnapshotType snapshotType; final FeedSnapshotType snapshotType;
final List<AppLatLng> routePoints; final List<AppLatLng> routePoints;
final List<FeedTelemetrySample> telemetrySamples;
final String? photoUrl; final String? photoUrl;
final String? photoCaption; final String? photoCaption;
final bool isGpsVerified; final bool isGpsVerified;
@ -178,6 +273,7 @@ class SocialFeedItem {
const SocialFeedItem({ const SocialFeedItem({
required this.id, required this.id,
this.rideId,
required this.authorUid, required this.authorUid,
required this.authorDisplayName, required this.authorDisplayName,
required this.authorHandle, required this.authorHandle,
@ -194,21 +290,164 @@ class SocialFeedItem {
this.maxLeanSide = 'RIGHT', this.maxLeanSide = 'RIGHT',
this.paceFactor = '1.2x Spirited', this.paceFactor = '1.2x Spirited',
this.hazardCount = 0, this.hazardCount = 0,
this.elevationFt = 1200, this.elevationFt = 0,
this.discipline = RidingDiscipline.canyonSport, this.discipline = RidingDiscipline.canyonSport,
this.regionName, this.regionName,
this.isFeatured = false, this.isFeatured = false,
this.snapshotType = FeedSnapshotType.routeTrack, this.snapshotType = FeedSnapshotType.routeTrack,
this.routePoints = const [], this.routePoints = const [],
this.telemetrySamples = const [],
this.photoUrl, this.photoUrl,
this.photoCaption, this.photoCaption,
this.isGpsVerified = true, this.isGpsVerified = false,
this.isDemo = false, this.isDemo = false,
this.fistBumpCount = 0, this.fistBumpCount = 0,
this.fistBumpUserIds = const [], this.fistBumpUserIds = const [],
this.comments = const [], this.comments = const [],
}); });
/// Evaluates whether a set of ride metrics, GPS route coordinates, and
/// recorded telemetry samples satisfy authentic GPS verification criteria.
static bool verifyGpsCriteria({
required bool isDemo,
required double distanceMiles,
required int durationSeconds,
required List<AppLatLng> routePoints,
List<FeedTelemetrySample> telemetrySamples = const [],
}) {
if (isDemo) return false;
if (!distanceMiles.isFinite || distanceMiles <= 0.0) return false;
if (durationSeconds <= 0) return false;
if (telemetrySamples.length < 2) return false;
double? firstProgress;
double? prevProgress;
DateTime? firstTimestamp;
DateTime? prevTimestamp;
int timestampCount = 0;
final validSamplePoints = <AppLatLng>[];
for (final sample in telemetrySamples) {
if (!sample.progress.isFinite ||
sample.progress < 0.0 ||
sample.progress > 1.0 ||
!sample.speedMph.isFinite ||
sample.speedMph < 0.0 ||
!sample.leanAngleDeg.isFinite ||
!sample.lateralG.isFinite ||
!sample.altitudeFt.isFinite) {
return false;
}
if (prevProgress != null && sample.progress < prevProgress) {
return false;
}
firstProgress ??= sample.progress;
prevProgress = sample.progress;
if (sample.timestamp != null) {
if (prevTimestamp != null &&
sample.timestamp!.isBefore(prevTimestamp)) {
return false;
}
firstTimestamp ??= sample.timestamp;
prevTimestamp = sample.timestamp;
timestampCount++;
}
final lat = sample.latitude;
final lng = sample.longitude;
if (lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
lat.abs() <= 90.0 &&
lng.abs() <= 180.0 &&
(lat != 0.0 || lng != 0.0)) {
validSamplePoints.add(AppLatLng(lat, lng));
}
}
// Require a positive recorded span across telemetry samples.
if (firstProgress == null ||
prevProgress == null ||
(prevProgress - firstProgress) <= 0.0) {
return false;
}
if (timestampCount > 0) {
if (timestampCount < 2 ||
firstTimestamp == null ||
prevTimestamp == null ||
prevTimestamp.difference(firstTimestamp).inMilliseconds <= 0) {
return false;
}
}
if (!_hasMinimumGeodesicDisplacement(validSamplePoints)) {
return false;
}
final validPoints = routePoints.where((p) {
return p.latitude.isFinite &&
p.longitude.isFinite &&
p.latitude.abs() <= 90.0 &&
p.longitude.abs() <= 180.0 &&
(p.latitude != 0.0 || p.longitude != 0.0);
}).toList();
return _hasMinimumGeodesicDisplacement(validPoints);
}
/// Require at least two coordinates separated by >= 0.05 miles (~80.47 m)
/// measured geodesically via [Geolocator.distanceBetween].
static bool _hasMinimumGeodesicDisplacement(List<AppLatLng> points) {
if (points.length < 2) return false;
for (int i = 0; i < points.length - 1; i++) {
final origin = points[i];
for (int j = i + 1; j < points.length; j++) {
final target = points[j];
final distMeters = Geolocator.distanceBetween(
origin.latitude,
origin.longitude,
target.latitude,
target.longitude,
);
if (distMeters.isFinite &&
distMeters >= minimumVerifiedDisplacementMeters) {
return true;
}
}
}
return false;
}
/// True only when this post is flagged as GPS-verified AND backed by valid,
/// non-demo GPS route coordinates, recorded telemetry samples, positive
/// distance, and positive duration.
bool get hasVerifiedGps =>
isGpsVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: routePoints,
telemetrySamples: telemetrySamples,
);
/// True when the post is a non-demo ride containing recorded route coordinates that can be plotted.
bool get hasRecordedRoute =>
!isDemo &&
routePoints
.where((p) =>
p.latitude.isFinite &&
p.longitude.isFinite &&
(p.latitude != 0.0 || p.longitude != 0.0))
.length >=
2;
/// True when the post contains recorded telemetry waveform samples.
bool get hasRecordedTelemetry => !isDemo && telemetrySamples.length >= 2;
bool isFistBumpedBy(String? uid) { bool isFistBumpedBy(String? uid) {
if (uid == null) return false; if (uid == null) return false;
return fistBumpUserIds.contains(uid); return fistBumpUserIds.contains(uid);
@ -216,6 +455,7 @@ class SocialFeedItem {
SocialFeedItem copyWith({ SocialFeedItem copyWith({
String? id, String? id,
int? rideId,
String? authorUid, String? authorUid,
String? authorDisplayName, String? authorDisplayName,
String? authorHandle, String? authorHandle,
@ -238,6 +478,7 @@ class SocialFeedItem {
bool? isFeatured, bool? isFeatured,
FeedSnapshotType? snapshotType, FeedSnapshotType? snapshotType,
List<AppLatLng>? routePoints, List<AppLatLng>? routePoints,
List<FeedTelemetrySample>? telemetrySamples,
String? photoUrl, String? photoUrl,
String? photoCaption, String? photoCaption,
bool? isGpsVerified, bool? isGpsVerified,
@ -248,6 +489,7 @@ class SocialFeedItem {
}) { }) {
return SocialFeedItem( return SocialFeedItem(
id: id ?? this.id, id: id ?? this.id,
rideId: rideId ?? this.rideId,
authorUid: authorUid ?? this.authorUid, authorUid: authorUid ?? this.authorUid,
authorDisplayName: authorDisplayName ?? this.authorDisplayName, authorDisplayName: authorDisplayName ?? this.authorDisplayName,
authorHandle: authorHandle ?? this.authorHandle, authorHandle: authorHandle ?? this.authorHandle,
@ -270,6 +512,7 @@ class SocialFeedItem {
isFeatured: isFeatured ?? this.isFeatured, isFeatured: isFeatured ?? this.isFeatured,
snapshotType: snapshotType ?? this.snapshotType, snapshotType: snapshotType ?? this.snapshotType,
routePoints: routePoints ?? this.routePoints, routePoints: routePoints ?? this.routePoints,
telemetrySamples: telemetrySamples ?? this.telemetrySamples,
photoUrl: photoUrl ?? this.photoUrl, photoUrl: photoUrl ?? this.photoUrl,
photoCaption: photoCaption ?? this.photoCaption, photoCaption: photoCaption ?? this.photoCaption,
isGpsVerified: isGpsVerified ?? this.isGpsVerified, isGpsVerified: isGpsVerified ?? this.isGpsVerified,
@ -282,6 +525,7 @@ class SocialFeedItem {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
if (rideId != null) 'rideId': rideId,
'authorUid': authorUid, 'authorUid': authorUid,
'authorDisplayName': authorDisplayName, 'authorDisplayName': authorDisplayName,
'authorHandle': authorHandle, 'authorHandle': authorHandle,
@ -303,10 +547,14 @@ class SocialFeedItem {
if (regionName != null) 'regionName': regionName, if (regionName != null) 'regionName': regionName,
'isFeatured': isFeatured, 'isFeatured': isFeatured,
'snapshotType': snapshotType.name, 'snapshotType': snapshotType.name,
'routePoints': routePoints.map((p) => {'lat': p.latitude, 'lng': p.longitude}).toList(), 'routePoints': routePoints
.map((p) => {'lat': p.latitude, 'lng': p.longitude})
.toList(),
if (telemetrySamples.isNotEmpty)
'telemetrySamples': telemetrySamples.map((s) => s.toJson()).toList(),
if (photoUrl != null) 'photoUrl': photoUrl, if (photoUrl != null) 'photoUrl': photoUrl,
if (photoCaption != null) 'photoCaption': photoCaption, if (photoCaption != null) 'photoCaption': photoCaption,
'isGpsVerified': isGpsVerified, 'isGpsVerified': hasVerifiedGps,
'isDemo': isDemo, 'isDemo': isDemo,
'fistBumpCount': fistBumpCount, 'fistBumpCount': fistBumpCount,
'fistBumpUserIds': fistBumpUserIds, 'fistBumpUserIds': fistBumpUserIds,
@ -326,14 +574,35 @@ class SocialFeedItem {
} }
} }
final telemetryList = json['telemetrySamples'] as List<dynamic>? ?? [];
final samples = telemetryList
.whereType<Map>()
.map((s) => FeedTelemetrySample.fromJson(Map<String, dynamic>.from(s))
.withMovingLeanGate())
.toList();
final commentList = json['comments'] as List<dynamic>? ?? []; final commentList = json['comments'] as List<dynamic>? ?? [];
final comments = commentList final comments = commentList
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map((c) => FeedPostComment.fromJson(c)) .map((c) => FeedPostComment.fromJson(c))
.toList(); .toList();
final distanceMiles = (json['distanceMiles'] as num?)?.toDouble() ?? 0.0;
final durationSeconds = (json['durationSeconds'] as num?)?.toInt() ?? 0;
final isDemo = json['isDemo'] as bool? ?? false;
final rawVerified = json['isGpsVerified'] as bool? ?? false;
final verified = rawVerified &&
verifyGpsCriteria(
isDemo: isDemo,
distanceMiles: distanceMiles,
durationSeconds: durationSeconds,
routePoints: points,
telemetrySamples: samples,
);
return SocialFeedItem( return SocialFeedItem(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
rideId: (json['rideId'] as num?)?.toInt(),
authorUid: json['authorUid'] as String? ?? '', authorUid: json['authorUid'] as String? ?? '',
authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider', authorDisplayName: json['authorDisplayName'] as String? ?? 'Rider',
authorHandle: json['authorHandle'] as String? ?? 'rider', authorHandle: json['authorHandle'] as String? ?? 'rider',
@ -344,8 +613,8 @@ class SocialFeedItem {
timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(), timestamp: _parseDateTime(json['timestamp']) ?? DateTime.now(),
rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve', rideTitle: json['rideTitle'] as String? ?? 'Afternoon Carve',
bikeModel: json['bikeModel'] as String? ?? 'Motorcycle', bikeModel: json['bikeModel'] as String? ?? 'Motorcycle',
distanceMiles: (json['distanceMiles'] as num?)?.toDouble() ?? 0.0, distanceMiles: distanceMiles,
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, durationSeconds: durationSeconds,
maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0, maxLeanAngleDeg: (json['maxLeanAngleDeg'] as num?)?.toDouble() ?? 0.0,
maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT', maxLeanSide: json['maxLeanSide'] as String? ?? 'RIGHT',
paceFactor: json['paceFactor'] as String? ?? '1.0x', paceFactor: json['paceFactor'] as String? ?? '1.0x',
@ -363,13 +632,16 @@ class SocialFeedItem {
? FeedSnapshotType.scenicPhoto ? FeedSnapshotType.scenicPhoto
: FeedSnapshotType.routeTrack, : FeedSnapshotType.routeTrack,
routePoints: points, routePoints: points,
telemetrySamples: samples,
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: verified,
isDemo: json['isDemo'] as bool? ?? false, isDemo: isDemo,
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

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import '../../database/database.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'social_feed_models.dart'; import 'social_feed_models.dart';
@ -363,7 +365,7 @@ class SocialFeedService {
result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList(); result = result.where((i) => i.distanceMiles >= minDistanceMiles).toList();
} }
if (verifiedGpsOnly == true) { if (verifiedGpsOnly == true) {
result = result.where((i) => i.isGpsVerified).toList(); result = result.where((i) => i.hasVerifiedGps).toList();
} }
return result; return result;
@ -433,6 +435,11 @@ class SocialFeedService {
} }
/// Share a recent completed ride session to the community feed. /// Share a recent completed ride session to the community feed.
///
/// When [authoritativeRide] is provided, telemetry metrics (distance,
/// duration, max lean angle, lean side, elevation gain, and ride ID) are
/// derived strictly from [authoritativeRide] so manual/forged telemetry
/// overrides cannot be injected at the submission layer.
Future<SocialFeedItem> shareRecentRide({ Future<SocialFeedItem> shareRecentRide({
required String authorUid, required String authorUid,
required String authorDisplayName, required String authorDisplayName,
@ -443,21 +450,76 @@ class SocialFeedService {
required String locationName, required String locationName,
required String rideTitle, required String rideTitle,
required String bikeModel, required String bikeModel,
required double distanceMiles, double distanceMiles = 0.0,
required int durationSeconds, int durationSeconds = 0,
required double maxLeanAngleDeg, double maxLeanAngleDeg = 0.0,
String maxLeanSide = 'RIGHT', String maxLeanSide = 'RIGHT',
String paceFactor = '1.2x Spirited', String paceFactor = '1.0x Cruiser',
int hazardCount = 0, int hazardCount = 0,
int elevationFt = 1500, int elevationFt = 0,
RidingDiscipline discipline = RidingDiscipline.canyonSport, RidingDiscipline discipline = RidingDiscipline.canyonSport,
String? regionName, String? regionName,
bool isFeatured = false, bool isFeatured = false,
FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack, FeedSnapshotType snapshotType = FeedSnapshotType.routeTrack,
List<AppLatLng> routePoints = const [], List<AppLatLng> routePoints = const [],
List<FeedTelemetrySample> telemetrySamples = const [],
Ride? authoritativeRide,
int? rideId,
bool? isGpsVerified,
}) async { }) async {
double resolvedDistance = distanceMiles;
int resolvedDurationSeconds = durationSeconds;
double resolvedMaxLean = maxLeanAngleDeg;
String resolvedLeanSide = maxLeanSide;
int resolvedElevationFt = elevationFt;
int? resolvedRideId = rideId;
if (authoritativeRide != null) {
resolvedRideId = authoritativeRide.id;
resolvedDistance = authoritativeRide.distanceMiles > 0
? authoritativeRide.distanceMiles
: authoritativeRide.distance;
resolvedDurationSeconds = authoritativeRide.movingTime > 0
? authoritativeRide.movingTime
: (authoritativeRide.endTime != null
? authoritativeRide.endTime!
.difference(authoritativeRide.startTime)
.inSeconds
.clamp(0, 864000)
: 0);
resolvedMaxLean = math.max(
authoritativeRide.maxLeanLeft,
authoritativeRide.maxLeanRight,
);
if (authoritativeRide.maxLeanLeft == 0 &&
authoritativeRide.maxLeanRight == 0) {
resolvedLeanSide = 'NONE';
} else {
resolvedLeanSide =
authoritativeRide.maxLeanRight >= authoritativeRide.maxLeanLeft
? 'RIGHT'
: 'LEFT';
}
resolvedElevationFt =
(authoritativeRide.elevationGain * 3.28084).round();
}
final gatedTelemetrySamples = telemetrySamples
.map((s) => s.withMovingLeanGate())
.toList(growable: false);
final meetsGpsVerification = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: resolvedDistance,
durationSeconds: resolvedDurationSeconds,
routePoints: routePoints,
telemetrySamples: gatedTelemetrySamples,
);
final effectiveGpsVerified = (isGpsVerified ?? true) && meetsGpsVerification;
final newPost = SocialFeedItem( final newPost = SocialFeedItem(
id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}', id: 'feed_post_${DateTime.now().millisecondsSinceEpoch}',
rideId: resolvedRideId,
authorUid: authorUid, authorUid: authorUid,
authorDisplayName: authorDisplayName, authorDisplayName: authorDisplayName,
authorHandle: authorHandle, authorHandle: authorHandle,
@ -468,19 +530,21 @@ class SocialFeedService {
timestamp: DateTime.now(), timestamp: DateTime.now(),
rideTitle: rideTitle, rideTitle: rideTitle,
bikeModel: bikeModel, bikeModel: bikeModel,
distanceMiles: distanceMiles, distanceMiles: resolvedDistance,
durationSeconds: durationSeconds, durationSeconds: resolvedDurationSeconds,
maxLeanAngleDeg: maxLeanAngleDeg, maxLeanAngleDeg: resolvedMaxLean,
maxLeanSide: maxLeanSide, maxLeanSide: resolvedLeanSide,
paceFactor: paceFactor, paceFactor: paceFactor,
hazardCount: hazardCount, hazardCount: hazardCount,
elevationFt: elevationFt, elevationFt: resolvedElevationFt,
discipline: discipline, discipline: discipline,
regionName: regionName, regionName: regionName,
isFeatured: isFeatured, isFeatured: isFeatured,
snapshotType: snapshotType, snapshotType: snapshotType,
routePoints: routePoints, routePoints: routePoints,
isGpsVerified: true, telemetrySamples: gatedTelemetrySamples,
isGpsVerified: effectiveGpsVerified,
isDemo: false,
fistBumpCount: 1, fistBumpCount: 1,
fistBumpUserIds: [authorUid], fistBumpUserIds: [authorUid],
comments: const [], comments: const [],
@ -504,3 +568,4 @@ class SocialFeedService {
_localFeedController.close(); _localFeedController.close();
} }
} }

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -20,6 +21,8 @@ import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart';
void main() { void main() {
@ -344,6 +347,198 @@ void main() {
expect(find.text('Enable'), findsOneWidget); expect(find.text('Enable'), findsOneWidget);
}); });
}); });
group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () {
testWidgets(
'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter',
(tester) async {
await prefs.setString('dashboardLayout', 'touring');
configureViewport(tester, 400, 800);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(find.byType(DashboardMapPanel), findsOneWidget);
expect(find.byType(FlutterMap), findsOneWidget);
expect(
find.byKey(const ValueKey('dashboard_map_recenter_button')),
findsNothing,
);
// Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 35.0,
heading: 90.0,
latitude: 34.0522,
longitude: -118.2437,
gpsAccuracy: 5.0,
gpsAgeMs: 100,
),
);
await tester.pump(const Duration(milliseconds: 50));
var unifiedMap = tester.widget<UnifiedMapView>(
find.byType(UnifiedMapView),
);
var flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001));
expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0522, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2437, 0.0001),
);
// Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 45.0,
heading: 95.0,
latitude: 34.0526,
longitude: -118.2431,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 50));
// Advance past the 250ms trailing follow timer window
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0526, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2431, 0.0001),
);
// Simulate user manual pan / gesture interaction on the Touring map
final element = tester.element(find.byType(DashboardMapPanel));
final container = ProviderScope.containerOf(element);
container.read(mapFollowStateProvider.notifier).userInteracted();
flutterMap.mapController!.move(
flutterMap.mapController!.camera.center,
16.5,
);
await tester.pump();
// Recenter button must appear when follow mode is disabled
final recenterFinder = find.byKey(
const ValueKey('dashboard_map_recenter_button'),
);
expect(recenterFinder, findsOneWidget);
// While panned (follow mode false), incoming GPS updates move the rider marker
// but do NOT hijack the user's panned camera center
final pannedCameraCenter = flutterMap.mapController!.camera.center;
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 50.0,
heading: 100.0,
latitude: 34.0600,
longitude: -118.2500,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(pannedCameraCenter.latitude, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(pannedCameraCenter.longitude, 0.0001),
);
// Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500)
await tester.tap(recenterFinder);
await tester.pump();
expect(recenterFinder, findsNothing);
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0600, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2500, 0.0001),
);
// Rotate to landscape Touring mode and verify GPS follow continues cleanly
configureViewport(tester, 800, 360);
await tester.pump();
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 55.0,
heading: 110.0,
latitude: 34.0700,
longitude: -118.2600,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0700, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2600, 0.0001),
);
},
);
testWidgets(
'Touring map orientation button toggles between North Up and Heading Up (#80)',
(tester) async {
await prefs.setString('dashboardLayout', 'touring');
configureViewport(tester, 400, 800);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
final orientBtn = find.byKey(
const ValueKey('dashboard_map_orientation_button'),
);
expect(orientBtn, findsOneWidget);
expect(find.text('NORTH UP'), findsOneWidget);
// Tap orientation button to switch to Heading Up
await tester.tap(orientBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('HEADING UP'), findsOneWidget);
// Tap again to switch back to North Up
await tester.tap(orientBtn);
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(find.text('NORTH UP'), findsOneWidget);
},
);
});
} }
class _TestRideStateMachine extends StateNotifier<RideSession> class _TestRideStateMachine extends StateNotifier<RideSession>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Map Smoothness & Orientation Angle Normalization (#79 & #80)', () {
test('shortestAngleDiff correctly calculates minimal circular angular distance', () {
// Direct small adjustments
expect(shortestAngleDiff(10.0, 20.0), closeTo(10.0, 0.001));
expect(shortestAngleDiff(20.0, 10.0), closeTo(-10.0, 0.001));
// Circular transition crossing 0° / 360°
// Moving from 359° to 1° should be +2° clockwise, NOT -358° counter-clockwise
expect(shortestAngleDiff(359.0, 1.0), closeTo(2.0, 0.001));
// Moving from 1° to 359° should be -2° counter-clockwise, NOT +358° clockwise
expect(shortestAngleDiff(1.0, 359.0), closeTo(-2.0, 0.001));
// 350° to 10° should be +20°
expect(shortestAngleDiff(350.0, 10.0), closeTo(20.0, 0.001));
// 10° to 350° should be -20°
expect(shortestAngleDiff(10.0, 350.0), closeTo(-20.0, 0.001));
// Half-circle
expect(shortestAngleDiff(0.0, 180.0).abs(), closeTo(180.0, 0.001));
});
test('normalizeAngle correctly constrains values to [0.0, 360.0)', () {
expect(normalizeAngle(0.0), closeTo(0.0, 0.001));
expect(normalizeAngle(360.0), closeTo(0.0, 0.001));
expect(normalizeAngle(365.0), closeTo(5.0, 0.001));
expect(normalizeAngle(-10.0), closeTo(350.0, 0.001));
expect(normalizeAngle(-370.0), closeTo(350.0, 0.001));
expect(normalizeAngle(720.0), closeTo(0.0, 0.001));
});
});
group('MapOrientation State & Persistence (#80)', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('MapOrientationNotifier toggles and persists orientation', () async {
final notifier = MapOrientationNotifier();
expect(notifier.state, MapOrientation.northUp);
await notifier.toggle();
expect(notifier.state, MapOrientation.headingUp);
await notifier.toggle();
expect(notifier.state, MapOrientation.northUp);
await notifier.setOrientation(MapOrientation.headingUp);
expect(notifier.state, MapOrientation.headingUp);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('selected_map_orientation'), 'headingUp');
});
});
}

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
/// Regression for Issue #52 / BUG-005 follow-up: the "Forensic CSV & PDF Export" Pro
/// upsell sheet showed "BOTTOM OVERFLOWED BY 90 PIXELS" in landscape on a Pixel 8a
/// (~914x411 dp) because showProUpgradeSheet built a non-scrolling Column inside a default
/// modal bottom sheet, which Flutter caps at 9/16 of the screen height.
class _FakeRideStateMachine extends StateNotifier<RideSession>
implements RideStateMachine {
_FakeRideStateMachine(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _pumpHost(
WidgetTester tester, {
required Size viewport,
double textScale = 1.0,
}) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
rideStateMachineProvider.overrideWith(
(ref) => _FakeRideStateMachine(RideSession(state: RideState.idle)),
),
],
child: MediaQuery(
data: MediaQueryData(
size: viewport,
textScaler: TextScaler.linear(textScale),
),
child: MaterialApp(
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => ElevatedButton(
onPressed: () => showProUpgradeSheet(
context,
ref,
feature: MotorSignalFeature.csvExport,
),
child: const Text('Open upsell'),
),
),
),
),
),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final viewport in const [
Size(914, 411), // Pixel 8a landscape
Size(800, 360),
Size(780, 360),
]) {
for (final scale in const [1.0, 1.3]) {
testWidgets(
'Pro upgrade sheet has no bottom overflow in landscape '
'${viewport.width.toInt()}x${viewport.height.toInt()} (text scale $scale)',
(tester) async {
await _pumpHost(tester, viewport: viewport, textScale: scale);
await tester.tap(find.text('Open upsell'));
await tester.pumpAndSettle();
expect(find.text('Forensic CSV & PDF Export'), findsOneWidget);
expect(find.text('VIEW MOTORSIGNAL PRO'), findsOneWidget);
expect(find.text('NOT NOW'), findsOneWidget);
// No RenderFlex overflow exception.
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets('NOT NOW is reachable and dismisses the sheet in landscape', (
tester,
) async {
await _pumpHost(tester, viewport: const Size(800, 360), textScale: 1.3);
await tester.tap(find.text('Open upsell'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('NOT NOW'));
await tester.tap(find.text('NOT NOW'));
await tester.pumpAndSettle();
expect(find.text('VIEW MOTORSIGNAL PRO'), findsNothing);
expect(tester.takeException(), isNull);
});
}